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 { service } from "@ember/service"; import DMenu from "discourse/float-kit/components/d-menu"; import { eq } from "discourse/truth-helpers"; import DButton from "discourse/ui-kit/d-button"; import DDropdownMenu from "discourse/ui-kit/d-dropdown-menu"; import dIcon from "discourse/ui-kit/helpers/d-icon"; import dDragAndDropAutoScroll from "discourse/ui-kit/modifiers/d-drag-and-drop-auto-scroll"; import dDragAndDropTarget from "discourse/ui-kit/modifiers/d-drag-and-drop-target"; import { i18n } from "discourse-i18n"; import { isRecencyColumn } from "../lib/boards-card-ordering"; import { columnColorVariable, hasColumnColor, } from "../lib/boards-column-helpers"; import { animateCardReorder, captureCardRects } from "../lib/boards-motion"; import BoardsCard from "./boards-card"; import BoardsAddTopicAsCardModal from "./modal/boards-add-topic-as-card"; import BoardsCardDetailModal from "./modal/boards-card-detail"; const RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; export function recencyTimestamp(card) { const value = Date.parse(card?.recency_at || ""); return Number.isFinite(value) ? value : 0; } export function recencyDropIndicatorInsertBefore( cardsContainer, cardElements, draggedCardId ) { return ( cardElements.find((cardEl) => { const elCardId = parseInt(cardEl.dataset.cardId, 10); return elCardId !== draggedCardId; }) || cardsContainer.querySelector(".discourse-boards-column__show-all") ); } export function shouldAnimateDropIndicatorPlacement({ hadIndicator, columnId, fromColumnId, hasPlacedIndicator, }) { return hadIndicator || columnId !== fromColumnId || hasPlacedIndicator; } export default class BoardsColumn extends Component { @service modal; @tracked showAllCards = false; get cardCount() { return this.args.column.cards?.length || 0; } get visibleCards() { const cards = this.args.column.cards || []; if (!this.isRecencySorted || this.showAllCards) { return cards; } const cutoff = Date.now() - RECENCY_WINDOW_MS; return cards.filter( (card) => card.id === this.args.linkedCardId || recencyTimestamp(card) >= cutoff ); } get hiddenCardCount() { if (!this.isRecencySorted || this.showAllCards) { return 0; } return this.cardCount - this.visibleCards.length; } get isRecencySorted() { return isRecencyColumn(this.args.column); } get columnTags() { const allColumns = this.args.allColumns || []; return allColumns.map((col) => col.tag_name).filter(Boolean); } get columnIndex() { const allColumns = this.args.allColumns || []; return allColumns.findIndex((col) => col.id === this.args.column.id); } get lastColumnIndex() { return (this.args.allColumns?.length || 0) - 1; } @action showAllOlderCards() { this.showAllCards = true; } @action async startAddCard(closeMenu) { await closeMenu(); this.modal.show(BoardsCardDetailModal, { model: { card: {}, isNew: true, board: this.args.board, onCreateCard: (data) => this.args.onAddCard({ ...data, columnId: this.args.column.id }), }, }); } @action async addTopicAsCard(closeMenu) { await closeMenu(); this.modal.show(BoardsAddTopicAsCardModal, { model: { onAddTopicAsCard: (data) => this.args.onAddCard({ ...data, columnId: this.args.column.id }), }, }); } @action editColumn(closeMenu) { closeMenu(); this.args.onEditColumn(this.args.column.id); } @action moveLeft(closeMenu) { closeMenu(); this.args.onMoveColumn(this.args.column.id, -1); } @action moveRight(closeMenu) { closeMenu(); this.args.onMoveColumn(this.args.column.id, 1); } @action clearColumn(closeMenu) { closeMenu(); this.args.onClearColumn(this.args.column.id); } @action deleteColumn(closeMenu) { closeMenu(); this.args.onDeleteColumn(this.args.column); } @action canDropCard({ source }) { // Recency columns sort themselves, so reordering within one is meaningless. return !( this.isRecencySorted && source.data.fromColumnId === this.args.column.id ); } @action dragOver({ element, location }) { const dragData = this.args.dragData; if (!dragData) { return; } element.classList.add("discourse-boards-column--drag-target"); const cardsContainer = element.querySelector( ".discourse-boards-column__cards" ); if (!cardsContainer) { return; } const { clientY } = location.current.input; let indicator = cardsContainer.querySelector( ".discourse-boards-column__drop-indicator" ); const hadIndicator = !!indicator; if (!indicator) { indicator = document.createElement("div"); indicator.className = "discourse-boards-column__drop-indicator"; } indicator.style.height = `${dragData.cardHeight}px`; const cardElements = [ ...cardsContainer.querySelectorAll(".discourse-boards-card"), ]; let insertBefore = null; if (this.isRecencySorted) { insertBefore = recencyDropIndicatorInsertBefore( cardsContainer, cardElements, dragData.cardId ); } else { for (const cardEl of cardElements) { const elCardId = parseInt(cardEl.dataset.cardId, 10); if (elCardId === dragData.cardId) { continue; } const rect = cardEl.getBoundingClientRect(); if (clientY <= rect.top + rect.height / 2) { insertBefore = cardEl; break; } } } const emptyMsg = cardsContainer.querySelector( ".discourse-boards-column__empty" ); if (emptyMsg) { emptyMsg.hidden = true; } if ( this.#indicatorMatchesPosition(cardsContainer, indicator, insertBefore) ) { return; } const shouldAnimate = shouldAnimateDropIndicatorPlacement({ hadIndicator, columnId: this.args.column.id, fromColumnId: dragData.fromColumnId, hasPlacedIndicator: dragData.hasPlacedIndicator, }); const previousRects = shouldAnimate ? captureCardRects(cardsContainer, { skipCardIds: [dragData.cardId], }) : null; indicator.classList.remove( "discourse-boards-column__drop-indicator--source" ); if (insertBefore) { cardsContainer.insertBefore(indicator, insertBefore); } else { cardsContainer.appendChild(indicator); } if (shouldAnimate) { animateCardReorder(cardsContainer, previousRects, { skipCardIds: [dragData.cardId], }); } dragData.hasPlacedIndicator = true; } @action dragLeave({ element }) { element.classList.remove("discourse-boards-column--drag-target"); this.removeDropIndicator(element, { animate: true }); } @action drop({ element, location }) { element.classList.remove("discourse-boards-column--drag-target"); const dragData = this.args.dragData; if (!dragData) { this.removeDropIndicator(element, { animate: false }); return; } const cardsContainer = element.querySelector( ".discourse-boards-column__cards" ); const { clientY } = location.current.input; let afterCardId = null; if (cardsContainer && !this.isRecencySorted) { const cardElements = [ ...cardsContainer.querySelectorAll(".discourse-boards-card"), ]; for (const cardEl of cardElements) { const elCardId = parseInt(cardEl.dataset.cardId, 10); if (elCardId === dragData.cardId) { continue; } const rect = cardEl.getBoundingClientRect(); if (clientY > rect.top + rect.height / 2) { afterCardId = elCardId; } } } // The board viewer removes the placeholder once the move settles. this.args.onDrop( dragData.cardId, this.args.column.id, afterCardId, dragData.fromColumnId ); } removeDropIndicator(columnEl, { animate = false } = {}) { const cardsContainer = columnEl.querySelector( ".discourse-boards-column__cards" ); const indicator = columnEl.querySelector( ".discourse-boards-column__drop-indicator" ); const dragData = this.args.dragData; if (!indicator) { columnEl .querySelector(".discourse-boards-column__empty") ?.removeAttribute("hidden"); return; } const previousRects = animate && cardsContainer ? captureCardRects(cardsContainer, { skipCardIds: dragData ? [dragData.cardId] : [], }) : null; indicator.remove(); const emptyMsg = columnEl.querySelector(".discourse-boards-column__empty"); if (emptyMsg) { emptyMsg.hidden = false; } if (cardsContainer && previousRects) { animateCardReorder(cardsContainer, previousRects, { skipCardIds: dragData ? [dragData.cardId] : [], }); } } #indicatorMatchesPosition(cardsContainer, indicator, insertBefore) { if (indicator.parentElement !== cardsContainer) { return false; } if (insertBefore) { return indicator.nextElementSibling === insertBefore; } return indicator === cardsContainer.lastElementChild; } }