1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-02-19 19:59:59 +02:00

Merge branch 'main' into feat/add_url_prop_frontend

This commit is contained in:
Bharat 2021-04-09 01:25:43 +05:30
commit 1640923001
53 changed files with 971 additions and 630 deletions

View File

@ -6,6 +6,7 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"github.com/google/uuid"
@ -13,9 +14,10 @@ import (
)
var sessionToken string = "su-" + uuid.New().String()
var serverExecutable string = filepath.Join(filepath.Dir(os.Executable()), "focalboard-server")
func runServer(ctx context.Context) {
cmd := exec.CommandContext(ctx, "./focalboard-server", "--monitorpid", strconv.FormatInt(int64(os.Getpid()), 10), "-single-user")
cmd := exec.CommandContext(ctx, serverExecutable, "--monitorpid", strconv.FormatInt(int64(os.Getpid()), 10), "-single-user")
cmd.Env = []string{fmt.Sprintf("FOCALBOARD_SINGLE_USER_TOKEN=%s", sessionToken)}
cmd.Stdout = os.Stdout
err := cmd.Run()

View File

@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
8014951C261598D600A51700 /* PortUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8014951B261598D600A51700 /* PortUtils.swift */; };
80D6DEBB252E13CB00AEED9E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */; };
80D6DEBD252E13CB00AEED9E /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6DEBC252E13CB00AEED9E /* ViewController.swift */; };
80D6DEBF252E13CD00AEED9E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 80D6DEBE252E13CD00AEED9E /* Assets.xcassets */; };
@ -35,6 +36,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
8014951B261598D600A51700 /* PortUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortUtils.swift; sourceTree = "<group>"; };
80D6DEB7252E13CB00AEED9E /* Focalboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Focalboard.app; sourceTree = BUILT_PRODUCTS_DIR; };
80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
80D6DEBC252E13CB00AEED9E /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
@ -103,6 +105,7 @@
isa = PBXGroup;
children = (
80D6DEBA252E13CB00AEED9E /* AppDelegate.swift */,
8014951B261598D600A51700 /* PortUtils.swift */,
80D6DEBC252E13CB00AEED9E /* ViewController.swift */,
80D6DF17252F9BDE00AEED9E /* AutoSaveWindowController.swift */,
80D6DEBE252E13CD00AEED9E /* Assets.xcassets */,
@ -283,6 +286,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
8014951C261598D600A51700 /* PortUtils.swift in Sources */,
80D6DF18252F9BDE00AEED9E /* AutoSaveWindowController.swift in Sources */,
80D6DEBD252E13CB00AEED9E /* ViewController.swift in Sources */,
80D6DEBB252E13CB00AEED9E /* AppDelegate.swift in Sources */,

View File

