일반 화면과 관리자 화면 및 활성 플러그인의 최종 한국어 번역을 반영했다. 기능별 검수 원본과 보드 화면 패치 원본을 저장소에 포함했다. yarn test와 yarn deploy 및 yarn verify:deployment 명령을 추가했다. 활성 플러그인 29개의 번역 키 6,005개에서 누락 0개를 확인했다. PC와 390픽셀 모바일 화면을 확인했다. 운영 app 컨테이너에 재배포하고 HTTP 200 응답을 확인했다. 데이터베이스 구조 변경과 데이터 이전은 없다. 사용자 요청이 없어 백업은 만들지 않았다.
1485 lines
40 KiB
Plaintext
1485 lines
40 KiB
Plaintext
import Component from "@glimmer/component";
|
|
import { tracked } from "@glimmer/tracking";
|
|
import { fn } from "@ember/helper";
|
|
import { on } from "@ember/modifier";
|
|
import { action } from "@ember/object";
|
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
|
import { cancel, next, schedule } from "@ember/runloop";
|
|
import { service } from "@ember/service";
|
|
import { modifier } from "ember-modifier";
|
|
import BackButton from "discourse/components/back-button";
|
|
import PermanentlyDeleteConfirmModal from "discourse/components/modal/permanently-delete-confirm";
|
|
import DMenu from "discourse/float-kit/components/d-menu";
|
|
import DTooltip from "discourse/float-kit/components/d-tooltip";
|
|
import bodyClass from "discourse/helpers/body-class";
|
|
import { reload } from "discourse/helpers/page-reloader";
|
|
import { ajax } from "discourse/lib/ajax";
|
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
|
import { waitForAnimationEnd } from "discourse/lib/animation-utils";
|
|
import { bind } from "discourse/lib/decorators";
|
|
import { isTesting } from "discourse/lib/environment";
|
|
import discourseLater from "discourse/lib/later";
|
|
import DiscourseURL from "discourse/lib/url";
|
|
import { prefersReducedMotion } from "discourse/lib/utilities";
|
|
import Category from "discourse/models/category";
|
|
import { or } from "discourse/truth-helpers";
|
|
import DAsyncContent from "discourse/ui-kit/d-async-content";
|
|
import DButton from "discourse/ui-kit/d-button";
|
|
import DDropdownMenu from "discourse/ui-kit/d-dropdown-menu";
|
|
import dBoundCategoryLink from "discourse/ui-kit/helpers/d-bound-category-link";
|
|
import dDiscourseTags from "discourse/ui-kit/helpers/d-discourse-tags";
|
|
import dIcon from "discourse/ui-kit/helpers/d-icon";
|
|
import dDragAndDropAutoScroll from "discourse/ui-kit/modifiers/d-drag-and-drop-auto-scroll";
|
|
import { i18n } from "discourse-i18n";
|
|
import { dragToScroll } from "../lib/boards-auto-scroll";
|
|
import {
|
|
isRecencyColumn,
|
|
sortCardsForColumn,
|
|
} from "../lib/boards-card-ordering";
|
|
import { loadCategories } from "../lib/boards-categories";
|
|
import { boardsBoardConfigureUrl, boardsBoardUrl } from "../lib/boards-urls";
|
|
import Board from "../models/board";
|
|
import Card from "../models/card";
|
|
import Column from "../models/column";
|
|
import BoardsColumn from "./boards-column";
|
|
import BoardsArchive from "./modal/boards-archive";
|
|
import BoardsBoardSettings from "./modal/boards-board-settings";
|
|
import BoardsCardDetailModal from "./modal/boards-card-detail";
|
|
import BoardsColumnSettings from "./modal/boards-column-settings";
|
|
import BoardsConstraintFix from "./modal/boards-constraint-fix";
|
|
import BoardsTopicCardDetailModal from "./modal/boards-topic-card-detail";
|
|
|
|
const onWindowResize = modifier((element, [callback]) => {
|
|
const wrappedCallback = () => callback(element);
|
|
window.addEventListener("resize", wrappedCallback);
|
|
|
|
const visualViewport = window.visualViewport;
|
|
if (visualViewport) {
|
|
visualViewport.addEventListener("resize", wrappedCallback);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", wrappedCallback);
|
|
if (visualViewport) {
|
|
visualViewport.removeEventListener("resize", wrappedCallback);
|
|
}
|
|
};
|
|
});
|
|
|
|
const matchLastColumnHeight = modifier((element) => {
|
|
const container = element.parentElement;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const update = () => {
|
|
const columns = container.querySelectorAll(
|
|
":scope > .discourse-boards-column"
|
|
);
|
|
const last = columns[columns.length - 1];
|
|
element.style.height = last ? `${last.offsetHeight}px` : "";
|
|
columns.forEach((col) => ro.observe(col));
|
|
};
|
|
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(container);
|
|
update();
|
|
|
|
return () => ro.disconnect();
|
|
});
|
|
|
|
function calcAvailableHeight(element) {
|
|
schedule("afterRender", () => {
|
|
const vv = window.visualViewport;
|
|
const viewportHeight = vv?.height ?? window.innerHeight;
|
|
const offsetTop = vv?.offsetTop ?? 0;
|
|
const top = element.getBoundingClientRect().top - offsetTop;
|
|
const available = viewportHeight - top;
|
|
document.documentElement.style.setProperty(
|
|
"--discourse-boards-available-height",
|
|
`${available}px`
|
|
);
|
|
});
|
|
}
|
|
|
|
export function shouldRefetchMovedCardPayload(existingCard, cardPayload) {
|
|
return !existingCard && !!cardPayload?.topic_id && !cardPayload.topic;
|
|
}
|
|
|
|
export default class BoardsBoardViewer extends Component {
|
|
@service appEvents;
|
|
@service composer;
|
|
@service dialog;
|
|
@service messageBus;
|
|
@service modal;
|
|
@service router;
|
|
@service toasts;
|
|
|
|
@tracked board;
|
|
@tracked columns;
|
|
@tracked dragData = null;
|
|
@tracked dropHighlightCardId = null;
|
|
@tracked fullscreen = false;
|
|
@tracked linkHighlightCardId = null;
|
|
@tracked linkedCardId = null;
|
|
@tracked pendingDropCardId = null;
|
|
|
|
setupMessageBus = modifier((element) => {
|
|
const channel = `/boards/${this.board.id}`;
|
|
this.messageBus.subscribe(channel, this.onBoardMessage);
|
|
|
|
const handleKeyboardMove = (event) => {
|
|
this.#handleCardMoved(event.detail);
|
|
};
|
|
element.addEventListener("boards:card-moved", handleKeyboardMove);
|
|
|
|
return () => {
|
|
this.messageBus.unsubscribe(channel, this.onBoardMessage);
|
|
element.removeEventListener("boards:card-moved", handleKeyboardMove);
|
|
};
|
|
});
|
|
|
|
_promotionAppEventListenersBound = false;
|
|
_promotionRouteListenerBound = false;
|
|
|
|
constructor() {
|
|
super(...arguments);
|
|
this.board = Board.create(this.args.model.board);
|
|
this.columns = this.args.model.columns.map((column) =>
|
|
Column.create(column)
|
|
);
|
|
|
|
if (this.args.initialCardId) {
|
|
schedule("afterRender", () => {
|
|
if (this.isDestroying) {
|
|
return;
|
|
}
|
|
const card = this.#findCard(this.args.initialCardId);
|
|
if (card) {
|
|
this.#openInitialCardModal(card);
|
|
} else {
|
|
DiscourseURL.routeTo(boardsBoardUrl(this.board));
|
|
}
|
|
});
|
|
}
|
|
|
|
if (this.args.highlightCardId) {
|
|
schedule("afterRender", () => {
|
|
if (this.isDestroying) {
|
|
return;
|
|
}
|
|
this.#focusHighlightCard(this.args.highlightCardId);
|
|
});
|
|
}
|
|
|
|
if (this.args.openBoardSettings && this.board.canManage) {
|
|
schedule("afterRender", () => {
|
|
if (this.isDestroying) {
|
|
return;
|
|
}
|
|
this.#openBoardSettingsModal({ updateUrl: false });
|
|
});
|
|
}
|
|
}
|
|
|
|
willDestroy() {
|
|
super.willDestroy(...arguments);
|
|
this._clearDropHighlight();
|
|
this._cleanupPromotion();
|
|
}
|
|
|
|
get boardTagNames() {
|
|
return this.board.tag_names || [];
|
|
}
|
|
|
|
get hasBoardFilters() {
|
|
return this.board.category_ids?.length > 0 || this.boardTagNames.length > 0;
|
|
}
|
|
|
|
get allSameCategory() {
|
|
let seenCategory = null;
|
|
for (const col of this.columns) {
|
|
for (const card of col.cards || []) {
|
|
const catId = card.topic?.category_id;
|
|
if (catId == null) {
|
|
continue;
|
|
}
|
|
if (seenCategory === null) {
|
|
seenCategory = catId;
|
|
} else if (seenCategory !== catId) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@bind
|
|
onBoardMessage(data) {
|
|
if (data.client_id && data.client_id === this.messageBus.clientId) {
|
|
return;
|
|
}
|
|
|
|
switch (data.type) {
|
|
case "board_archived":
|
|
case "board_unarchived":
|
|
reload();
|
|
break;
|
|
case "card_created":
|
|
this.#handleCardCreated(data.card);
|
|
break;
|
|
case "card_updated":
|
|
this.#handleCardUpdated(data.card);
|
|
break;
|
|
case "card_moved":
|
|
this.#handleCardMoved(data.card);
|
|
break;
|
|
case "card_deleted":
|
|
this.#handleCardDeleted(data.card_id);
|
|
break;
|
|
case "column_cleared":
|
|
this.#handleColumnCleared(data.column_id);
|
|
break;
|
|
case "columns_reordered":
|
|
this.#handleColumnsReordered(data.column_order);
|
|
break;
|
|
case "board_updated":
|
|
this.#handleBoardUpdated();
|
|
break;
|
|
}
|
|
}
|
|
|
|
@bind
|
|
refreshBoard() {
|
|
return this.#handleBoardUpdated();
|
|
}
|
|
|
|
@action
|
|
toggleFullscreen() {
|
|
this.fullscreen = !this.fullscreen;
|
|
}
|
|
|
|
@action
|
|
exitFullscreen() {
|
|
this.fullscreen = false;
|
|
}
|
|
|
|
@action
|
|
onDragStart(data) {
|
|
this.dragData = data;
|
|
}
|
|
|
|
@action
|
|
onDragEnd(cardId) {
|
|
next(this, () => {
|
|
if (this.dragData?.cardId === cardId) {
|
|
this.dragData = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
@action
|
|
async onDrop(cardId, toColumnId, afterCardId, fromColumnId) {
|
|
// A move can wait on a server check or a confirmation. Until it settles,
|
|
// keep the card hidden and the placeholder where it was dropped.
|
|
this.pendingDropCardId = cardId;
|
|
try {
|
|
await this.#moveDroppedCard(
|
|
cardId,
|
|
toColumnId,
|
|
afterCardId,
|
|
fromColumnId
|
|
);
|
|
} finally {
|
|
this.#settlePendingDrop(cardId);
|
|
}
|
|
}
|
|
|
|
@action
|
|
async onDeleteCard(cardId) {
|
|
const snapshot = this.columns.map((col) =>
|
|
col.copy({ cards: [...col.cards] })
|
|
);
|
|
|
|
this.#handleCardDeleted(cardId);
|
|
|
|
try {
|
|
await ajax(`/boards/api/boards/${this.board.id}/cards/${cardId}`, {
|
|
type: "DELETE",
|
|
data: { client_id: this.messageBus.clientId },
|
|
});
|
|
} catch (error) {
|
|
this.columns = snapshot;
|
|
popupAjaxError(error);
|
|
}
|
|
}
|
|
|
|
@action
|
|
async onUpdateCard(cardId, updates) {
|
|
try {
|
|
const result = await ajax(
|
|
`/boards/api/boards/${this.board.id}/cards/${cardId}`,
|
|
{
|
|
type: "PUT",
|
|
data: { client_id: this.messageBus.clientId, card: updates },
|
|
}
|
|
);
|
|
|
|
if (result.card) {
|
|
if (result.adopted_floater_id) {
|
|
this.columns = this.columns.map((col) =>
|
|
col.copy({
|
|
cards: col.cards.filter((c) => c.id !== cardId),
|
|
})
|
|
);
|
|
this.#handleCardMoved(result.card);
|
|
} else {
|
|
this.#handleCardUpdated(result.card);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
@action
|
|
async onAddCard({
|
|
topicId,
|
|
title,
|
|
columnId,
|
|
notes,
|
|
tag_ids,
|
|
assigned_to_name,
|
|
}) {
|
|
if (topicId) {
|
|
try {
|
|
const column = this.columns.find(
|
|
(candidate) => candidate.id === columnId
|
|
);
|
|
const constraintFix = await this.#resolveConstraintFix(
|
|
topicId,
|
|
{ id: topicId },
|
|
column
|
|
);
|
|
if (constraintFix === false) {
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
client_id: this.messageBus.clientId,
|
|
card: { column_id: columnId, topic_id: topicId },
|
|
};
|
|
if (constraintFix) {
|
|
data.constraint_fix = constraintFix;
|
|
}
|
|
|
|
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
|
|
type: "POST",
|
|
data,
|
|
});
|
|
this.#appendCardToColumn(result.card, columnId);
|
|
return;
|
|
} catch (error) {
|
|
if (this.#isTopicNotFoundError(error)) {
|
|
await this.#createFallbackFloater(title, columnId);
|
|
return;
|
|
}
|
|
popupAjaxError(error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
|
|
type: "POST",
|
|
data: {
|
|
client_id: this.messageBus.clientId,
|
|
card: {
|
|
column_id: columnId,
|
|
title,
|
|
notes,
|
|
tag_ids,
|
|
assigned_to_name,
|
|
},
|
|
},
|
|
});
|
|
this.#appendCardToColumn(result.card, columnId);
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
}
|
|
}
|
|
|
|
@action
|
|
async onPromoteToTopic(cardId) {
|
|
let card;
|
|
let column;
|
|
for (const col of this.columns) {
|
|
const found = col.cards.find((c) => c.id === cardId);
|
|
if (found) {
|
|
card = found;
|
|
column = col;
|
|
break;
|
|
}
|
|
}
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
this._promotingCardId = cardId;
|
|
this.#bindPromotionListeners();
|
|
|
|
const opts = { title: card.title };
|
|
if (card.notes) {
|
|
opts.body = card.notes;
|
|
}
|
|
|
|
const tagNames = new Set();
|
|
if (card.tags?.length) {
|
|
card.tags.forEach((tag) => tagNames.add(tag.name));
|
|
}
|
|
if (this.board.tag_names?.length) {
|
|
this.board.tag_names.forEach((name) => tagNames.add(name));
|
|
}
|
|
if (column.tag_name) {
|
|
tagNames.add(column.tag_name);
|
|
}
|
|
if (tagNames.size) {
|
|
opts.tags = [...tagNames].join(",");
|
|
}
|
|
|
|
const categoryId =
|
|
column.move_to_category_id || this.board.category_ids?.[0];
|
|
if (categoryId) {
|
|
try {
|
|
opts.category = await Category.asyncFindById(categoryId);
|
|
if (!opts.category) {
|
|
this._cleanupPromotion();
|
|
this.dialog.alert(i18n("boards.board.errors.category_unavailable"));
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
this._cleanupPromotion();
|
|
popupAjaxError(error);
|
|
return;
|
|
}
|
|
}
|
|
this.composer.openNewTopic(opts);
|
|
}
|
|
|
|
// Column management actions
|
|
|
|
@action
|
|
openAddColumnModal(closeMenu) {
|
|
if (typeof closeMenu === "function") {
|
|
closeMenu();
|
|
}
|
|
this.modal.show(BoardsColumnSettings, {
|
|
model: {
|
|
column: null,
|
|
board: this.board,
|
|
onSave: (columnData) => this.addColumn(columnData),
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
openEditColumnModal(columnId) {
|
|
const column = this.columns.find((c) => c.id === columnId);
|
|
if (!column) {
|
|
return;
|
|
}
|
|
this.modal.show(BoardsColumnSettings, {
|
|
model: {
|
|
column,
|
|
board: this.board,
|
|
onSave: (columnData) => this.editColumn(columnId, columnData),
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
async addColumn(columnData) {
|
|
await this._saveColumn(
|
|
"POST",
|
|
`/boards/api/boards/${this.board.id}/columns`,
|
|
{
|
|
column: this._serializeColumn(columnData),
|
|
}
|
|
);
|
|
}
|
|
|
|
@action
|
|
async editColumn(columnId, columnData) {
|
|
const column = this.columns.find((col) => col.id === columnId);
|
|
if (!column) {
|
|
return;
|
|
}
|
|
|
|
await this._saveColumn(
|
|
"PUT",
|
|
`/boards/api/boards/${this.board.id}/columns/${columnId}`,
|
|
{ column: this._serializeColumn(column.copy(columnData)) }
|
|
);
|
|
}
|
|
|
|
@action
|
|
async moveColumn(columnId, direction) {
|
|
const index = this.columns.findIndex((c) => c.id === columnId);
|
|
if (index === -1) {
|
|
return;
|
|
}
|
|
const newIndex = index + direction;
|
|
if (newIndex < 0 || newIndex >= this.columns.length) {
|
|
return;
|
|
}
|
|
|
|
const snapshot = this.columns;
|
|
const reordered = [...this.columns];
|
|
[reordered[index], reordered[newIndex]] = [
|
|
reordered[newIndex],
|
|
reordered[index],
|
|
];
|
|
|
|
this.columns = reordered;
|
|
try {
|
|
const result = await ajax(
|
|
`/boards/api/boards/${this.board.id}/move-column`,
|
|
{
|
|
type: "POST",
|
|
data: {
|
|
column_id: columnId,
|
|
direction,
|
|
client_id: this.messageBus.clientId,
|
|
},
|
|
}
|
|
);
|
|
if (result?.column_order) {
|
|
this.#handleColumnsReordered(result.column_order);
|
|
}
|
|
} catch (error) {
|
|
this.columns = snapshot;
|
|
popupAjaxError(error);
|
|
}
|
|
}
|
|
|
|
@action
|
|
async deleteColumn(column) {
|
|
const saveColumns = async () => {
|
|
try {
|
|
return await this._saveColumn(
|
|
"DELETE",
|
|
`/boards/api/boards/${this.board.id}/columns/${column.id}`
|
|
);
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
}
|
|
};
|
|
|
|
// No need for confirmation if there are no cards in the column.
|
|
if (column.cards.length === 0) {
|
|
await saveColumns();
|
|
return;
|
|
}
|
|
|
|
// Need a bit of a stronger delete confirmation when getting rid
|
|
// of a lot of cards.
|
|
if (column.cards.length > 5) {
|
|
return this.modal.show(PermanentlyDeleteConfirmModal, {
|
|
model: {
|
|
message: i18n("boards.board.confirm_delete_column_with_cards", {
|
|
count: column.cards.length,
|
|
}),
|
|
confirmPhrase: column.title,
|
|
didConfirm: () => {
|
|
saveColumns();
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
this.dialog.confirm({
|
|
message: i18n("boards.board.confirm_delete_column"),
|
|
didConfirm: saveColumns,
|
|
});
|
|
}
|
|
|
|
@action
|
|
clearColumn(columnId) {
|
|
const column = this.columns.find((col) => col.id === columnId);
|
|
const cardCount = column?.cards?.length || 0;
|
|
if (cardCount === 0) {
|
|
return;
|
|
}
|
|
|
|
this.dialog.confirm({
|
|
message: i18n("boards.board.confirm_clear_column", {
|
|
count: cardCount,
|
|
column_title: column.fancyTitle,
|
|
}),
|
|
didConfirm: async () => {
|
|
const snapshot = this.columns.map((col) =>
|
|
col.copy({ cards: [...col.cards] })
|
|
);
|
|
|
|
this.columns = this.columns.map((col) => {
|
|
if (col.id === columnId) {
|
|
return col.copy({ cards: [] });
|
|
}
|
|
return col;
|
|
});
|
|
|
|
try {
|
|
await ajax(
|
|
`/boards/api/boards/${this.board.id}/columns/${columnId}/cards`,
|
|
{
|
|
type: "DELETE",
|
|
data: { client_id: this.messageBus.clientId },
|
|
}
|
|
);
|
|
} catch (error) {
|
|
this.columns = snapshot;
|
|
popupAjaxError(error);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// Board settings actions
|
|
|
|
@action
|
|
openArchiveModal(closeMenu) {
|
|
closeMenu();
|
|
this.modal.show(BoardsArchive, {
|
|
model: {
|
|
board: this.board,
|
|
onSuccess: async (board) => {
|
|
this.toasts.success({
|
|
data: {
|
|
message: board.archived
|
|
? i18n("boards.board.archived_successfully")
|
|
: i18n("boards.board.unarchived_successfully"),
|
|
},
|
|
duration: "short",
|
|
});
|
|
Object.assign(this.board, board);
|
|
await this.#handleBoardUpdated();
|
|
DiscourseURL.replaceState(boardsBoardUrl(this.board));
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
openBoardSettings(closeMenu) {
|
|
closeMenu();
|
|
this.#openBoardSettingsModal();
|
|
}
|
|
|
|
@action
|
|
async saveBoardSettings(boardData) {
|
|
const payload = {
|
|
board: boardData,
|
|
};
|
|
|
|
const originalSlug = this.board.slug;
|
|
|
|
const result = await ajax(`/boards/api/boards/${this.board.id}`, {
|
|
type: "PUT",
|
|
contentType: "application/json",
|
|
data: JSON.stringify(payload),
|
|
});
|
|
|
|
if (result.board) {
|
|
this.board = Board.create({ ...this.board, ...result.board });
|
|
}
|
|
|
|
this.toasts.success({
|
|
data: { message: i18n("saved") },
|
|
duration: "short",
|
|
});
|
|
|
|
if (this.board.slug && this.board.slug !== originalSlug) {
|
|
this.router.replaceWith("boardsBoard", this.board.slug, this.board.id);
|
|
}
|
|
}
|
|
|
|
@action
|
|
deleteBoard(closeMenu) {
|
|
closeMenu?.();
|
|
this.dialog.confirm({
|
|
message: i18n("boards.manage.confirm_delete"),
|
|
didConfirm: async () => {
|
|
try {
|
|
await ajax(`/boards/api/boards/${this.board.id}`, {
|
|
type: "DELETE",
|
|
});
|
|
this.router.transitionTo("boards");
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
@action
|
|
goToAllBoards(closeMenu) {
|
|
closeMenu?.();
|
|
this.router.transitionTo("boards");
|
|
}
|
|
|
|
#handleCardCreated(card) {
|
|
if (card.topic_id && !card.topic) {
|
|
this.#handleBoardUpdated();
|
|
return;
|
|
}
|
|
|
|
this.#appendCardToColumn(card, card.column_id);
|
|
}
|
|
|
|
#handleCardUpdated(card) {
|
|
this.columns = this.columns.map((col) => {
|
|
const cards = col.cards.map((c) => (c.id === card.id ? c.copy(card) : c));
|
|
return col.copy({ cards: sortCardsForColumn(col, cards) });
|
|
});
|
|
}
|
|
|
|
#handleCardMoved(card) {
|
|
const existing = this.#findCard(card.id);
|
|
if (shouldRefetchMovedCardPayload(existing, card)) {
|
|
this.#handleBoardUpdated();
|
|
return;
|
|
}
|
|
|
|
const mergedCard = existing ? existing.copy(card) : Card.create(card);
|
|
|
|
const withoutCard = this.columns.map((col) =>
|
|
col.copy({
|
|
cards: col.cards.filter((c) => c.id !== card.id),
|
|
})
|
|
);
|
|
|
|
this.columns = withoutCard.map((col) => {
|
|
if (col.id === mergedCard.column_id) {
|
|
return col.copy({
|
|
cards: sortCardsForColumn(col, [...col.cards, mergedCard]),
|
|
});
|
|
}
|
|
return col;
|
|
});
|
|
}
|
|
|
|
#handleCardDeleted(cardId) {
|
|
this.columns = this.columns.map((col) =>
|
|
col.copy({
|
|
cards: col.cards.filter((c) => c.id !== cardId),
|
|
})
|
|
);
|
|
}
|
|
|
|
#handleColumnCleared(columnId) {
|
|
this.columns = this.columns.map((col) => {
|
|
if (col.id === columnId) {
|
|
return col.copy({ cards: [] });
|
|
}
|
|
return col;
|
|
});
|
|
}
|
|
|
|
#handleColumnsReordered(columnOrder) {
|
|
const orderMap = new Map(columnOrder.map((id, idx) => [id, idx]));
|
|
this.columns = [...this.columns].sort(
|
|
(a, b) =>
|
|
(orderMap.get(a.id) ?? Infinity) - (orderMap.get(b.id) ?? Infinity)
|
|
);
|
|
}
|
|
|
|
async #handleBoardUpdated() {
|
|
try {
|
|
const result = await ajax(`/boards/api/boards/${this.board.id}.json`);
|
|
await Board.preloadCategories(result);
|
|
if (result.columns) {
|
|
this.columns = result.columns.map((col) =>
|
|
Column.create({
|
|
...col,
|
|
cards: sortCardsForColumn(col, col.cards || []),
|
|
})
|
|
);
|
|
}
|
|
Object.assign(this.board, result.board || result);
|
|
} catch {
|
|
// Board may have been deleted — no action needed
|
|
}
|
|
}
|
|
|
|
async #moveDroppedCard(cardId, toColumnId, afterCardId, fromColumnId) {
|
|
if (!this.board.canWrite || !fromColumnId) {
|
|
return;
|
|
}
|
|
|
|
const fromColumn = this.columns.find((c) => c.id === fromColumnId);
|
|
const toColumn = this.columns.find((c) => c.id === toColumnId);
|
|
if (!fromColumn || !toColumn) {
|
|
return;
|
|
}
|
|
|
|
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
|
|
if (cardIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
const card = fromColumn.cards[cardIndex];
|
|
const isSameColumn = fromColumnId === toColumnId;
|
|
const targetIsRecency = isRecencyColumn(toColumn);
|
|
if (isSameColumn && targetIsRecency) {
|
|
this.dragData = null;
|
|
return;
|
|
}
|
|
|
|
if (targetIsRecency) {
|
|
afterCardId = null;
|
|
}
|
|
|
|
let constraintFix = null;
|
|
|
|
if (!isSameColumn && card.topic) {
|
|
try {
|
|
constraintFix = await this.#resolveConstraintFix(
|
|
card.topic_id || card.topic.id,
|
|
card.topic,
|
|
toColumn
|
|
);
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
return;
|
|
}
|
|
|
|
if (constraintFix === false) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isSameColumn && !constraintFix && this.board.require_confirmation) {
|
|
const confirmed = await this._confirmMove(card, toColumn);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const snapshot = this.columns.map((col) =>
|
|
col.copy({ cards: col.cards.map((c) => c.copy()) })
|
|
);
|
|
|
|
fromColumn.cards.splice(cardIndex, 1);
|
|
|
|
let insertIndex = targetIsRecency ? 0 : toColumn.cards.length;
|
|
if (!targetIsRecency && afterCardId != null) {
|
|
const idx = toColumn.cards.findIndex((c) => c.id === afterCardId);
|
|
if (idx !== -1) {
|
|
insertIndex = idx + 1;
|
|
}
|
|
} else {
|
|
insertIndex = 0;
|
|
}
|
|
toColumn.cards.splice(insertIndex, 0, card);
|
|
card.column_id = toColumnId;
|
|
|
|
this.#settlePendingDrop(cardId);
|
|
this.columns = this.columns.map((col) => {
|
|
if (col.id === fromColumnId || col.id === toColumnId) {
|
|
return col.copy({ cards: [...col.cards] });
|
|
}
|
|
return col;
|
|
});
|
|
this.dragData = null;
|
|
|
|
const data = {
|
|
client_id: this.messageBus.clientId,
|
|
card: {
|
|
column_id: toColumnId,
|
|
after_card_id: afterCardId,
|
|
},
|
|
};
|
|
if (constraintFix) {
|
|
data.constraint_fix = constraintFix;
|
|
}
|
|
|
|
try {
|
|
const result = await ajax(
|
|
`/boards/api/boards/${this.board.id}/cards/${card.id}`,
|
|
{ type: "PUT", data }
|
|
);
|
|
if (result?.card) {
|
|
const existingCard = this.columns
|
|
.flatMap((col) => col.cards)
|
|
.find((c) => c.id === result.card.id);
|
|
const mergedCard = existingCard
|
|
? existingCard.copy(result.card)
|
|
: Card.create(result.card);
|
|
const withoutCard = this.columns.map((col) =>
|
|
col.copy({
|
|
cards: col.cards.filter((c) => c.id !== result.card.id),
|
|
})
|
|
);
|
|
|
|
this.columns = withoutCard.map((col) => {
|
|
if (col.id === mergedCard.column_id) {
|
|
return col.copy({
|
|
cards: sortCardsForColumn(col, [...col.cards, mergedCard]),
|
|
});
|
|
}
|
|
return col;
|
|
});
|
|
}
|
|
this._highlightDroppedCard(card.id);
|
|
} catch (error) {
|
|
this.columns = snapshot;
|
|
popupAjaxError(error);
|
|
}
|
|
}
|
|
|
|
#settlePendingDrop(cardId) {
|
|
if (this.pendingDropCardId !== cardId) {
|
|
return;
|
|
}
|
|
|
|
this.pendingDropCardId = null;
|
|
document
|
|
.querySelectorAll(".discourse-boards-column__drop-indicator")
|
|
.forEach((indicator) => indicator.remove());
|
|
document
|
|
.querySelectorAll(".discourse-boards-column__empty[hidden]")
|
|
.forEach((emptyMessage) => (emptyMessage.hidden = false));
|
|
}
|
|
|
|
async #resolveConstraintFix(topicId, topic, column) {
|
|
if (!column) {
|
|
return null;
|
|
}
|
|
|
|
const response = await ajax(
|
|
`/boards/api/boards/${this.board.id}/check-constraint-mismatches`,
|
|
{
|
|
method: "PUT",
|
|
data: {
|
|
topic_id: topicId,
|
|
target_column_id: column.id,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.constraints_need_fixing) {
|
|
return null;
|
|
}
|
|
|
|
const mismatches = {
|
|
needsTags: response.tags_needed.length > 0,
|
|
needsCategory: response.categories_needed.length > 0,
|
|
boardTagNames: response.tags_needed,
|
|
boardCategoryIds: response.categories_needed,
|
|
};
|
|
|
|
const constraintFix = await this._showConstraintFixModal(
|
|
topic,
|
|
column,
|
|
mismatches
|
|
);
|
|
|
|
return constraintFix ?? false;
|
|
}
|
|
|
|
#isTopicNotFoundError(error) {
|
|
return error?.jqXHR?.status === 404;
|
|
}
|
|
|
|
#appendCardToColumn(card, columnId) {
|
|
if (!card) {
|
|
return;
|
|
}
|
|
card = Card.create(card);
|
|
this.columns = this.columns.map((col) => {
|
|
if (col.id === columnId) {
|
|
return col.copy({
|
|
cards: sortCardsForColumn(col, [
|
|
...col.cards.filter((c) => c.id !== card.id),
|
|
card,
|
|
]),
|
|
});
|
|
}
|
|
return col;
|
|
});
|
|
}
|
|
|
|
async #createFallbackFloater(title, columnId) {
|
|
try {
|
|
const result = await ajax(`/boards/api/boards/${this.board.id}/cards`, {
|
|
type: "POST",
|
|
data: {
|
|
client_id: this.messageBus.clientId,
|
|
card: { column_id: columnId, title },
|
|
},
|
|
});
|
|
this.#appendCardToColumn(result.card, columnId);
|
|
} catch (error) {
|
|
popupAjaxError(error);
|
|
}
|
|
}
|
|
|
|
#openBoardSettingsModal({ updateUrl = true } = {}) {
|
|
const boardUrl = boardsBoardUrl(this.board);
|
|
|
|
if (updateUrl) {
|
|
DiscourseURL.replaceState(boardsBoardConfigureUrl(this.board));
|
|
}
|
|
|
|
this.modal
|
|
.show(BoardsBoardSettings, {
|
|
model: {
|
|
board: this.board,
|
|
isNew: false,
|
|
onSave: (boardData) => this.saveBoardSettings(boardData),
|
|
onDelete: () => this.deleteBoard(),
|
|
},
|
|
})
|
|
.then((result) => {
|
|
if (!this.isDestroying) {
|
|
DiscourseURL.replaceState(boardUrl);
|
|
|
|
if (result?.reloadAfterSave) {
|
|
reload();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#bindPromotionListeners() {
|
|
if (!this._promotionAppEventListenersBound) {
|
|
this.appEvents.on("topic:created", this, this._onTopicCreated);
|
|
this.appEvents.on("composer:cancelled", this, this._cleanupPromotion);
|
|
this._promotionAppEventListenersBound = true;
|
|
}
|
|
|
|
if (!this._promotionRouteListenerBound) {
|
|
this.router.on("routeWillChange", this._onRouteWillChange);
|
|
this._promotionRouteListenerBound = true;
|
|
}
|
|
}
|
|
|
|
#unbindPromotionAppEventListeners() {
|
|
if (!this._promotionAppEventListenersBound) {
|
|
return;
|
|
}
|
|
|
|
this.appEvents.off("topic:created", this, this._onTopicCreated);
|
|
this.appEvents.off("composer:cancelled", this, this._cleanupPromotion);
|
|
this._promotionAppEventListenersBound = false;
|
|
}
|
|
|
|
#unbindPromotionRouteListener() {
|
|
if (!this._promotionRouteListenerBound) {
|
|
return;
|
|
}
|
|
|
|
this.router.off("routeWillChange", this._onRouteWillChange);
|
|
this._promotionRouteListenerBound = false;
|
|
}
|
|
|
|
#findCard(cardId) {
|
|
for (const col of this.columns) {
|
|
const card = col.cards.find((c) => c.id === cardId);
|
|
if (card) {
|
|
return card;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
#focusHighlightCard(cardId) {
|
|
if (!this.#findCard(cardId)) {
|
|
return;
|
|
}
|
|
|
|
// The deep-linked card stays flagged for the lifetime of the board so a
|
|
// card outside its column's recency window keeps rendering; only the pulse
|
|
// highlight below is transient.
|
|
this.linkedCardId = cardId;
|
|
this.linkHighlightCardId = cardId;
|
|
|
|
// next() rather than afterRender: this can be called mid-afterRender
|
|
// flush, before the highlight class renders, and waitForAnimationEnd
|
|
// would then see no animation and fall back to resolving immediately.
|
|
next(this, async () => {
|
|
if (this.isDestroying) {
|
|
return;
|
|
}
|
|
|
|
const cardElement = document.querySelector(
|
|
`.discourse-boards-card[data-card-id="${cardId}"]`
|
|
);
|
|
cardElement?.scrollIntoView({ block: "center", inline: "center" });
|
|
|
|
// With reduced motion there is no animation to wait for; the static
|
|
// highlight stays as a persistent indicator instead.
|
|
if (!cardElement || isTesting() || prefersReducedMotion()) {
|
|
return;
|
|
}
|
|
|
|
await waitForAnimationEnd(cardElement);
|
|
|
|
if (!this.isDestroying && this.linkHighlightCardId === cardId) {
|
|
this.linkHighlightCardId = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
#openInitialCardModal(card) {
|
|
const boardUrl = boardsBoardUrl(this.board);
|
|
const isTopicCard = card.card_type === "topic" && card.topic;
|
|
const ModalComponent = isTopicCard
|
|
? BoardsTopicCardDetailModal
|
|
: BoardsCardDetailModal;
|
|
let navigatedAway = false;
|
|
|
|
const column = this.columns.find((col) => col.id === card.column_id);
|
|
const model = isTopicCard
|
|
? {
|
|
card,
|
|
columnTitle: column?.fancyTitle,
|
|
columnIcon: column?.icon,
|
|
columnColor: column?.color,
|
|
onNavigateAway: (url) => {
|
|
navigatedAway = true;
|
|
DiscourseURL.routeTo(url);
|
|
},
|
|
}
|
|
: { card, board: this.board, onUpdateCard: this.onUpdateCard };
|
|
|
|
this.modal.show(ModalComponent, { model }).finally(() => {
|
|
if (!navigatedAway && !this.isDestroying) {
|
|
DiscourseURL.replaceState(boardUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
_showConstraintFixModal(topic, column, mismatches) {
|
|
return new Promise((resolve) => {
|
|
this.modal.show(BoardsConstraintFix, {
|
|
model: {
|
|
topic,
|
|
board: this.board,
|
|
column,
|
|
mismatches,
|
|
onConfirm: (result) => resolve(result),
|
|
onCancel: () => resolve(null),
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
_confirmMove(card, toColumn) {
|
|
return new Promise((resolve) => {
|
|
const cardTitle = card.fancyTitle || "";
|
|
this.dialog.yesNoConfirm({
|
|
message: i18n("boards.board.move_confirm", {
|
|
topic_title: cardTitle,
|
|
column_title: toColumn.fancyTitle,
|
|
}),
|
|
didConfirm: () => resolve(true),
|
|
didCancel: () => resolve(false),
|
|
});
|
|
});
|
|
}
|
|
|
|
_serializeColumn(col) {
|
|
return {
|
|
id: col.id,
|
|
title: col.title,
|
|
icon: col.icon,
|
|
color: col.color || null,
|
|
default_sort: col.default_sort || "priority",
|
|
tag_name: col.tag_name || null,
|
|
move_to_category_id: col.move_to_category_id,
|
|
move_to_assigned:
|
|
col.move_to_assigned === "_user" ? "" : col.move_to_assigned,
|
|
move_to_status: col.move_to_status,
|
|
};
|
|
}
|
|
|
|
async _saveColumn(type, url, payload = {}) {
|
|
await ajax(url, {
|
|
type,
|
|
contentType: "application/json",
|
|
data: JSON.stringify({
|
|
...payload,
|
|
client_id: this.messageBus.clientId,
|
|
}),
|
|
});
|
|
|
|
this.toasts.success({
|
|
data: { message: i18n("saved") },
|
|
duration: "short",
|
|
});
|
|
|
|
await this.#handleBoardUpdated();
|
|
}
|
|
|
|
@bind
|
|
_onTopicCreated(createdPost) {
|
|
const cardId = this._promotingCardId;
|
|
this._cleanupPromotion();
|
|
this.onUpdateCard(cardId, { topic_id: createdPost.topic_id });
|
|
}
|
|
|
|
@bind
|
|
_onRouteWillChange(transition) {
|
|
if (this._promotingCardId) {
|
|
transition.abort();
|
|
this.#unbindPromotionRouteListener();
|
|
this._promotingCardId = null;
|
|
}
|
|
}
|
|
|
|
_cleanupPromotion() {
|
|
this._promotingCardId = null;
|
|
this.#unbindPromotionAppEventListeners();
|
|
this.#unbindPromotionRouteListener();
|
|
}
|
|
|
|
_highlightDroppedCard(cardId) {
|
|
this._clearDropHighlight();
|
|
this.dropHighlightCardId = null;
|
|
|
|
schedule("afterRender", () => {
|
|
if (this.isDestroying) {
|
|
return;
|
|
}
|
|
|
|
this.dropHighlightCardId = cardId;
|
|
if (isTesting()) {
|
|
return;
|
|
}
|
|
|
|
this._dropHighlightTimeout = discourseLater(
|
|
this,
|
|
() => {
|
|
if (this.dropHighlightCardId === cardId) {
|
|
this.dropHighlightCardId = null;
|
|
}
|
|
this._dropHighlightTimeout = null;
|
|
},
|
|
1000
|
|
);
|
|
});
|
|
}
|
|
|
|
_clearDropHighlight() {
|
|
if (this._dropHighlightTimeout) {
|
|
cancel(this._dropHighlightTimeout);
|
|
this._dropHighlightTimeout = null;
|
|
}
|
|
}
|
|
|
|
<template>
|
|
{{#if this.fullscreen}}
|
|
{{bodyClass "discourse-boards-fullscreen"}}
|
|
{{/if}}
|
|
|
|
<div
|
|
class="discourse-boards-board-viewer
|
|
{{if this.fullscreen 'discourse-boards-board-viewer--fullscreen'}}"
|
|
{{this.setupMessageBus}}
|
|
{{onWindowResize calcAvailableHeight}}
|
|
{{didInsert calcAvailableHeight}}
|
|
>
|
|
<div class="discourse-boards-board-viewer__header">
|
|
<div class="discourse-boards-board-viewer__title-wrapper">
|
|
<BackButton @label="boards.board.all_boards" @route="boards" />
|
|
<h2 class="discourse-boards-board-viewer__title">
|
|
{{#if this.board.archived}}
|
|
<DTooltip>
|
|
<:trigger>
|
|
{{dIcon "box-archive"}}
|
|
</:trigger>
|
|
<:content>
|
|
<span>{{i18n "boards.board.archived_tooltip"}}</span>
|
|
</:content>
|
|
</DTooltip>
|
|
{{/if}}
|
|
{{this.board.fancyTitle}}
|
|
</h2>
|
|
|
|
<div class="discourse-boards-board-viewer__metadata">
|
|
{{#if this.hasBoardFilters}}
|
|
<div class="discourse-boards-board-viewer__constraint">
|
|
<DAsyncContent
|
|
@asyncData={{loadCategories}}
|
|
@context={{this.board.category_ids}}
|
|
>
|
|
<:content as |categories|>
|
|
{{#each categories as |category|}}
|
|
{{dBoundCategoryLink category link=true}}
|
|
{{/each}}
|
|
</:content>
|
|
</DAsyncContent>
|
|
{{#if this.boardTagNames.length}}
|
|
<div class="list-tags">
|
|
{{dDiscourseTags null tags=this.boardTagNames}}
|
|
</div>
|
|
{{/if}}
|
|
<DTooltip>
|
|
<:trigger>
|
|
{{dIcon "circle-info"}}
|
|
</:trigger>
|
|
<:content>
|
|
<span>{{i18n "boards.board.constraint_tooltip"}}</span>
|
|
</:content>
|
|
</DTooltip>
|
|
</div>
|
|
{{/if}}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="discourse-boards-board-viewer__controls">
|
|
{{#if
|
|
(or
|
|
this.board.canManage
|
|
this.board.can_archive
|
|
this.board.can_unarchive
|
|
)
|
|
}}
|
|
<DMenu
|
|
@icon="ellipsis"
|
|
@identifier="boards-board-controls"
|
|
@title="보드 메뉴"
|
|
@triggerClass="btn-flat"
|
|
>
|
|
<:content as |args|>
|
|
<DDropdownMenu as |dropdown|>
|
|
{{#if this.board.canManage}}
|
|
<dropdown.item data-identifier="add-column">
|
|
<DButton
|
|
class="btn-transparent"
|
|
@action={{fn this.openAddColumnModal args.close}}
|
|
@icon="plus"
|
|
@label="boards.board.add_column"
|
|
/>
|
|
</dropdown.item>
|
|
<dropdown.item data-identifier="board-settings">
|
|
<DButton
|
|
class="btn-transparent"
|
|
@action={{fn this.openBoardSettings args.close}}
|
|
@icon="gear"
|
|
@label="boards.board.board_settings"
|
|
/>
|
|
</dropdown.item>
|
|
{{/if}}
|
|
{{#if (or this.board.can_archive this.board.can_unarchive)}}
|
|
<dropdown.item data-identifier="archive-board">
|
|
<DButton
|
|
class="btn-transparent"
|
|
@action={{fn this.openArchiveModal args.close}}
|
|
@icon="box-archive"
|
|
@label={{if
|
|
this.board.archived
|
|
"boards.board.unarchive_board_menu"
|
|
"boards.board.archive_board"
|
|
}}
|
|
/>
|
|
</dropdown.item>
|
|
{{/if}}
|
|
{{#if this.board.canManage}}
|
|
<dropdown.item data-identifier="delete-board">
|
|
<DButton
|
|
class="btn-transparent btn-danger"
|
|
@action={{fn this.deleteBoard args.close}}
|
|
@icon="trash-can"
|
|
@label="boards.board.delete_board"
|
|
/>
|
|
</dropdown.item>
|
|
{{/if}}
|
|
</DDropdownMenu>
|
|
</:content>
|
|
</DMenu>
|
|
{{/if}}
|
|
{{#if this.fullscreen}}
|
|
<DButton
|
|
class="btn-flat discourse-boards-board-viewer__exit-fullscreen"
|
|
@action={{this.exitFullscreen}}
|
|
@icon="discourse-compress"
|
|
@title="boards.board.exit_fullscreen"
|
|
/>
|
|
{{else}}
|
|
<DButton
|
|
class="btn-flat"
|
|
@action={{this.toggleFullscreen}}
|
|
@icon="discourse-expand"
|
|
@title="boards.board.fullscreen"
|
|
/>
|
|
{{/if}}
|
|
</div>
|
|
</div>
|
|
|
|
{{#if this.columns.length}}
|
|
<div
|
|
class="discourse-boards-board-container"
|
|
{{dDragAndDropAutoScroll axis="horizontal" types="boards-card"}}
|
|
{{dragToScroll}}
|
|
>
|
|
{{#each this.columns key="id" as |column|}}
|
|
<BoardsColumn
|
|
@allColumns={{this.columns}}
|
|
@allSameCategory={{this.allSameCategory}}
|
|
@board={{this.board}}
|
|
@column={{column}}
|
|
@dragData={{this.dragData}}
|
|
@dropHighlightCardId={{this.dropHighlightCardId}}
|
|
@linkedCardId={{this.linkedCardId}}
|
|
@linkHighlightCardId={{this.linkHighlightCardId}}
|
|
@onAddCard={{this.onAddCard}}
|
|
@onClearColumn={{this.clearColumn}}
|
|
@onDeleteCard={{this.onDeleteCard}}
|
|
@onDeleteColumn={{this.deleteColumn}}
|
|
@onDragEnd={{this.onDragEnd}}
|
|
@onDragStart={{this.onDragStart}}
|
|
@onDrop={{this.onDrop}}
|
|
@onEditColumn={{this.openEditColumnModal}}
|
|
@onMoveColumn={{this.moveColumn}}
|
|
@onPromoteToTopic={{this.onPromoteToTopic}}
|
|
@onRefreshBoard={{this.refreshBoard}}
|
|
@onUpdateCard={{this.onUpdateCard}}
|
|
@pendingDropCardId={{this.pendingDropCardId}}
|
|
/>
|
|
{{/each}}
|
|
{{#if this.board.canManage}}
|
|
<button
|
|
class="discourse-boards-board-container__add-column"
|
|
title={{i18n "boards.board.add_column"}}
|
|
type="button"
|
|
{{on "click" this.openAddColumnModal}}
|
|
{{matchLastColumnHeight}}
|
|
>
|
|
{{dIcon "plus"}}
|
|
</button>
|
|
{{/if}}
|
|
</div>
|
|
{{else}}
|
|
<div class="discourse-boards-board-viewer__empty">
|
|
<div class="discourse-boards-board-viewer__empty-column">
|
|
{{dIcon "table-columns"}}
|
|
<h3>{{i18n "boards.board.empty_board"}}</h3>
|
|
{{#if this.board.canManage}}
|
|
<p>{{i18n "boards.board.empty_board_cta"}}</p>
|
|
<DButton
|
|
class="btn-primary"
|
|
@action={{this.openAddColumnModal}}
|
|
@icon="plus"
|
|
@label="boards.board.add_column"
|
|
/>
|
|
{{/if}}
|
|
</div>
|
|
</div>
|
|
{{/if}}
|
|
</div>
|
|
</template>
|
|
}
|