@ -88,7 +88,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return "su-" + randomNumber
}
private func getFreePort() {
if PortUtils.isPortFree(in_port_t(serverPort)) {
return
}
serverPort = Int(PortUtils.getFreePort())
}
private func startServer() {
getFreePort()
sessionToken = generateSessionToken()
let cwdUrl = webFolder()

View File

@ -578,6 +578,13 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="hB3-LF-h0Y"/>
<menuItem title="Diagnostics Info" id="oQB-FY-dcB">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="showDiagnosticsInfo:" target="Ady-hI-5gd" id="hkZ-Xn-3T1"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="dRF-4R-egy"/>
<menuItem title="Show Sidebar" keyEquivalent="s" id="kIP-vf-haE">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>

View File

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Foundation
class PortUtils {
static func isPortFree(_ port: in_port_t) -> Bool {
let socketFileDescriptor = socket(AF_INET, SOCK_STREAM, 0)
if socketFileDescriptor == -1 {
return false
}
var addr = sockaddr_in()
let sizeOfSockkAddr = MemoryLayout<sockaddr_in>.size
addr.sin_len = __uint8_t(sizeOfSockkAddr)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = Int(OSHostByteOrder()) == OSLittleEndian ? _OSSwapInt16(port) : port
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
addr.sin_zero = (0, 0, 0, 0, 0, 0, 0, 0)
var bind_addr = sockaddr()
memcpy(&bind_addr, &addr, Int(sizeOfSockkAddr))
if Darwin.bind(socketFileDescriptor, &bind_addr, socklen_t(sizeOfSockkAddr)) == -1 {
release(socket: socketFileDescriptor)
return false
}
if listen(socketFileDescriptor, SOMAXCONN ) == -1 {
release(socket: socketFileDescriptor)
return false
}
release(socket: socketFileDescriptor)
return true
}
private static func release(socket: Int32) {
Darwin.shutdown(socket, SHUT_RDWR)
close(socket)
}
static func getFreePort() -> in_port_t {
var portNum: in_port_t = 0
for i in 50000..<65000 {
let isFree = isPortFree(in_port_t(i))
if isFree {
portNum = in_port_t(i)
return portNum
}
}
return in_port_t(0)
}
}

View File

@ -45,6 +45,17 @@ class ViewController:
WKWebsiteDataStore.default().removeData(ofTypes: websiteDataTypes as! Set<String>, modifiedSince: date, completionHandler:{ })
}
@IBAction func showDiagnosticsInfo(_ sender: NSObject) {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
let alert: NSAlert = NSAlert()
alert.messageText = "Diagnostics info"
alert.informativeText = "Port: \(appDelegate.serverPort)"
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc func onServerStarted() {
NSLog("onServerStarted")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {

View File

@ -41,6 +41,8 @@ func ReadConfigFile() (*Configuration, error) {
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("json") // REQUIRED if the config file does not have the extension in the name
viper.AddConfigPath(".") // optionally look for config in the working directory
viper.SetEnvPrefix("focalboard")
viper.AutomaticEnv() // read config values from env like FOCALBOARD_SERVERROOT=...
viper.SetDefault("ServerRoot", DefaultServerRoot)
viper.SetDefault("Port", DefaultPort)
viper.SetDefault("DBType", "sqlite3")

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Löschen",
"BoardCard.duplicate": "Duplizieren",
"BoardCard.untitled": "Unbenannt",
"BoardComponent.add-a-group": "+ Hinzufügen einer Gruppe",
"BoardComponent.delete": "Löschen",
"BoardComponent.hidden-columns": "Versteckte Spalten",
@ -131,6 +128,5 @@
"ViewTitle.random-icon": "Zufällig",
"ViewTitle.remove-icon": "Symbol entfernen",
"ViewTitle.show-description": "Beschreibung anzeigen",
"ViewTitle.untitled-board": "Unbenanntes Board",
"WorkspaceComponent.editing-board-template": "Sie bearbeiten eine Board Vorlage"
"ViewTitle.untitled-board": "Unbenanntes Board"
}

View File

@ -158,5 +158,6 @@
"ViewTitle.remove-icon": "Remove icon",
"ViewTitle.show-description": "show description",
"ViewTitle.untitled-board": "Untitled board",
"Workspace.editing-board-template": "You're editing a board template"
"Workspace.editing-board-template": "You're editing a board template",
"default-properties.title": "Title"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Borrar",
"BoardCard.duplicate": "Duplicar",
"BoardCard.untitled": "Sin título",
"BoardComponent.add-a-group": "+ Añadir un grupo",
"BoardComponent.delete": "Borrar",
"BoardComponent.hidden-columns": "Columnas Ocultas",
@ -133,6 +130,5 @@
"ViewTitle.random-icon": "Aleatorio",
"ViewTitle.remove-icon": "Quitar Icono",
"ViewTitle.show-description": "mostrar descripción",
"ViewTitle.untitled-board": "Panel sin título",
"WorkspaceComponent.editing-board-template": "Está editando un plantilla de panel"
"ViewTitle.untitled-board": "Panel sin título"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Supprimer",
"BoardCard.duplicate": "Dupliquer",
"BoardCard.untitled": "Sans titre",
"BoardComponent.add-a-group": "+ Ajouter un groupe",
"BoardComponent.delete": "Supprimer",
"BoardComponent.hidden-columns": "Colonnes cachées",
@ -132,6 +129,5 @@
"ViewTitle.random-icon": "Aléatoire",
"ViewTitle.remove-icon": "Supprimer l'icône",
"ViewTitle.show-description": "montrer la description",
"ViewTitle.untitled-board": "Tableau sans titre",
"WorkspaceComponent.editing-board-template": "Vous éditez un modèle de tableau"
"ViewTitle.untitled-board": "Tableau sans titre"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "削除",
"BoardCard.duplicate": "複製",
"BoardCard.untitled": "Untitled",
"BoardComponent.add-a-group": "+ グループを追加する",
"BoardComponent.delete": "削除",
"BoardComponent.hidden-columns": "非表示",
@ -70,9 +67,9 @@
"Sidebar.add-board": "+ ボードを追加する",
"Sidebar.add-template": "+ 新しいテンプレート",
"Sidebar.changePassword": "パスワードを変更する",
"Sidebar.chinese": "Chinese",
"Sidebar.dark-theme": "Dark theme",
"Sidebar.default-theme": "Default theme",
"Sidebar.chinese": "中国語",
"Sidebar.dark-theme": "ダークテーマ",
"Sidebar.default-theme": "デフォルトテーマ",
"Sidebar.delete-board": "ボードを削除",
"Sidebar.delete-template": "削除",
"Sidebar.duplicate-board": "ボードを複製する",
@ -86,7 +83,7 @@
"Sidebar.import-archive": "インポート",
"Sidebar.invite-users": "ユーザーを招待する",
"Sidebar.japanese": "日本語",
"Sidebar.light-theme": "Light theme",
"Sidebar.light-theme": "ライトテーマ",
"Sidebar.logout": "ログアウト",
"Sidebar.no-views-in-board": "ページがありません",
"Sidebar.occitan": "Occitan",
@ -98,9 +95,9 @@
"Sidebar.spanish": "Spanish",
"Sidebar.template-from-board": "ボードから新しいテンプレートを作成",
"Sidebar.turkish": "Turkish",
"Sidebar.untitled": "ボード",
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",
"Sidebar.untitled": "名無し",
"Sidebar.untitled-board": "(名無しのボード)",
"Sidebar.untitled-view": "(名無しのビュー)",
"TableComponent.add-icon": "アイコンを追加する",
"TableComponent.name": "名前",
"TableComponent.plus-new": "+ 新規",
@ -136,6 +133,5 @@
"ViewTitle.random-icon": "ランダム",
"ViewTitle.remove-icon": "アイコンを削除する",
"ViewTitle.show-description": "説明を表示",
"ViewTitle.untitled-board": "Untitled board",
"WorkspaceComponent.editing-board-template": "ボードテンプレートを編集しています"
"ViewTitle.untitled-board": "Untitled board"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Verwijderen",
"BoardCard.duplicate": "Kopiëren",
"BoardCard.untitled": "Zonder titel",
"BoardComponent.add-a-group": "+ Een groep toevoegen",
"BoardComponent.delete": "Verwijderen",
"BoardComponent.hidden-columns": "Verborgen kolommen",
@ -23,23 +20,40 @@
"ContentBlock.Delete": "Verwijderen",
"ContentBlock.DeleteAction": "verwijderen",
"ContentBlock.addElement": "voeg {type} toe",
"ContentBlock.checkbox": "selectievakje",
"ContentBlock.divider": "verdeler",
"ContentBlock.editCardCheckbox": "Aangevinkt selectievakje",
"ContentBlock.editCardCheckboxText": "kaarttekst bewerken",
"ContentBlock.editCardText": "kaarttekst bewerken",
"ContentBlock.editText": "Tekst bewerken...",
"ContentBlock.image": "afbeelding",
"ContentBlock.insertAbove": "Hierboven invoegen",
"ContentBlock.moveDown": "Naar beneden verplaatsen",
"ContentBlock.moveUp": "Naar boven verplaatsen",
"ContentBlock.text": "tekst",
"Dialog.closeDialog": "Dialoogvenster sluiten",
"EmptyCenterPanel.no-content": "Voeg een bord toe of selecteer een bord in de zijbalk om te beginnen.",
"EmptyCenterPanel.workspace": "Dit is de werkruimte voor:",
"Filter.includes": "bevat",
"Filter.is-empty": "is leeg",
"Filter.is-not-empty": "is niet leeg",
"Filter.not-includes": "bevat niet",
"FilterComponent.add-filter": "+ Filter toevoegen",
"FilterComponent.delete": "Verwijderen",
"GalleryCard.delete": "Verwijderen",
"GalleryCard.duplicate": "Kopiëren",
"KanbanCard.delete": "Verwijderen",
"KanbanCard.duplicate": "Kopiëren",
"KanbanCard.untitled": "Titelloos",
"Mutator.duplicate-board": "bord kopiëren",
"Mutator.new-board-from-template": "nieuw bord van sjabloon",
"Mutator.new-card-from-template": "nieuwe kaart van sjabloon",
"Mutator.new-template-from-board": "nieuw sjabloon van bord",
"Mutator.new-template-from-card": "nieuw sjabloon van kaart",
"PropertyMenu.Delete": "Verwijderen",
"PropertyMenu.changeType": "Type eigenschap wijzigen",
"PropertyMenu.typeTitle": "Type",
"PropertyType.Checkbox": "Checkbox",
"PropertyType.Checkbox": "Selectievakje",
"PropertyType.CreatedBy": "Gemaakt door",
"PropertyType.CreatedTime": "Created Time",
"PropertyType.Email": "E-mail",
@ -96,6 +110,7 @@
"Sidebar.set-theme": "Thema instellen",
"Sidebar.settings": "Instellingen",
"Sidebar.spanish": "Spaans",
"Sidebar.system-theme": "Systeemthema",
"Sidebar.template-from-board": "Nieuw sjabloon van bord",
"Sidebar.turkish": "Turks",
"Sidebar.untitled": "Titelloos",
@ -112,8 +127,14 @@
"TableHeaderMenu.sort-ascending": "Sorteer oplopend",
"TableHeaderMenu.sort-descending": "Aflopend sorteren",
"TableRow.open": "Openen",
"View.AddView": "Weergave toevoegen",
"View.Board": "Bord",
"View.DeleteView": "Weergave verwijderen",
"View.DuplicateView": "Weergave kopiëren",
"View.NewBoardTitle": "Bordweergave",
"View.NewGalleryTitle": "Galerie bekijken",
"View.NewTableTitle": "Tabelweergave",
"View.Table": "Tabel",
"ViewHeader.add-template": "+ Nieuw sjabloon",
"ViewHeader.delete-template": "Verwijderen",
"ViewHeader.edit-template": "Bewerken",
@ -137,5 +158,5 @@
"ViewTitle.remove-icon": "Verwijder pictogram",
"ViewTitle.show-description": "beschrijving tonen",
"ViewTitle.untitled-board": "Titelloze bord",
"WorkspaceComponent.editing-board-template": "Je bent een bordsjabloon aan het bewerken"
"Workspace.editing-board-template": "Je bent een bordsjabloon aan het bewerken"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Suprimir",
"BoardCard.duplicate": "Duplicar",
"BoardCard.untitled": "Sens títol",
"BoardComponent.add-a-group": "+ Apondre un grop",
"BoardComponent.delete": "Suprimir",
"BoardComponent.hidden-columns": "Colomnas rescondudas",
@ -136,6 +133,5 @@
"ViewTitle.random-icon": "Aleatòria",
"ViewTitle.remove-icon": "Suprimir l'icòna",
"ViewTitle.show-description": "mostrar la descripcion",
"ViewTitle.untitled-board": "Tablèu sens títol",
"WorkspaceComponent.editing-board-template": "Sètz a modificar un modèl de tablèu"
"ViewTitle.untitled-board": "Tablèu sens títol"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Удалить",
"BoardCard.duplicate": "Дубликат",
"BoardCard.untitled": "Без названия",
"BoardComponent.add-a-group": "+ Добавить группу",
"BoardComponent.delete": "Удалить",
"BoardComponent.hidden-columns": "Скрытые колонки",
@ -23,20 +20,37 @@
"ContentBlock.Delete": "Удалить",
"ContentBlock.DeleteAction": "удалить",
"ContentBlock.addElement": "добавить {type}",
"ContentBlock.checkbox": "флажок",
"ContentBlock.divider": "разделитель",
"ContentBlock.editCardCheckbox": "помеченный флажок",
"ContentBlock.editCardCheckboxText": "редактировать текст карточки",
"ContentBlock.editCardText": "редактировать текст карточки",
"ContentBlock.editText": "Редактировать текст...",
"ContentBlock.image": "изображение",
"ContentBlock.insertAbove": "Вставить выше",
"ContentBlock.moveDown": "Опустить",
"ContentBlock.moveUp": "Поднять",
"ContentBlock.text": "текст",
"Dialog.closeDialog": "Закрыть диалог",
"EmptyCenterPanel.no-content": "Добавьте или выберите доску на боковой панели, чтобы начать работу.",
"EmptyCenterPanel.workspace": "Это рабочее пространство для:",
"Filter.includes": "содержит",
"Filter.is-empty": "пусто",
"Filter.is-not-empty": "не пусто",
"Filter.not-includes": "не содержит",
"FilterComponent.add-filter": "+ Добавить фильтр",
"FilterComponent.delete": "Удалить",
"GalleryCard.delete": "Удалить",
"GalleryCard.duplicate": "Создать дубликат",
"KanbanCard.delete": "Удалить",
"KanbanCard.duplicate": "Создать дубликат",
"KanbanCard.untitled": "Без названия",
"Mutator.duplicate-board": "сделать дубликат доски",
"Mutator.new-board-from-template": "новая доска из шаблона",
"Mutator.new-card-from-template": "новая карточка из шаблона",
"Mutator.new-template-from-board": "новый шаблон из доски",
"Mutator.new-template-from-card": "новый шаблон из карточки",
"PropertyMenu.Delete": "Удалить",
"PropertyMenu.changeType": "Изменить тип свойства",
"PropertyMenu.typeTitle": "Тип",
"PropertyType.Checkbox": "Флажок",
@ -70,6 +84,7 @@
"Sidebar.add-board": "+ Добавить доску",
"Sidebar.add-template": "+ Новый шаблон",
"Sidebar.changePassword": "Изменить пароль",
"Sidebar.chinese": "Китайский",
"Sidebar.dark-theme": "Тёмная тема",
"Sidebar.default-theme": "Тема по умолчанию",
"Sidebar.delete-board": "Удалить доску",
@ -88,13 +103,16 @@
"Sidebar.light-theme": "Светлая тема",
"Sidebar.logout": "Выйти",
"Sidebar.no-views-in-board": "Внутри нет страниц",
"Sidebar.occitan": "Окситанский",
"Sidebar.russian": "Русский",
"Sidebar.select-a-template": "Выберите шаблон",
"Sidebar.set-language": "Язык",
"Sidebar.set-theme": "Тема",
"Sidebar.settings": "Настройки",
"Sidebar.spanish": "Испанский",
"Sidebar.system-theme": "Системная тема",
"Sidebar.template-from-board": "Новый шаблон из доски",
"Sidebar.turkish": "Турецкий",
"Sidebar.untitled": "Без названия",
"Sidebar.untitled-board": "(Доска без названия)",
"Sidebar.untitled-view": "(Вид без названия)",
@ -109,13 +127,21 @@
"TableHeaderMenu.sort-ascending": "Сортировать по возрастанию",
"TableHeaderMenu.sort-descending": "Сортировать по убыванию",
"TableRow.open": "Открыть",
"View.AddView": "Добавить вид",
"View.Board": "Доска",
"View.DeleteView": "Удалить вид",
"View.DuplicateView": "Создать дубликат вида",
"View.NewBoardTitle": "Вид доски",
"View.NewGalleryTitle": "Представление \"галерея\"",
"View.NewTableTitle": "Вид таблицы",
"View.Table": "Таблица",
"ViewHeader.add-template": "+ Новый шаблон",
"ViewHeader.delete-template": "Удалить",
"ViewHeader.edit-template": "Редактировать",
"ViewHeader.empty-card": "Очистить карточку",
"ViewHeader.export-complete": "Экспорт завершен!",
"ViewHeader.export-csv": "Экспорт в CSV",
"ViewHeader.export-failed": "Ошибка экспорта!",
"ViewHeader.filter": "Фильтр",
"ViewHeader.group-by": "Сгруппировать по: {property}",
"ViewHeader.new": "Создать",
@ -132,5 +158,5 @@
"ViewTitle.remove-icon": "Убрать иконку",
"ViewTitle.show-description": "показать описание",
"ViewTitle.untitled-board": "Доска без названия",
"WorkspaceComponent.editing-board-template": "Вы редактируете шаблон доски"
"Workspace.editing-board-template": "Вы редактируете шаблон доски"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "Sil",
"BoardCard.duplicate": "Kopya Oluştur",
"BoardCard.untitled": "Başlıksız",
"BoardComponent.add-a-group": "+ Grup ekle",
"BoardComponent.delete": "Sil",
"BoardComponent.hidden-columns": "Gizli sütunlar",
@ -23,15 +20,26 @@
"ContentBlock.Delete": "Sil",
"ContentBlock.DeleteAction": "sil",
"ContentBlock.addElement": "{type} ekle",
"ContentBlock.checkbox": "işaret kutusu",
"ContentBlock.divider": "ayıraç",
"ContentBlock.editCardCheckbox": "değiştirilmiş işaret kutusu",
"ContentBlock.editCardCheckboxText": "kart metnini düzenle",
"ContentBlock.editCardText": "kart metnini düzenle",
"ContentBlock.editText": "Metni düzenle...",
"ContentBlock.image": "görsel",
"ContentBlock.insertAbove": "Üste ekle",
"ContentBlock.moveDown": "Alta taşı",
"ContentBlock.moveUp": "Yukarı taşı",
"ContentBlock.text": "metin",
"Filter.includes": "içerir",
"Filter.is-empty": "boş",
"Filter.is-not-empty": "boş değil",
"Filter.not-includes": "içermez",
"FilterComponent.add-filter": "+ Filtre ekle",
"FilterComponent.delete": "Sil",
"KanbanCard.delete": "Sil",
"KanbanCard.duplicate": "Kopyala",
"KanbanCard.untitled": "Başlıksız",
"Mutator.duplicate-board": "panonun kopyasını oluştur",
"Mutator.new-board-from-template": "şablondan yeni pano oluştur",
"Mutator.new-card-from-template": "şablondan yeni kart oluştur",
@ -96,6 +104,7 @@
"Sidebar.set-theme": "Tema ayarla",
"Sidebar.settings": "Ayarlar",
"Sidebar.spanish": "İspanyolca",
"Sidebar.system-theme": "Sistem teması",
"Sidebar.template-from-board": "Panodan yeni şablon",
"Sidebar.turkish": "Türkçe",
"Sidebar.untitled": "Başlıksız",
@ -113,6 +122,7 @@
"TableHeaderMenu.sort-descending": "Azalan sıralama",
"TableRow.open": "Aç",
"View.NewBoardTitle": "Pano görünümü",
"View.NewGalleryTitle": "Galeri görünümü",
"View.NewTableTitle": "Tablo görünümü",
"ViewHeader.add-template": "+ Yeni şablon",
"ViewHeader.delete-template": "Sil",
@ -137,5 +147,5 @@
"ViewTitle.remove-icon": "Simgeyi kaldır",
"ViewTitle.show-description": "açıklamayı göster",
"ViewTitle.untitled-board": "Başlıksız pano",
"WorkspaceComponent.editing-board-template": "Bir pano şablonunu düzenliyorsun"
"Workspace.editing-board-template": "Bir tahta kalıbını düzenliyorsunuz"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "删除",
"BoardCard.duplicate": "制作副本",
"BoardCard.untitled": "无标题",
"BoardComponent.add-a-group": "+ 新增群组",
"BoardComponent.delete": "删除",
"BoardComponent.hidden-columns": "隐藏列",
@ -23,15 +20,24 @@
"ContentBlock.Delete": "删除",
"ContentBlock.DeleteAction": "删除",
"ContentBlock.addElement": "新增 {type}",
"ContentBlock.checkbox": "复选框",
"ContentBlock.editCardCheckboxText": "编辑卡片文字",
"ContentBlock.editCardText": "编辑卡片文字",
"ContentBlock.editText": "编辑文字...",
"ContentBlock.image": "图片",
"ContentBlock.insertAbove": "在上方插入",
"ContentBlock.moveDown": "下移",
"ContentBlock.moveUp": "上移",
"ContentBlock.text": "文字",
"Filter.includes": "含有",
"Filter.is-empty": "爲空",
"Filter.is-not-empty": "不空",
"Filter.is-empty": "空",
"Filter.is-not-empty": "不空",
"Filter.not-includes": "不包含",
"FilterComponent.add-filter": "+ 增加过滤条件",
"FilterComponent.delete": "删除",
"KanbanCard.delete": "删除",
"KanbanCard.duplicate": "复本",
"KanbanCard.untitled": "无标题",
"Mutator.duplicate-board": "复制版面",
"Mutator.new-board-from-template": "使用范本新增版面",
"Mutator.new-card-from-template": "使用范本新增卡片",
@ -96,6 +102,7 @@
"Sidebar.set-theme": "设定佈景主题",
"Sidebar.settings": "设定",
"Sidebar.spanish": "西班牙文",
"Sidebar.system-theme": "系统主题风格",
"Sidebar.template-from-board": "从版面新增范本",
"Sidebar.turkish": "土耳其语",
"Sidebar.untitled": "无标题",
@ -137,5 +144,5 @@
"ViewTitle.remove-icon": "移除图标",
"ViewTitle.show-description": "显示叙述",
"ViewTitle.untitled-board": "无标题版面",
"WorkspaceComponent.editing-board-template": "您正在编辑版面范本"
"Workspace.editing-board-template": "您正在编辑板模板"
}

View File

@ -1,7 +1,4 @@
{
"BoardCard.delete": "刪除",
"BoardCard.duplicate": "製作副本",
"BoardCard.untitled": "無標題",
"BoardComponent.add-a-group": "+ 新增群組",
"BoardComponent.delete": "刪除",
"BoardComponent.hidden-columns": "隱藏列",
@ -136,6 +133,5 @@
"ViewTitle.random-icon": "隨機",
"ViewTitle.remove-icon": "移除圖標",
"ViewTitle.show-description": "顯示敘述",
"ViewTitle.untitled-board": "無標題版面",
"WorkspaceComponent.editing-board-template": "您正在編輯版面範本"
"ViewTitle.untitled-board": "無標題版面"
}

View File

@ -1646,6 +1646,21 @@
"fastq": "^1.6.0"
}
},
"@react-dnd/asap": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz",
"integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ=="
},
"@react-dnd/invariant": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw=="
},
"@react-dnd/shallowequal": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg=="
},
"@samverschueren/stream-to-observable": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz",
@ -4331,6 +4346,16 @@
"path-type": "^4.0.0"
}
},
"dnd-core": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.0.tgz",
"integrity": "sha512-wTDYKyjSqWuYw3ZG0GJ7k+UIfzxTNoZLjDrut37PbcPGNfwhlKYlPUqjAKUjOOv80izshUiqusaKgJPItXSevA==",
"requires": {
"@react-dnd/asap": "^4.0.0",
"@react-dnd/invariant": "^2.0.0",
"redux": "^4.0.5"
}
},
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -10185,6 +10210,35 @@
"object-assign": "^4.1.1"
}
},
"react-dnd": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.2.tgz",
"integrity": "sha512-JoEL78sBCg8SzjOKMlkR70GWaPORudhWuTNqJ56lb2P8Vq0eM2+er3ZrMGiSDhOmzaRPuA9SNBz46nHCrjn11A==",
"requires": {
"@react-dnd/invariant": "^2.0.0",
"@react-dnd/shallowequal": "^2.0.0",
"dnd-core": "14.0.0",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
}
},
"react-dnd-html5-backend": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.0.tgz",
"integrity": "sha512-2wAQqRFC1hbRGmk6+dKhOXsyQQOn3cN8PSZyOUeOun9J8t3tjZ7PS2+aFu7CVu2ujMDwTJR3VTwZh8pj2kCv7g==",
"requires": {
"dnd-core": "14.0.0"
}
},
"react-dnd-touch-backend": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-14.0.0.tgz",
"integrity": "sha512-fNt3isf9h0xgjj86dIXhBi3dJ7OhC88vKUYdEvsOGypdp3LOFD+TobBAuBu00v26WmJ6II6xqbkhOx+KOcyHxQ==",
"requires": {
"@react-dnd/invariant": "^2.0.0",
"dnd-core": "14.0.0"
}
},
"react-dom": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
@ -10428,6 +10482,15 @@
"strip-indent": "^3.0.0"
}
},
"redux": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz",
"integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==",
"requires": {
"loose-envify": "^1.4.0",
"symbol-observable": "^1.2.0"
}
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
@ -11677,8 +11740,7 @@
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"optional": true
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
},
"symbol-tree": {
"version": "3.2.4",

View File

@ -25,6 +25,9 @@
"marked": ">=2.0.1",
"nanoevents": "^5.1.13",
"react": "^17.0.2",
"react-dnd": "^14.0.2",
"react-dnd-html5-backend": "^14.0.0",
"react-dnd-touch-backend": "^14.0.0",
"react-dom": "^17.0.2",
"react-hot-keys": "^2.6.2",
"react-hotkeys-hook": "^3.3.0",

View File

@ -8,6 +8,9 @@ import {
Route,
Switch,
} from 'react-router-dom'
import {DndProvider} from 'react-dnd'
import {HTML5Backend} from 'react-dnd-html5-backend'
import {TouchBackend} from 'react-dnd-touch-backend'
import {FlashMessages} from './components/flashMessages'
import {getCurrentLanguage, getMessages, storeLanguage} from './i18n'
@ -18,6 +21,7 @@ import ErrorPage from './pages/errorPage'
import LoginPage from './pages/loginPage'
import RegisterPage from './pages/registerPage'
import {IUser, UserContext} from './user'
import {Utils} from './utils'
type State = {
language: string,
@ -51,78 +55,80 @@ export default class App extends React.PureComponent<unknown, State> {
locale={this.state.language}
messages={getMessages(this.state.language)}
>
<UserContext.Provider value={this.state.user}>
<FlashMessages milliseconds={2000}/>
<Router forceRefresh={true}>
<div id='frame'>
<div id='main'>
<Switch>
<Route path='/error'>
<ErrorPage/>
</Route>
<Route path='/login'>
<LoginPage/>
</Route>
<Route path='/register'>
<RegisterPage/>
</Route>
<Route path='/change_password'>
<ChangePasswordPage/>
</Route>
<Route path='/shared'>
<BoardPage
workspaceId='0'
readonly={true}
setLanguage={this.setAndStoreLanguage}
<DndProvider backend={Utils.isMobile() ? TouchBackend : HTML5Backend}>
<UserContext.Provider value={this.state.user}>
<FlashMessages milliseconds={2000}/>
<Router forceRefresh={true}>
<div id='frame'>
<div id='main'>
<Switch>
<Route path='/error'>
<ErrorPage/>
</Route>
<Route path='/login'>
<LoginPage/>
</Route>
<Route path='/register'>
<RegisterPage/>
</Route>
<Route path='/change_password'>
<ChangePasswordPage/>
</Route>
<Route path='/shared'>
<BoardPage
workspaceId='0'
readonly={true}
setLanguage={this.setAndStoreLanguage}
/>
</Route>
<Route path='/board'>
{this.state.initialLoad && !this.state.user && <Redirect to='/login'/>}
<BoardPage
workspaceId='0'
setLanguage={this.setAndStoreLanguage}
/>
</Route>
<Route
path='/workspace/:workspaceId/shared'
render={({match}) => {
return (
<BoardPage
workspaceId={match.params.workspaceId}
readonly={true}
setLanguage={this.setAndStoreLanguage}
/>
)
}}
/>
</Route>
<Route path='/board'>
{this.state.initialLoad && !this.state.user && <Redirect to='/login'/>}
<BoardPage
workspaceId='0'
setLanguage={this.setAndStoreLanguage}
<Route
path='/workspace/:workspaceId/'
render={({match}) => {
if (this.state.initialLoad && !this.state.user) {
const redirectUrl = `/workspace/${match.params.workspaceId}/`
const loginUrl = `/login?r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
}
return (
<BoardPage
workspaceId={match.params.workspaceId}
setLanguage={this.setAndStoreLanguage}
/>
)
}}
/>
</Route>
<Route
path='/workspace/:workspaceId/shared'
render={({match}) => {
return (
<BoardPage
workspaceId={match.params.workspaceId}
readonly={true}
setLanguage={this.setAndStoreLanguage}
/>
)
}}
/>
<Route
path='/workspace/:workspaceId/'
render={({match}) => {
if (this.state.initialLoad && !this.state.user) {
const redirectUrl = `/workspace/${match.params.workspaceId}/`
const loginUrl = `/login?r=${encodeURIComponent(redirectUrl)}`
return <Redirect to={loginUrl}/>
}
return (
<BoardPage
workspaceId={match.params.workspaceId}
setLanguage={this.setAndStoreLanguage}
/>
)
}}
/>
<Route path='/'>
{this.state.initialLoad && !this.state.user && <Redirect to='/login'/>}
<BoardPage
workspaceId='0'
setLanguage={this.setAndStoreLanguage}
/>
</Route>
</Switch>
<Route path='/'>
{this.state.initialLoad && !this.state.user && <Redirect to='/login'/>}
<BoardPage
workspaceId='0'
setLanguage={this.setAndStoreLanguage}
/>
</Route>
</Switch>
</div>
</div>
</div>
</Router>
</UserContext.Provider>
</Router>
</UserContext.Provider>
</DndProvider>
</IntlProvider>
)
}

View File

@ -3,6 +3,7 @@
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IContentBlock} from '../../blocks/contentBlock'
import {MutableTextBlock} from '../../blocks/textBlock'
import mutator from '../../mutator'
import {CardTree} from '../../viewModel/cardTree'
@ -32,6 +33,22 @@ function addTextBlock(card: Card, intl: IntlShape, text: string): void {
})
}
function moveBlock(card: Card, srcBlock: IContentBlock, dstBlock: IContentBlock, intl: IntlShape): void {
let contentOrder = card.contentOrder.slice()
const isDraggingDown = contentOrder.indexOf(srcBlock.id) <= contentOrder.indexOf(dstBlock.id)
contentOrder = contentOrder.filter((id) => srcBlock.id !== id)
let destIndex = contentOrder.indexOf(dstBlock.id)
if (isDraggingDown) {
destIndex += 1
}
contentOrder.splice(destIndex, 0, srcBlock.id)
mutator.performAsUndoGroup(async () => {
const description = intl.formatMessage({id: 'CardDetail.moveContent', defaultMessage: 'move card content'})
await mutator.changeCardContentOrder(card, contentOrder, description)
})
}
const CardDetailContents = React.memo((props: Props) => {
const {cardTree} = props
if (!cardTree) {
@ -49,6 +66,7 @@ const CardDetailContents = React.memo((props: Props) => {
card={card}
contents={cardTree.contents}
readonly={props.readonly}
onDrop={(src, dst) => moveBlock(card, src, dst, props.intl)}
/>
))}
</div>

View File

@ -58,6 +58,7 @@ class CenterPanel extends React.Component<Props, State> {
e.stopPropagation()
}
// TODO: Might need a different hotkey, as Cmd+D is save bookmark on Chrome
if (keyName === 'ctrl+d') {
// CTRL+D: Duplicate selected cards
this.duplicateSelectedCards()

View File

@ -13,4 +13,7 @@
> .octo-block-margin {
flex: 0 0 auto;
}
.ImageElement {
pointer-events: none;
}
}

View File

@ -16,6 +16,7 @@ import SortDownIcon from '../widgets/icons/sortDown'
import SortUpIcon from '../widgets/icons/sortUp'
import Menu from '../widgets/menu'
import MenuWrapper from '../widgets/menuWrapper'
import useSortable from '../hooks/sortable'
import ContentElement from './content/contentElement'
import AddContentMenuItem from './addContentMenuItem'
@ -28,14 +29,24 @@ type Props = {
contents: readonly IContentBlock[]
readonly: boolean
intl: IntlShape
onDrop: (srctBlock: IContentBlock, dstBlock: IContentBlock) => void
}
const ContentBlock = React.memo((props: Props): JSX.Element => {
const {intl, card, contents, block, readonly} = props
const [isDragging, isOver, contentRef] = useSortable('content', block, true, props.onDrop)
const index = contents.indexOf(block)
let className = 'ContentBlock octo-block'
if (isOver) {
className += ' dragover'
}
return (
<div className='ContentBlock octo-block'>
<div
className={className}
style={{opacity: isDragging ? 0.5 : 1}}
ref={contentRef}
>
<div className='octo-block-margin'>
{!props.readonly &&
<MenuWrapper>

View File

@ -8,7 +8,7 @@
color: rgba(var(--body-color), 0.3);
cursor: pointer;
width: 280px;
min-height: 200px;
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -3,7 +3,10 @@
import React, {useState, useEffect} from 'react'
import {FormattedMessage} from 'react-intl'
import {Constants} from '../../constants'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import {CardTree, MutableCardTree} from '../../viewModel/cardTree'
import useCardListener from '../../hooks/cardListener'
@ -21,9 +24,34 @@ type Props = {
const Gallery = (props: Props): JSX.Element => {
const {boardTree} = props
const {cards} = boardTree
const {cards, activeView} = boardTree
const visiblePropertyTemplates = boardTree.board.cardProperties.filter((template) => boardTree.activeView.visiblePropertyIds.includes(template.id))
const [cardTrees, setCardTrees] = useState<{[key: string]: CardTree | undefined}>({})
const isManualSort = activeView.sortOptions.length < 1
const onDropToCard = (srcCard: Card, dstCard: Card) => {
Utils.log(`onDropToCard: ${dstCard.title}`)
const {selectedCardIds} = props
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
// Update dstCard order
let cardOrder = Array.from(new Set([...activeView.cardOrder, ...boardTree.cards.map((o) => o.id)]))
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCard.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(dstCard.id)
if (isDraggingDown) {
destIndex += 1
}
cardOrder.splice(destIndex, 0, ...draggedCardIds)
mutator.performAsUndoGroup(async () => {
await mutator.changeViewCardOrder(activeView, cardOrder, description)
})
}
const visibleTitle = boardTree.activeView.visiblePropertyIds.includes(Constants.titleColumnId)
useCardListener(
cards.map((c) => c.id),
@ -60,8 +88,11 @@ const Gallery = (props: Props): JSX.Element => {
cardTree={cardTree}
onClick={props.onCardClicked}
visiblePropertyTemplates={visiblePropertyTemplates}
visibleTitle={visibleTitle}
isSelected={props.selectedCardIds.includes(card.id)}
readonly={props.readonly}
onDrop={onDropToCard}
isManualSort={isManualSort}
/>
)
}

View File

@ -56,6 +56,7 @@
overflow: hidden;
max-height: 160px;
min-height: 160px;
pointer-events: none;
.ImageElement {
width: 100%;

View File

@ -15,6 +15,7 @@ import DuplicateIcon from '../../widgets/icons/duplicate'
import OptionsIcon from '../../widgets/icons/options'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import useSortable from '../../hooks/sortable'
import ImageElement from '../content/imageElement'
import ContentElement from '../content/contentElement'
@ -26,23 +27,33 @@ type Props = {
cardTree: CardTree
onClick: (e: React.MouseEvent, card: Card) => void
visiblePropertyTemplates: IPropertyTemplate[]
visibleTitle: boolean
isSelected: boolean
intl: IntlShape
readonly: boolean
isManualSort: boolean
onDrop: (srcCard: Card, dstCard: Card) => void
}
const GalleryCard = React.memo((props: Props) => {
const {cardTree} = props
const [isDragging, isOver, cardRef] = useSortable('card', cardTree.card, props.isManualSort, props.onDrop)
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
let images: IContentBlock[] = []
images = cardTree.contents.filter((content) => content.type === 'image')
let className = props.isSelected ? 'GalleryCard selected' : 'GalleryCard'
if (isOver) {
className += ' dragover'
}
return (
<div
className={`GalleryCard ${props.isSelected ? 'selected' : ''}`}
className={className}
onClick={(e: React.MouseEvent) => props.onClick(e, cardTree.card)}
style={{opacity: isDragging ? 0.5 : 1}}
ref={cardRef}
>
{!props.readonly &&
<MenuWrapper
@ -83,16 +94,17 @@ const GalleryCard = React.memo((props: Props) => {
/>
))}
</div>}
<div className='gallery-title'>
{ cardTree.card.icon ? <div className='octo-icon'>{cardTree.card.icon}</div> : undefined }
<div key='__title'>
{cardTree.card.title ||
<FormattedMessage
id='KanbanCard.untitled'
defaultMessage='Untitled'
/>}
</div>
</div>
{props.visibleTitle &&
<div className='gallery-title'>
{ cardTree.card.icon ? <div className='octo-icon'>{cardTree.card.icon}</div> : undefined }
<div key='__title'>
{cardTree.card.title ||
<FormattedMessage
id='KanbanCard.untitled'
defaultMessage='Untitled'
/>}
</div>
</div>}
{visiblePropertyTemplates.length > 0 &&
<div className='gallery-props'>
{visiblePropertyTemplates.map((template) => (

View File

@ -37,10 +37,18 @@
}
.Label{
max-width: 165px;
margin-right: 5px;
.Editable {
background: transparent;
}
}
>.Button {
&.IconButton {
cursor: pointer;
}
cursor: auto;
}
}

View File

@ -78,9 +78,6 @@ class Kanban extends React.Component<Props, State> {
readonly={this.props.readonly}
propertyNameChanged={this.propertyNameChanged}
onDropToColumn={this.onDropToColumn}
setDraggedHeaderOption={(draggedHeaderOption?: IPropertyOption) => {
this.setState({draggedHeaderOption})
}}
/>
))}
@ -120,8 +117,7 @@ class Kanban extends React.Component<Props, State> {
{visibleGroups.map((group) => (
<KanbanColumn
key={group.option.id || 'empty'}
isDropZone={!isManualSort || group.cards.length < 1}
onDrop={() => this.onDropToColumn(group.option)}
onDrop={(card: Card) => this.onDropToColumn(group.option, card)}
>
{group.cards.map((card) => (
<KanbanCard
@ -133,21 +129,8 @@ class Kanban extends React.Component<Props, State> {
onClick={(e) => {
this.props.onCardClicked(e, card)
}}
onDragStart={() => {
if (this.props.selectedCardIds.includes(card.id)) {
this.setState({draggedCards: this.props.selectedCardIds.map((id) => boardTree.allCards.find((o) => o.id === id)!)})
} else {
this.setState({draggedCards: [card]})
}
}}
onDragEnd={() => {
this.setState({draggedCards: []})
}}
isDropZone={isManualSort}
onDrop={() => {
this.onDropToCard(card)
}}
onDrop={this.onDropToCard}
isManualSort={isManualSort}
/>
))}
{!this.props.readonly &&
@ -176,8 +159,7 @@ class Kanban extends React.Component<Props, State> {
boardTree={boardTree}
intl={this.props.intl}
readonly={this.props.readonly}
onDropToColumn={this.onDropToColumn}
hasDraggedCards={this.state.draggedCards.length > 0}
onDrop={(card: Card) => this.onDropToColumn(group.option, card)}
/>
))}
</div>}
@ -206,14 +188,24 @@ class Kanban extends React.Component<Props, State> {
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty!, option, 'add group')
}
private onDropToColumn = async (option: IPropertyOption) => {
const {boardTree} = this.props
const {draggedCards, draggedHeaderOption} = this.state
private onDropToColumn = async (option: IPropertyOption, card?: Card, dstOption?: IPropertyOption) => {
const {boardTree, selectedCardIds} = this.props
const optionId = option ? option.id : undefined
let draggedCardIds = selectedCardIds
if (card) {
draggedCardIds = Array.from(new Set(selectedCardIds).add(card.id))
}
Utils.assertValue(boardTree)
if (draggedCards.length > 0) {
if (draggedCardIds.length > 0) {
const orderedCards = boardTree.orderedCards()
const cardsById: {[key: string]: Card} = orderedCards.reduce((acc: {[key: string]: Card}, c: Card): {[key: string]: Card} => {
acc[c.id] = c
return acc
}, {})
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o])
await mutator.performAsUndoGroup(async () => {
const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card'
const awaits = []
@ -226,14 +218,14 @@ class Kanban extends React.Component<Props, State> {
}
await Promise.all(awaits)
})
} else if (draggedHeaderOption) {
Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${option?.value}`)
} else if (dstOption) {
Utils.log(`ondrop. Header option: ${dstOption.value}, column: ${option?.value}`)
// Move option to new index
const visibleOptionIds = boardTree.visibleGroups.map((o) => o.option.id)
const {activeView} = boardTree
const srcIndex = visibleOptionIds.indexOf(draggedHeaderOption.id)
const srcIndex = visibleOptionIds.indexOf(dstOption.id)
const destIndex = visibleOptionIds.indexOf(option.id)
visibleOptionIds.splice(destIndex, 0, visibleOptionIds.splice(srcIndex, 1)[0])
@ -242,28 +234,29 @@ class Kanban extends React.Component<Props, State> {
}
}
private async onDropToCard(card: Card) {
Utils.log(`onDropToCard: ${card.title}`)
const {boardTree} = this.props
private onDropToCard = async (srcCard: Card, dstCard: Card) => {
Utils.log(`onDropToCard: ${dstCard.title}`)
const {boardTree, selectedCardIds} = this.props
const {activeView} = boardTree
const {draggedCards} = this.state
const optionId = card.properties[activeView.groupById!]
const optionId = dstCard.properties[activeView.groupById!]
if (draggedCards.length < 1 || draggedCards.includes(card)) {
return
}
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCards.length > 1 ? `drag ${draggedCards.length} cards` : 'drag card'
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
// Update card order
let cardOrder = boardTree.orderedCards().map((o) => o.id)
const draggedCardIds = draggedCards.map((o) => o.id)
const firstDraggedCard = draggedCards[0]
const isDraggingDown = cardOrder.indexOf(firstDraggedCard.id) <= cardOrder.indexOf(card.id)
// Update dstCard order
const orderedCards = boardTree.orderedCards()
const cardsById: {[key: string]: Card} = orderedCards.reduce((acc: {[key: string]: Card}, card: Card): {[key: string]: Card} => {
acc[card.id] = card
return acc
}, {})
const draggedCards: Card[] = draggedCardIds.map((o: string) => cardsById[o])
let cardOrder = orderedCards.map((o) => o.id)
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCard.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(card.id)
if (firstDraggedCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) {
// If the cards are in the same column and dragging down, drop after the target card
let destIndex = cardOrder.indexOf(dstCard.id)
if (srcCard.properties[boardTree.groupByProperty!.id] === optionId && isDraggingDown) {
// If the cards are in the same column and dragging down, drop after the target dstCard
destIndex += 1
}
cardOrder.splice(destIndex, 0, ...draggedCardIds)

View File

@ -1,6 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import React from 'react'
import {injectIntl, IntlShape} from 'react-intl'
import {IPropertyTemplate} from '../../blocks/board'
@ -12,6 +12,7 @@ import DuplicateIcon from '../../widgets/icons/duplicate'
import OptionsIcon from '../../widgets/icons/options'
import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import useSortable from '../../hooks/sortable'
import './kanbanCard.scss'
import PropertyValueElement from '../propertyValueElement'
@ -20,60 +21,29 @@ type Props = {
card: Card
visiblePropertyTemplates: IPropertyTemplate[]
isSelected: boolean
isDropZone?: boolean
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
intl: IntlShape
readonly: boolean
onDrop: (srcCard: Card, dstCard: Card) => void
isManualSort: boolean
}
const KanbanCard = (props: Props) => {
const [isDragged, setIsDragged] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const KanbanCard = React.memo((props: Props) => {
const {card, intl} = props
const [isDragging, isOver, cardRef] = useSortable('card', card, props.isManualSort, props.onDrop)
const visiblePropertyTemplates = props.visiblePropertyTemplates || []
let className = props.isSelected ? 'KanbanCard selected' : 'KanbanCard'
if (props.isDropZone && isDragOver) {
if (isOver) {
className += ' dragover'
}
return (
<div
ref={props.readonly ? () => null : cardRef}
className={className}
draggable={!props.readonly}
style={{opacity: isDragged ? 0.5 : 1}}
style={{opacity: isDragging ? 0.5 : 1}}
onClick={props.onClick}
onDragStart={(e) => {
setIsDragged(true)
props.onDragStart(e)
}}
onDragEnd={(e) => {
setIsDragged(false)
props.onDragEnd(e)
}}
onDragOver={() => {
if (!isDragOver) {
setIsDragOver(true)
}
}}
onDragEnter={() => {
if (!isDragOver) {
setIsDragOver(true)
}
}}
onDragLeave={() => {
setIsDragOver(false)
}}
onDrop={(e) => {
setIsDragOver(false)
if (props.isDropZone) {
props.onDrop(e)
}
}}
>
{!props.readonly &&
<MenuWrapper
@ -115,6 +85,6 @@ const KanbanCard = (props: Props) => {
))}
</div>
)
}
})
export default injectIntl(KanbanCard)

View File

@ -1,45 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react'
import React from 'react'
import {useDrop} from 'react-dnd'
import {Card} from '../../blocks/card'
type Props = {
onDrop: (e: React.DragEvent<HTMLDivElement>) => void
isDropZone: boolean
onDrop: (card: Card) => void
children: React.ReactNode
}
const KanbanColumn = React.memo((props: Props) => {
const [isDragOver, setIsDragOver] = useState(false)
const [{isOver}, drop] = useDrop(() => ({
accept: 'card',
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
}),
drop: (item: Card, monitor) => {
if (monitor.isOver({shallow: true})) {
props.onDrop(item)
}
},
}))
let className = 'octo-board-column'
if (props.isDropZone && isDragOver) {
if (isOver) {
className += ' dragover'
}
return (
<div
ref={drop}
className={className}
onDragOver={(e) => {
e.preventDefault()
if (!isDragOver) {
setIsDragOver(true)
}
}}
onDragEnter={(e) => {
e.preventDefault()
if (!isDragOver) {
setIsDragOver(true)
}
}}
onDragLeave={(e) => {
e.preventDefault()
setIsDragOver(false)
}}
onDrop={(e) => {
setIsDragOver(false)
if (props.isDropZone) {
props.onDrop(e)
}
}}
>
{props.children}
</div>

View File

@ -1,11 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import React, {useState, useEffect} from 'react'
import React, {useState, useEffect, useRef} from 'react'
import {FormattedMessage, IntlShape} from 'react-intl'
import {useDrop, useDrag} from 'react-dnd'
import {Constants} from '../../constants'
import {IPropertyOption} from '../../blocks/board'
import {Card} from '../../blocks/card'
import mutator from '../../mutator'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
@ -26,8 +28,7 @@ type Props = {
readonly: boolean
addCard: (groupByOptionId?: string) => Promise<void>
propertyNameChanged: (option: IPropertyOption, text: string) => Promise<void>
onDropToColumn: (option: IPropertyOption) => void
setDraggedHeaderOption: (draggedHeaderOption?: IPropertyOption) => void
onDropToColumn: (srcOption: IPropertyOption, card?: Card, dstOption?: IPropertyOption) => void
}
export default function KanbanColumnHeader(props: Props): JSX.Element {
@ -35,42 +36,42 @@ export default function KanbanColumnHeader(props: Props): JSX.Element {
const {activeView} = boardTree
const [groupTitle, setGroupTitle] = useState(group.option.value)
const headerRef = useRef<HTMLDivElement>(null)
const [{isDragging}, drag] = useDrag(() => ({
type: 'column',
item: group.option,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}))
const [{isOver}, drop] = useDrop(() => ({
accept: 'column',
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
drop: (item: IPropertyOption) => {
props.onDropToColumn(item, undefined, group.option)
},
}))
useEffect(() => {
setGroupTitle(group.option.value)
}, [group.option.value])
const ref = React.createRef<HTMLDivElement>()
drop(drag(headerRef))
let className = 'octo-board-header-cell KanbanColumnHeader'
if (isOver) {
className += ' dragover'
}
return (
<div
key={group.option.id || 'empty'}
ref={ref}
className='octo-board-header-cell KanbanColumnHeader'
ref={headerRef}
style={{opacity: isDragging ? 0.5 : 1}}
className={className}
draggable={!props.readonly}
onDragStart={() => {
props.setDraggedHeaderOption(group.option)
}}
onDragEnd={() => {
props.setDraggedHeaderOption(undefined)
}}
onDragOver={(e) => {
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragEnter={(e) => {
ref.current?.classList.add('dragover')
e.preventDefault()
}}
onDragLeave={(e) => {
ref.current?.classList.remove('dragover')
e.preventDefault()
}}
onDrop={(e) => {
ref.current?.classList.remove('dragover')
e.preventDefault()
props.onDropToColumn(group.option)
}}
>
{!group.option.id &&
<Label

View File

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import React, {useRef, useState} from 'react'
import React from 'react'
import {IntlShape} from 'react-intl'
import {useDrop} from 'react-dnd'
import {IPropertyOption} from '../../blocks/board'
import mutator from '../../mutator'
import {BoardTree, BoardTreeGroup} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
@ -12,53 +12,39 @@ import Menu from '../../widgets/menu'
import MenuWrapper from '../../widgets/menuWrapper'
import ShowIcon from '../../widgets/icons/show'
import Label from '../../widgets/label'
import {Card} from '../../blocks/card'
type Props = {
boardTree: BoardTree
group: BoardTreeGroup
intl: IntlShape
readonly: boolean
onDropToColumn: (option: IPropertyOption) => void
hasDraggedCards: boolean
onDrop: (card: Card) => void
}
export default function KanbanHiddenColumnItem(props: Props): JSX.Element {
const {boardTree, intl, group} = props
const {activeView} = boardTree
const [{isOver}, drop] = useDrop(() => ({
accept: 'card',
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
drop: (item: Card) => {
props.onDrop(item)
},
}))
const ref = useRef<HTMLDivElement>(null)
const [dragClass, setDragClass] = useState('')
let className = 'octo-board-hidden-item'
if (isOver) {
className += ' dragover'
}
return (
<div
ref={ref}
ref={drop}
key={group.option.id || 'empty'}
className={`octo-board-hidden-item ${dragClass}`}
onDragOver={(e) => {
if (props.hasDraggedCards) {
setDragClass('dragover')
e.preventDefault()
}
}}
onDragEnter={(e) => {
if (props.hasDraggedCards) {
setDragClass('dragover')
e.preventDefault()
}
}}
onDragLeave={(e) => {
if (props.hasDraggedCards) {
setDragClass('')
e.preventDefault()
}
}}
onDrop={(e) => {
setDragClass('')
e.preventDefault()
if (props.hasDraggedCards) {
props.onDropToColumn(group.option)
}
}}
className={className}
>
<MenuWrapper
disabled={props.readonly}

View File

@ -29,6 +29,24 @@ const PropertyValueElement = (props:Props): JSX.Element => {
const displayValue = OctoUtils.propertyDisplayValue(card, propertyValue, propertyTemplate)
const finalDisplayValue = displayValue || emptyDisplayValue
const validateProp = (propType: string, val: string): boolean => {
if (val === '') {
return true
}
switch (propType) {
case 'number':
return !isNaN(parseInt(val, 10))
case 'email': {
const emailRegexp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
return emailRegexp.test(val.toLowerCase())
}
case 'text':
return true
default:
return false
}
}
if (propertyTemplate.type === 'select') {
let propertyColorCssClassName = ''
const cardPropertyValue = propertyTemplate.options.find((o) => o.id === propertyValue)
@ -90,6 +108,7 @@ const PropertyValueElement = (props:Props): JSX.Element => {
onChange={setValue}
onSave={() => mutator.changePropertyValue(card, propertyTemplate.id, value)}
onCancel={() => setValue(propertyValue)}
validator={(newValue) => validateProp(propertyTemplate.type, newValue)}
/>
)
}

View File

@ -47,7 +47,7 @@
.octo-sidebar-list {
flex: 1 1 auto;
overflow-y: auto;
max-width: 250px;
width: 250px;
>div {
margin-bottom: 16px;

View File

@ -1,57 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {useDrag} from 'react-dnd'
import './horizontalGrip.scss'
type Props = {
onDrag: (offset: number) => void
onDragEnd: (offset: number) => void
templateId: string
}
type State = {
isDragging: boolean
startX: number
offset: number
}
const HorizontalGrip = React.memo((props: Props): JSX.Element => {
const [, drag] = useDrag(() => ({
type: 'horizontalGrip',
item: {id: props.templateId},
}))
class HorizontalGrip extends React.PureComponent<Props, State> {
state: State = {
isDragging: false,
startX: 0,
offset: 0,
}
render(): JSX.Element {
return (
<div
className='HorizontalGrip'
onMouseDown={(e) => {
this.setState({isDragging: true, startX: e.clientX, offset: 0})
window.addEventListener('mousemove', this.globalMouseMove)
window.addEventListener('mouseup', this.globalMouseUp)
}}
/>)
}
private globalMouseMove = (e: MouseEvent) => {
if (!this.state.isDragging) {
return
}
const offset = e.clientX - this.state.startX
if (offset !== this.state.offset) {
this.props.onDrag(offset)
this.setState({offset})
}
}
private globalMouseUp = (e: MouseEvent) => {
window.removeEventListener('mousemove', this.globalMouseMove)
window.removeEventListener('mouseup', this.globalMouseUp)
this.setState({isDragging: false})
const offset = e.clientX - this.state.startX
this.props.onDragEnd(offset)
}
}
return (
<div
ref={drag}
className='HorizontalGrip'
/>
)
})
export default HorizontalGrip

View File

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage} from 'react-intl'
import {useDrop, useDragLayer} from 'react-dnd'
import {IPropertyTemplate} from '../../blocks/board'
import {MutableBoardView} from '../../blocks/boardView'
@ -10,15 +11,9 @@ import {Constants} from '../../constants'
import mutator from '../../mutator'
import {Utils} from '../../utils'
import {BoardTree} from '../../viewModel/boardTree'
import SortDownIcon from '../../widgets/icons/sortDown'
import SortUpIcon from '../../widgets/icons/sortUp'
import MenuWrapper from '../../widgets/menuWrapper'
import Label from '../../widgets/label'
import HorizontalGrip from './horizontalGrip'
import './table.scss'
import TableHeaderMenu from './tableHeaderMenu'
import TableHeader from './tableHeader'
import TableRow from './tableRow'
type Props = {
@ -31,252 +26,174 @@ type Props = {
onCardClicked: (e: React.MouseEvent, card: Card) => void
}
type State = {
shownCardId?: string
}
const Table = (props: Props) => {
const {boardTree} = props
const {board, cards, activeView} = boardTree
class Table extends React.Component<Props, State> {
private draggedHeaderTemplate?: IPropertyTemplate
state: State = {}
shouldComponentUpdate(): boolean {
return true
}
render(): JSX.Element {
const {boardTree} = this.props
const {board, cards, activeView} = boardTree
const titleRef = React.createRef<HTMLDivElement>()
let titleSortIcon: React.ReactNode
const titleSortOption = activeView.sortOptions.find((o) => o.propertyId === Constants.titleColumnId)
if (titleSortOption) {
titleSortIcon = titleSortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
const {offset, resizingColumn} = useDragLayer((monitor) => {
if (monitor.getItemType() === 'horizontalGrip') {
return {
offset: monitor.getDifferenceFromInitialOffset()?.x || 0,
resizingColumn: monitor.getItem()?.id,
}
}
return (
<div className='octo-table-body Table'>
{/* Headers */}
<div
className='octo-table-header'
id='mainBoardHeader'
>
<div
id='mainBoardHeader'
ref={titleRef}
className='octo-table-cell header-cell'
style={{overflow: 'unset', width: this.columnWidth(Constants.titleColumnId)}}
>
<MenuWrapper disabled={this.props.readonly}>
<Label>
<FormattedMessage
id='TableComponent.name'
defaultMessage='Name'
/>
{titleSortIcon}
</Label>
<TableHeaderMenu
boardTree={boardTree}
templateId={Constants.titleColumnId}
/>
</MenuWrapper>
<div className='octo-spacer'/>
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(Constants.titleColumnId)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (titleRef.current) {
titleRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[Constants.titleColumnId]) {
columnWidths[Constants.titleColumnId] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>
{/* Table header row */}
{board.cardProperties.
filter((template) => activeView.visiblePropertyIds.includes(template.id)).
map((template) => {
const headerRef = React.createRef<HTMLDivElement>()
let sortIcon
const sortOption = activeView.sortOptions.find((o) => o.propertyId === template.id)
if (sortOption) {
sortIcon = sortOption.reversed ? <SortUpIcon/> : <SortDownIcon/>
}
return (
<div
key={template.id}
ref={headerRef}
style={{overflow: 'unset', width: this.columnWidth(template.id)}}
className='octo-table-cell header-cell'
onDragOver={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.add('dragover')
}}
onDragEnter={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.add('dragover')
}}
onDragLeave={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.remove('dragover')
}}
onDrop={(e) => {
e.preventDefault();
(e.target as HTMLElement).classList.remove('dragover')
this.onDropToColumn(template)
}}
>
<MenuWrapper
disabled={this.props.readonly}
>
<div
draggable={!this.props.readonly}
onDragStart={() => {
this.draggedHeaderTemplate = template
}}
onDragEnd={() => {
this.draggedHeaderTemplate = undefined
}}
>
<Label>
{template.name}
{sortIcon}
</Label>
</div>
<TableHeaderMenu
boardTree={boardTree}
templateId={template.id}
/>
</MenuWrapper>
<div className='octo-spacer'/>
{!this.props.readonly &&
<HorizontalGrip
onDrag={(offset) => {
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
}}
onDragEnd={(offset) => {
Utils.log(`onDragEnd offset: ${offset}`)
const originalWidth = this.columnWidth(template.id)
const newWidth = Math.max(Constants.minColumnWidth, originalWidth + offset)
if (headerRef.current) {
headerRef.current.style.width = `${newWidth}px`
}
const columnWidths = {...activeView.columnWidths}
if (newWidth !== columnWidths[template.id]) {
columnWidths[template.id] = newWidth
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
}}
/>
}
</div>)
})}
</div>
{/* Rows, one per card */}
{cards.map((card) => {
const tableRow = (
<TableRow
key={card.id + card.updateAt}
boardTree={boardTree}
card={card}
isSelected={this.props.selectedCardIds.includes(card.id)}
focusOnMount={this.props.cardIdToFocusOnRender === card.id}
onSaveWithEnter={() => {
if (cards.length > 0 && cards[cards.length - 1] === card) {
this.props.addCard(false)
}
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
this.props.onCardClicked(e, card)
}}
showCard={this.props.showCard}
readonly={this.props.readonly}
/>)
return tableRow
})}
{/* Add New row */}
<div className='octo-table-footer'>
{!this.props.readonly &&
<div
className='octo-table-cell'
onClick={() => {
this.props.addCard(false)
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
}
</div>
</div>
)
}
private columnWidth(templateId: string): number {
return Math.max(Constants.minColumnWidth, this.props.boardTree.activeView.columnWidths[templateId] || 0)
}
private async onDropToColumn(template: IPropertyTemplate) {
const {draggedHeaderTemplate} = this
if (!draggedHeaderTemplate) {
return
return {
offset: 0,
resizingColumn: '',
}
})
const {boardTree} = this.props
const {board} = boardTree
const [, drop] = useDrop(() => ({
accept: 'horizontalGrip',
drop: (item: {id: string}, monitor) => {
const columnWidths = {...activeView.columnWidths}
const finalOffset = monitor.getDifferenceFromInitialOffset()?.x || 0
const newWidth = Math.max(Constants.minColumnWidth, (columnWidths[item.id] || 0) + (finalOffset || 0))
if (newWidth !== columnWidths[item.id]) {
columnWidths[item.id] = newWidth
Utils.assertValue(mutator)
Utils.assertValue(boardTree)
const newView = new MutableBoardView(activeView)
newView.columnWidths = columnWidths
mutator.updateBlock(newView, activeView, 'resize column')
}
},
}), [activeView])
Utils.log(`ondrop. Source column: ${draggedHeaderTemplate.name}, dest column: ${template.name}`)
const onDropToCard = (srcCard: Card, dstCard: Card) => {
Utils.log(`onDropToCard: ${dstCard.title}`)
const {selectedCardIds} = props
const draggedCardIds = Array.from(new Set(selectedCardIds).add(srcCard.id))
const description = draggedCardIds.length > 1 ? `drag ${draggedCardIds.length} cards` : 'drag card'
// Update dstCard order
let cardOrder = Array.from(new Set([...activeView.cardOrder, ...boardTree.cards.map((o) => o.id)]))
const isDraggingDown = cardOrder.indexOf(srcCard.id) <= cardOrder.indexOf(dstCard.id)
cardOrder = cardOrder.filter((id) => !draggedCardIds.includes(id))
let destIndex = cardOrder.indexOf(dstCard.id)
if (isDraggingDown) {
destIndex += 1
}
cardOrder.splice(destIndex, 0, ...draggedCardIds)
mutator.performAsUndoGroup(async () => {
await mutator.changeViewCardOrder(activeView, cardOrder, description)
})
}
const onDropToColumn = async (template: IPropertyTemplate, container: IPropertyTemplate) => {
Utils.log(`ondrop. Source column: ${template.name}, dest column: ${container.name}`)
// Move template to new index
const destIndex = template ? board.cardProperties.indexOf(template) : 0
await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex)
const destIndex = container ? board.cardProperties.indexOf(container) : 0
await mutator.changePropertyTemplateOrder(board, template, destIndex >= 0 ? destIndex : 0)
}
const titleSortOption = activeView.sortOptions.find((o) => o.propertyId === Constants.titleColumnId)
let titleSorted: 'up' | 'down' | 'none' = 'none'
if (titleSortOption) {
titleSorted = titleSortOption.reversed ? 'up' : 'down'
}
return (
<div
className='octo-table-body Table'
ref={drop}
>
{/* Headers */}
<div
className='octo-table-header'
id='mainBoardHeader'
>
<TableHeader
name={
<FormattedMessage
id='TableComponent.name'
defaultMessage='Name'
/>
}
sorted={titleSorted}
readonly={props.readonly}
boardTree={boardTree}
template={{id: Constants.titleColumnId, name: 'title', type: 'text', options: []}}
offset={resizingColumn === Constants.titleColumnId ? offset : 0}
onDrop={onDropToColumn}
/>
{/* Table header row */}
{board.cardProperties.
filter((template) => activeView.visiblePropertyIds.includes(template.id)).
map((template) => {
let sorted: 'up' | 'down' | 'none' = 'none'
const sortOption = activeView.sortOptions.find((o) => o.propertyId === template.id)
if (sortOption) {
sorted = sortOption.reversed ? 'up' : 'down'
}
return (
<TableHeader
name={template.name}
sorted={sorted}
readonly={props.readonly}
boardTree={boardTree}
template={template}
key={template.id}
offset={resizingColumn === template.id ? offset : 0}
onDrop={onDropToColumn}
/>
)
})}
</div>
{/* Rows, one per card */}
{cards.map((card) => {
const tableRow = (
<TableRow
key={card.id + card.updateAt}
boardTree={boardTree}
card={card}
isSelected={props.selectedCardIds.includes(card.id)}
focusOnMount={props.cardIdToFocusOnRender === card.id}
onSaveWithEnter={() => {
if (cards.length > 0 && cards[cards.length - 1] === card) {
props.addCard(false)
}
}}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
props.onCardClicked(e, card)
}}
showCard={props.showCard}
readonly={props.readonly}
onDrop={onDropToCard}
offset={offset}
resizingColumn={resizingColumn}
/>)
return tableRow
})}
{/* Add New row */}
<div className='octo-table-footer'>
{!props.readonly &&
<div
className='octo-table-cell'
onClick={() => {
props.addCard(false)
}}
>
<FormattedMessage
id='TableComponent.plus-new'
defaultMessage='+ New'
/>
</div>
}
</div>
</div>
)
}
export default Table

View File

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {IPropertyTemplate} from '../../blocks/board'
import {Constants} from '../../constants'
import {BoardTree} from '../../viewModel/boardTree'
import SortDownIcon from '../../widgets/icons/sortDown'
import SortUpIcon from '../../widgets/icons/sortUp'
import MenuWrapper from '../../widgets/menuWrapper'
import Label from '../../widgets/label'
import useSortable from '../../hooks/sortable'
import HorizontalGrip from './horizontalGrip'
import './table.scss'
import TableHeaderMenu from './tableHeaderMenu'
type Props = {
readonly: boolean
sorted: 'up'|'down'|'none'
name: React.ReactNode
boardTree: BoardTree
template: IPropertyTemplate
offset: number
onDrop: (template: IPropertyTemplate, container: IPropertyTemplate) => void
}
const TableHeader = React.memo((props: Props): JSX.Element => {
const isManualSort = props.boardTree.activeView.sortOptions.length < 1
const [isDragging, isOver, columnRef] = useSortable('column', props.template, isManualSort, props.onDrop)
const columnWidth = (templateId: string): number => {
return Math.max(Constants.minColumnWidth, (props.boardTree.activeView.columnWidths[templateId] || 0) + props.offset)
}
let className = 'octo-table-cell header-cell'
if (isOver) {
className += ' dragover'
}
return (
<div
className={className}
style={{overflow: 'unset', width: columnWidth(props.template.id), opacity: isDragging ? 0.5 : 1}}
ref={props.template.id === Constants.titleColumnId ? () => null : columnRef}
>
<MenuWrapper disabled={props.readonly}>
<Label>
{props.name}
{props.sorted === 'up' && <SortUpIcon/>}
{props.sorted === 'down' && <SortDownIcon/>}
</Label>
<TableHeaderMenu
boardTree={props.boardTree}
templateId={props.template.id}
/>
</MenuWrapper>
<div className='octo-spacer'/>
{!props.readonly &&
<HorizontalGrip templateId={props.template.id}/>
}
</div>
)
})
export default TableHeader

View File

@ -9,6 +9,7 @@ import mutator from '../../mutator'
import {BoardTree} from '../../viewModel/boardTree'
import Button from '../../widgets/buttons/button'
import Editable from '../../widgets/editable'
import useSortable from '../../hooks/sortable'
import PropertyValueElement from '../propertyValueElement'
import './tableRow.scss'
@ -21,12 +22,21 @@ type Props = {
onSaveWithEnter: () => void
showCard: (cardId: string) => void
readonly: boolean
offset: number
resizingColumn: string
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
onDrop: (srcCard: Card, dstCard: Card) => void
}
const TableRow = React.memo((props: Props) => {
const {boardTree, onSaveWithEnter} = props
const {board, activeView} = boardTree
const titleRef = useRef<Editable>(null)
const [title, setTitle] = useState(props.card.title)
const {card} = props
const isManualSort = activeView.sortOptions.length < 1
const [isDragging, isOver, cardRef] = useSortable('card', card, isManualSort, props.onDrop)
useEffect(() => {
if (props.focusOnMount) {
@ -35,18 +45,23 @@ const TableRow = React.memo((props: Props) => {
}, [])
const columnWidth = (templateId: string): number => {
if (props.resizingColumn === templateId) {
return Math.max(Constants.minColumnWidth, (props.boardTree.activeView.columnWidths[templateId] || 0) + props.offset)
}
return Math.max(Constants.minColumnWidth, props.boardTree.activeView.columnWidths[templateId] || 0)
}
const {boardTree, card, onSaveWithEnter} = props
const {board, activeView} = boardTree
const className = props.isSelected ? 'TableRow octo-table-row selected' : 'TableRow octo-table-row'
let className = props.isSelected ? 'TableRow octo-table-row selected' : 'TableRow octo-table-row'
if (isOver) {
className += ' dragover'
}
return (
<div
className={className}
onClick={props.onClick}
ref={cardRef}
style={{opacity: isDragging ? 0.5 : 1}}
>
{/* Name / title */}

View File

@ -1,8 +1,9 @@
// 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 {FormattedMessage, injectIntl, IntlShape} from 'react-intl'
import {Constants} from '../../constants'
import {IPropertyTemplate} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import mutator from '../../mutator'
@ -13,6 +14,7 @@ import MenuWrapper from '../../widgets/menuWrapper'
type Props = {
properties: readonly IPropertyTemplate[]
activeView: BoardView
intl: IntlShape
}
const ViewHeaderPropertiesMenu = React.memo((props: Props) => {
const {properties, activeView} = props
@ -25,6 +27,22 @@ const ViewHeaderPropertiesMenu = React.memo((props: Props) => {
/>
</Button>
<Menu>
{activeView.viewType === 'gallery' &&
<Menu.Switch
key={Constants.titleColumnId}
id={Constants.titleColumnId}
name={props.intl.formatMessage({id: 'default-properties.title', defaultMessage: 'Title'})}
isOn={activeView.visiblePropertyIds.includes(Constants.titleColumnId)}
onClick={(propertyId: string) => {
let newVisiblePropertyIds = []
if (activeView.visiblePropertyIds.includes(propertyId)) {
newVisiblePropertyIds = activeView.visiblePropertyIds.filter((o: string) => o !== propertyId)
} else {
newVisiblePropertyIds = [...activeView.visiblePropertyIds, propertyId]
}
mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
}}
/>}
{properties.map((option: IPropertyTemplate) => (
<Menu.Switch
key={option.id}
@ -47,4 +65,4 @@ const ViewHeaderPropertiesMenu = React.memo((props: Props) => {
)
})
export default ViewHeaderPropertiesMenu
export default injectIntl(ViewHeaderPropertiesMenu)

View File

@ -29,7 +29,7 @@ const ViewHeaderSearch = (props: Props) => {
setSearchValue(boardTree.getSearchText())
}, [boardTree])
useHotkeys('ctrl+shift+f', () => {
useHotkeys('ctrl+shift+f,cmd+shift+f', () => {
setIsSearching(true)
searchFieldRef.current?.focus(true)
})

View File

@ -132,6 +132,7 @@ export class ViewMenu extends React.PureComponent<Props> {
view.viewType = 'gallery'
view.parentId = board.id
view.rootId = board.rootId
view.visiblePropertyIds = [Constants.titleColumnId]
const oldViewId = boardTree.activeView.id

View File

@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useRef} from 'react'
import {useDrag, useDrop} from 'react-dnd'
export default function useSortable(itemType: string, item: any, enabled: boolean, handler: (src: any, st: any) => void): [boolean, boolean, React.RefObject<HTMLDivElement>] {
const ref = useRef<HTMLDivElement>(null)
const [{isDragging}, drag] = useDrag(() => ({
type: itemType,
item,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
canDrag: () => enabled,
}), [itemType, item, enabled])
const [{isOver}, drop] = useDrop(() => ({
accept: itemType,
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
drop: (dragItem: any) => {
handler(dragItem, item)
},
canDrop: () => enabled,
}), [item, handler, enabled])
drop(drag(ref))
return [isDragging, isOver, ref]
}

View File

@ -96,7 +96,7 @@ class BoardPage extends React.Component<Props, State> {
return
}
if (keyName === 'ctrl+z') { // Cmd+Z
if (keyName === 'ctrl+z' || keyName === 'cmd+z') { // Cmd+Z
Utils.log('Undo')
if (mutator.canUndo) {
const description = mutator.undoDescription
@ -109,7 +109,7 @@ class BoardPage extends React.Component<Props, State> {
} else {
sendFlashMessage({content: 'Nothing to Undo', severity: 'low'})
}
} else if (keyName === 'shift+ctrl+z') { // Shift+Cmd+Z
} else if (keyName === 'shift+ctrl+z' || keyName === 'shift+cmd+z') { // Shift+Cmd+Z
Utils.log('Redo')
if (mutator.canRedo) {
const description = mutator.redoDescription
@ -161,7 +161,7 @@ class BoardPage extends React.Component<Props, State> {
return (
<div className='BoardPage'>
<HotKeys
keyName='shift+ctrl+z,ctrl+z'
keyName='shift+ctrl+z,shift+cmd+z,ctrl+z,cmd+z'
onKeyDown={this.undoRedoHandler}
/>
<Workspace

View File

@ -8,6 +8,7 @@
--button-bg: 22, 109, 204;
--link-color: 35, 137, 215;
--link-visited-color: #551a8b;
--error-color: #ffa9a9;
// Label Colors
--prop-default: #fff;
@ -32,4 +33,4 @@
// Radius
--default-rad: 4px;
--modal-rad: 8px;
}
}

View File

@ -231,6 +231,22 @@ class Utils {
return result
}
static isMobile() {
const toMatch = [
/Android/i,
/webOS/i,
/iPhone/i,
/iPad/i,
/iPod/i,
/BlackBerry/i,
/Windows Phone/i,
]
return toMatch.some((toMatchItem) => {
return navigator.userAgent.match(toMatchItem)
})
}
}
export {Utils}

View File

@ -198,7 +198,32 @@ class MutableBoardTree implements BoardTree {
return cards.slice()
}
return cards.filter((card) => card.title?.toLocaleLowerCase().indexOf(searchText) !== -1)
return cards.filter((card: Card) => {
const searchTextInCardTitle: boolean = card.title?.toLocaleLowerCase().includes(searchText)
if (searchTextInCardTitle) {
return true
}
// Search for text in properties
const {board} = this
for (const [propertyId, propertyValue] of Object.entries(card.properties)) {
// TODO: Refactor to a shared function that returns the display value of a property
const propertyTemplate = board.cardProperties.find((o) => o.id === propertyId)
if (propertyTemplate) {
if (propertyTemplate.type === 'select') {
// Look up the value of the select option
const option = propertyTemplate.options.find((o) => o.id === propertyValue)
if (option?.value.toLowerCase().includes(searchText)) {
return true
}
} else if (propertyValue.toLowerCase().includes(searchText)) {
return true
}
}
}
return false
})
}
private setGroupByProperty(propertyId: string) {

View File

@ -3,6 +3,7 @@
border: 0;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
&.active {
min-width: 100px;
}
@ -10,4 +11,8 @@
color: rgba(var(--body-color), 0.4);
opacity: 1;
}
&.error {
border: 1px solid var(--error-color);
border-radius: var(--default-rad);
}
}

View File

@ -12,6 +12,7 @@ type Props = {
saveOnEsc?: boolean
readonly?: boolean
validator?: (value: string) => boolean
onCancel?: () => void
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
}
@ -24,6 +25,22 @@ export default class Editable extends React.Component<Props> {
return true
}
save = (saveType: 'onEnter'|'onEsc'|'onBlur'): void => {
if (this.props.validator && !this.props.validator(this.props.value || '')) {
return
}
if (!this.props.onSave) {
return
}
if (saveType === 'onBlur' && !this.saveOnBlur) {
return
}
if (saveType === 'onEsc' && !this.props.saveOnEsc) {
return
}
this.props.onSave(saveType)
}
public focus(selectAll = false): void {
if (this.elementRef.current) {
const valueLength = this.elementRef.current.value.length
@ -44,30 +61,34 @@ export default class Editable extends React.Component<Props> {
public render(): JSX.Element {
const {value, onChange, className, placeholderText} = this.props
let error = false
if (this.props.validator) {
error = !this.props.validator(value || '')
}
return (
<input
ref={this.elementRef}
className={'Editable ' + className}
className={'Editable ' + (error ? 'error ' : '') + className}
placeholder={placeholderText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}}
value={value}
title={value}
onBlur={() => this.saveOnBlur && this.props.onSave && this.props.onSave('onBlur')}
onBlur={() => this.save('onBlur')}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
e.stopPropagation()
if (this.props.saveOnEsc) {
this.props.onSave?.('onEsc')
this.save('onEsc')
} else {
this.props.onCancel?.()
}
this.blur()
} else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
e.stopPropagation()
this.props.onSave?.('onEnter')
this.save('onEnter')
this.blur()
}
}}

View File

@ -2,18 +2,24 @@
width: 100%;
border-radius: var(--default-rad);
color: rgb(var(--body-color));
&:hover {
background-color: rgba(var(--body-color), 0.1),
}
display: flex;
> .Label {
margin: 0 10px;
max-width: calc(100% - 10px);
&.empty {
color: rgba(var(--body-color), 0.6);
}
}
.Label {
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
border-radius: var(--default-rad);
max-width: 100%;
}
.value-menu-option {
display: flex;
width: 100%;

View File

@ -107,14 +107,19 @@ function ValueSelector(props: Props): JSX.Element {
}),
control: (): CSSObject => ({
border: 0,
width: '100%',
margin: '4px 0 0 0',
}),
valueContainer: (provided: CSSObject): CSSObject => ({
...provided,
padding: '0 8px',
overflow: 'unset',
}),
singleValue: (provided: CSSObject): CSSObject => ({
...provided,
color: 'rgb(var(--main-fg))',
overflow: 'unset',
maxWidth: 'calc(100% - 20px)',
}),
input: (provided: CSSObject): CSSObject => ({
...provided,