feat(kanban): add column visibility toggle with hide/unhide functionality

This commit is contained in:
Quorafind 2025-12-03 08:56:19 +08:00
parent e0b6d079b5
commit 6ce9671511
8 changed files with 197 additions and 66 deletions

View file

@ -104,6 +104,7 @@ export interface KanbanSpecificConfig {
| "context"
| "filePath";
customColumns?: KanbanColumnConfig[]; // Custom column definitions when not using status
hiddenColumns?: string[]; // List of hidden column titles
}
export interface KanbanColumnConfig {

View file

@ -1,4 +1,4 @@
import { App, Component, setIcon } from "obsidian";
import { App, Component, setIcon, Menu } from "obsidian";
import { Task } from "@/types/task"; // Adjust path
import { KanbanCardComponent } from "./kanban-card";
import TaskProgressBarPlugin from "@/index"; // Adjust path
@ -38,6 +38,7 @@ export class KanbanColumnComponent extends Component {
filterType: string,
value: string | number | string[],
) => void;
onColumnHide?: (columnTitle: string) => void;
},
) {
super();
@ -88,6 +89,25 @@ export class KanbanColumnComponent extends Component {
text: `(${this.tasks.length})`,
});
// Add context menu for "Hide this column"
this.registerDomEvent(
this.headerEl,
"contextmenu",
(event: MouseEvent) => {
if (this.params.onColumnHide) {
const menu = new Menu();
menu.addItem((item) => {
item.setTitle(t("Hide this column"))
.setIcon("eye-off")
.onClick(() => {
this.params.onColumnHide?.(this.statusName);
});
});
menu.showAtMouseEvent(event);
}
},
);
// Column Content (Scrollable Area for Cards, and Drop Zone)
this.contentEl = this.element.createDiv({
cls: "tg-kanban-column-content",

View file

@ -602,6 +602,24 @@ export class KanbanComponent extends Component {
}
}
private handleColumnHide(title: string): void {
const viewConfig = this.plugin.settings.viewConfiguration.find(
(v) => v.id === this.currentViewId,
);
if (viewConfig && viewConfig.specificConfig?.viewType === "kanban") {
const kanbanConfig =
viewConfig.specificConfig as KanbanSpecificConfig;
if (!kanbanConfig.hiddenColumns) {
kanbanConfig.hiddenColumns = [];
}
if (!kanbanConfig.hiddenColumns.includes(title)) {
kanbanConfig.hiddenColumns.push(title);
this.plugin.saveSettings();
this.renderColumns();
}
}
}
private renderColumns() {
this.columnContainerEl?.empty();
this.columns.forEach((col) => this.removeChild(col));
@ -696,7 +714,13 @@ export class KanbanComponent extends Component {
// Apply saved column order to status names
const statusColumns = statusNames.map((name) => ({ title: name }));
const orderedStatusColumns = this.applyColumnOrder(statusColumns);
const orderedStatusNames = orderedStatusColumns.map((col) => col.title);
// Filter hidden columns
const hiddenColumns =
this.getEffectiveKanbanConfig()?.hiddenColumns || [];
const orderedStatusNames = orderedStatusColumns
.map((col) => col.title)
.filter((title) => !hiddenColumns.includes(title));
orderedStatusNames.forEach((statusName) => {
const tasksForStatus = this.getTasksForStatus(statusName);
@ -714,6 +738,7 @@ export class KanbanComponent extends Component {
newStatusMark: string,
) => this.handleStatusUpdate(taskId, newStatusMark),
onFilterApply: this.handleFilterApply,
onColumnHide: (title) => this.handleColumnHide(title),
},
);
this.addChild(column);
@ -737,6 +762,7 @@ export class KanbanComponent extends Component {
newStatusMark: string,
) => this.handleStatusUpdate(taskId, newStatusMark),
onFilterApply: this.handleFilterApply,
onColumnHide: (title) => this.handleColumnHide(title),
},
);
this.addChild(otherColumn); // Must call addChild first to trigger onload()
@ -790,7 +816,14 @@ export class KanbanComponent extends Component {
// Apply saved column order to column configurations
const orderedColumnConfigs = this.applyColumnOrder(columnConfigs);
// Filter hidden columns
const hiddenColumns =
this.getEffectiveKanbanConfig()?.hiddenColumns || [];
orderedColumnConfigs.forEach((config) => {
// Skip hidden columns
if (hiddenColumns.includes(config.title)) return;
const tasksForColumn = this.getTasksForProperty(
groupBy,
config.value,
@ -812,6 +845,7 @@ export class KanbanComponent extends Component {
newValue,
),
onFilterApply: this.handleFilterApply,
onColumnHide: (title) => this.handleColumnHide(title),
},
);
this.addChild(column);

View file

@ -9,14 +9,14 @@ import "@/styles/view-setting-tab.css";
export function renderViewSettingsTab(
settingTab: TaskProgressBarSettingTab,
containerEl: HTMLElement
containerEl: HTMLElement,
) {
new Setting(containerEl)
.setName(t("View Configuration"))
.setDesc(
t(
"Configure the Task Genius sidebar views, visibility, order, and create custom views."
)
"Configure the Task Genius sidebar views, visibility, order, and create custom views.",
),
)
.setHeading();
@ -24,8 +24,8 @@ export function renderViewSettingsTab(
.setName(t("Enable Task Genius Views"))
.setDesc(
t(
"Enable Task Genius sidebar views to display and manage tasks. Requires the indexer to be enabled."
)
"Enable Task Genius sidebar views to display and manage tasks. Requires the indexer to be enabled.",
),
)
.addToggle((toggle) => {
toggle.setValue(settingTab.plugin.settings.enableView);
@ -34,8 +34,8 @@ export function renderViewSettingsTab(
// If trying to enable views but indexer is disabled, show warning
new Notice(
t(
"Cannot enable views without indexer. Please enable the indexer first in Index & Sources settings."
)
"Cannot enable views without indexer. Please enable the indexer first in Index & Sources settings.",
),
);
toggle.setValue(false);
return;
@ -51,7 +51,7 @@ export function renderViewSettingsTab(
new Setting(containerEl)
.setName(t("Views are disabled"))
.setDesc(
t("Enable Task Genius Views above to configure view settings.")
t("Enable Task Genius Views above to configure view settings."),
);
return;
}
@ -60,8 +60,8 @@ export function renderViewSettingsTab(
.setName(t("Default view mode"))
.setDesc(
t(
"Choose the default display mode for all views. This affects how tasks are displayed when you first open a view or create a new view."
)
"Choose the default display mode for all views. This affects how tasks are displayed when you first open a view or create a new view.",
),
)
.addDropdown((dropdown) => {
dropdown
@ -86,8 +86,8 @@ export function renderViewSettingsTab(
.setName(t("Default project view mode"))
.setDesc(
t(
"Choose whether to display projects as a flat list or hierarchical tree by default."
)
"Choose whether to display projects as a flat list or hierarchical tree by default.",
),
)
.addDropdown((dropdown) => {
dropdown
@ -105,8 +105,8 @@ export function renderViewSettingsTab(
.setName(t("Auto-expand project tree"))
.setDesc(
t(
"Automatically expand all project nodes when opening the project view in tree mode."
)
"Automatically expand all project nodes when opening the project view in tree mode.",
),
)
.addToggle((toggle) => {
toggle
@ -120,12 +120,12 @@ export function renderViewSettingsTab(
new Setting(containerEl)
.setName(t("Show empty project folders"))
.setDesc(
t("Display project folders even if they don't contain any tasks.")
t("Display project folders even if they don't contain any tasks."),
)
.addToggle((toggle) => {
toggle
.setValue(
settingTab.plugin.settings.projectTreeShowEmptyFolders
settingTab.plugin.settings.projectTreeShowEmptyFolders,
)
.onChange((value) => {
settingTab.plugin.settings.projectTreeShowEmptyFolders =
@ -138,8 +138,8 @@ export function renderViewSettingsTab(
.setName(t("Project path separator"))
.setDesc(
t(
"Character used to separate project hierarchy levels (e.g., '/' in 'Project/SubProject')."
)
"Character used to separate project hierarchy levels (e.g., '/' in 'Project/SubProject').",
),
)
.addText((text) => {
text.setPlaceholder("/")
@ -161,8 +161,8 @@ export function renderViewSettingsTab(
.setName(t("Use relative time for date"))
.setDesc(
t(
"Use relative time for date in task list item, e.g. 'yesterday', 'today', 'tomorrow', 'in 2 days', '3 months ago', etc."
)
"Use relative time for date in task list item, e.g. 'yesterday', 'today', 'tomorrow', 'in 2 days', '3 months ago', etc.",
),
)
.addToggle((toggle) => {
toggle.setValue(settingTab.plugin.settings.useRelativeTimeForDate);
@ -182,8 +182,8 @@ export function renderViewSettingsTab(
.setName(t("Enable inline editor"))
.setDesc(
t(
"Enable inline editing of task content and metadata directly in task views. When disabled, tasks can only be edited in the source file."
)
"Enable inline editing of task content and metadata directly in task views. When disabled, tasks can only be edited in the source file.",
),
)
.addToggle((toggle) => {
toggle.setValue(settingTab.plugin.settings.enableInlineEditor);
@ -197,12 +197,12 @@ export function renderViewSettingsTab(
.setName(t("Enable dynamic metadata positioning"))
.setDesc(
t(
"Intelligently position task metadata. When enabled, metadata appears on the same line as short tasks and below long tasks. When disabled, metadata always appears below the task content."
)
"Intelligently position task metadata. When enabled, metadata appears on the same line as short tasks and below long tasks. When disabled, metadata always appears below the task content.",
),
)
.addToggle((toggle) => {
toggle.setValue(
settingTab.plugin.settings.enableDynamicMetadataPositioning
settingTab.plugin.settings.enableDynamicMetadataPositioning,
);
toggle.onChange((value) => {
settingTab.plugin.settings.enableDynamicMetadataPositioning =
@ -216,8 +216,8 @@ export function renderViewSettingsTab(
.setName(t("Global Filter Configuration"))
.setDesc(
t(
"Configure global filter rules that apply to all Views by default. Individual Views can override these settings."
)
"Configure global filter rules that apply to all Views by default. Individual Views can override these settings.",
),
)
.setHeading();
@ -243,7 +243,7 @@ export function renderViewSettingsTab(
if (settingTab.plugin.settings.globalFilterRules.advancedFilter) {
settingTab.app.saveLocalStorage(
"task-genius-view-filter-global-filter",
settingTab.plugin.settings.globalFilterRules.advancedFilter
settingTab.plugin.settings.globalFilterRules.advancedFilter,
);
}
@ -251,7 +251,7 @@ export function renderViewSettingsTab(
globalFilterContainer,
settingTab.app,
"global-filter", // Use a special leafId for global filter
settingTab.plugin
settingTab.plugin,
);
// Load the component
@ -281,8 +281,8 @@ export function renderViewSettingsTab(
if (leafId === "global-filter") {
handleGlobalFilterChange(filterState);
}
}
)
},
),
);
};
@ -311,8 +311,8 @@ export function renderViewSettingsTab(
.setName(t("Manage Views"))
.setDesc(
t(
"Drag views between sections or within sections to reorder them. Toggle visibility with the eye icon."
)
"Drag views between sections or within sections to reorder them. Toggle visibility with the eye icon.",
),
)
.setHeading();
@ -418,7 +418,7 @@ export function renderViewSettingsTab(
// Emit event to notify TaskView sidebar to update without full view refresh
(settingTab.app.workspace as any).trigger(
"task-genius:view-config-changed",
{ reason: "visibility-changed", viewId: view.id }
{ reason: "visibility-changed", viewId: view.id },
);
};
@ -443,7 +443,7 @@ export function renderViewSettingsTab(
(updatedView: ViewConfig, updatedRules: ViewFilterRule) => {
const currentIndex =
settingTab.plugin.settings.viewConfiguration.findIndex(
(v) => v.id === updatedView.id
(v) => v.id === updatedView.id,
);
if (currentIndex !== -1) {
settingTab.plugin.settings.viewConfiguration[
@ -459,10 +459,10 @@ export function renderViewSettingsTab(
{
reason: "view-updated",
viewId: updatedView.id,
}
},
);
}
}
},
).open();
};
@ -483,7 +483,7 @@ export function renderViewSettingsTab(
(createdView: ViewConfig, createdRules: ViewFilterRule) => {
if (
!settingTab.plugin.settings.viewConfiguration.some(
(v) => v.id === createdView.id
(v) => v.id === createdView.id,
)
) {
settingTab.plugin.settings.viewConfiguration.push({
@ -497,18 +497,18 @@ export function renderViewSettingsTab(
{
reason: "view-copied",
viewId: createdView.id,
}
},
);
new Notice(
t("View copied successfully: ") +
createdView.name
createdView.name,
);
} else {
new Notice(t("Error: View ID already exists."));
}
},
view,
view.id
view.id,
).open();
};
@ -528,12 +528,12 @@ export function renderViewSettingsTab(
deleteBtn.onclick = () => {
const index =
settingTab.plugin.settings.viewConfiguration.findIndex(
(v) => v.id === view.id
(v) => v.id === view.id,
);
if (index !== -1) {
settingTab.plugin.settings.viewConfiguration.splice(
index,
1
1,
);
settingTab.applySettingsUpdate();
renderViewList();
@ -547,7 +547,7 @@ export function renderViewSettingsTab(
// Render views in their respective containers
topViews.forEach((view) => createViewItem(view, topViewsContainer));
bottomViews.forEach((view) =>
createViewItem(view, bottomViewsContainer)
createViewItem(view, bottomViewsContainer),
);
// Setup sortable for both containers
@ -561,7 +561,7 @@ export function renderViewSettingsTab(
const viewId = el.getAttribute("data-view-id");
const view =
settingTab.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (view) {
view.region = "top";
@ -576,7 +576,7 @@ export function renderViewSettingsTab(
const viewId = el.getAttribute("data-view-id");
const view =
settingTab.plugin.settings.viewConfiguration.find(
(v) => v.id === viewId
(v) => v.id === viewId,
);
if (view) {
view.region = "bottom";
@ -589,7 +589,7 @@ export function renderViewSettingsTab(
settingTab.plugin.saveSettings();
(settingTab.app.workspace as any).trigger(
"task-genius:view-config-changed",
{ reason: "order-changed" }
{ reason: "order-changed" },
);
};
@ -621,6 +621,19 @@ export function renderViewSettingsTab(
renderViewList(); // Initial render
// Listen for view config changes from FluentSidebar or other sources
settingTab.plugin.registerEvent(
settingTab.app.workspace.on(
"task-genius:view-config-changed",
(payload: { reason: string }) => {
// Only refresh if the change came from sidebar reorder
if (payload?.reason === "sidebar-reorder") {
renderViewList();
}
},
),
);
// Add New Custom View Button (Logic unchanged)
const addBtnContainer = containerEl.createDiv();
new Setting(addBtnContainer).addButton((button) => {
@ -636,7 +649,7 @@ export function renderViewSettingsTab(
(createdView: ViewConfig, createdRules: ViewFilterRule) => {
if (
!settingTab.plugin.settings.viewConfiguration.some(
(v) => v.id === createdView.id
(v) => v.id === createdView.id,
)
) {
// Save with filter rules embedded
@ -648,12 +661,15 @@ export function renderViewSettingsTab(
renderViewList();
(settingTab.app.workspace as any).trigger(
"task-genius:view-config-changed",
{ reason: "view-added", viewId: createdView.id }
{
reason: "view-added",
viewId: createdView.id,
},
);
} else {
new Notice(t("Error: View ID already exists."));
}
}
},
).open();
});
});

View file

@ -175,9 +175,7 @@ export class ViewConfigModal extends Modal {
this.onSave = onSave;
}
private applyTwoColumnPresetIfNeeded(
sourceConfig?: ViewConfig,
): void {
private applyTwoColumnPresetIfNeeded(sourceConfig?: ViewConfig): void {
if (!this.isCopyMode) {
return;
}
@ -196,8 +194,8 @@ export class ViewConfigModal extends Modal {
if (hasTwoColumnConfig) {
// Already a two column view; ensure task property aligns with preset if missing
const currentConfig =
this.viewConfig.specificConfig as TwoColumnSpecificConfig;
const currentConfig = this.viewConfig
.specificConfig as TwoColumnSpecificConfig;
if (!currentConfig.taskPropertyKey) {
currentConfig.taskPropertyKey = preset.taskPropertyKey;
}
@ -216,10 +214,7 @@ export class ViewConfigModal extends Modal {
}
const identifier =
sourceIdentifier ??
sourceConfig?.id ??
sourceConfig?.name ??
null;
sourceIdentifier ?? sourceConfig?.id ?? sourceConfig?.name ?? null;
if (!identifier) {
return null;
@ -281,9 +276,7 @@ export class ViewConfigModal extends Modal {
case "project":
multiSelectText = t("projects selected");
if (normalized.includes("review")) {
emptyStateText = t(
"Select a project to review its tasks.",
);
emptyStateText = t("Select a project to review its tasks.");
leftColumnTitle = t("Review Projects");
} else {
emptyStateText = t("Select a project to see related tasks");
@ -333,7 +326,10 @@ export class ViewConfigModal extends Modal {
normalizedIdentifier: string,
sourceConfig?: ViewConfig,
): string {
if (propertyKey === "project" && normalizedIdentifier.includes("review")) {
if (
propertyKey === "project" &&
normalizedIdentifier.includes("review")
) {
return t("Review Projects");
}
@ -833,6 +829,40 @@ export class ViewConfigModal extends Modal {
});
});
// Hidden Columns Management
const kanbanConfigForHidden = this.viewConfig
.specificConfig as KanbanSpecificConfig;
const hiddenColumns = kanbanConfigForHidden?.hiddenColumns || [];
if (hiddenColumns.length > 0) {
new Setting(contentEl)
.setName(t("Hidden Columns"))
.setDesc(
t(
"Columns that are currently hidden from view. Click the eye icon to show them again.",
),
)
.setHeading();
hiddenColumns.forEach((colTitle, index) => {
new Setting(contentEl)
.setName(colTitle)
.addButton((button) => {
button
.setIcon("eye")
.setTooltip(t("Unhide column"))
.onClick(() => {
const config = this.viewConfig
.specificConfig as KanbanSpecificConfig;
if (config.hiddenColumns) {
config.hiddenColumns.splice(index, 1);
this.checkForChanges();
this.display(); // Refresh to update list
}
});
});
});
}
// Custom columns configuration for non-status grouping
const kanbanConfig = this.viewConfig
.specificConfig as KanbanSpecificConfig;

View file

@ -2583,6 +2583,13 @@ const translations = {
"kanban.allCycles": "All Cycles",
"kanban.otherColumn": "Other",
"kanban.noCyclesAvailable": "No cycles available",
// Kanban Column Visibility
"Hide this column": "Hide this column",
"Hidden Columns": "Hidden Columns",
"Columns that are currently hidden from view. Click the eye icon to show them again.":
"Columns that are currently hidden from view. Click the eye icon to show them again.",
"Unhide column": "Unhide column",
};
export default translations;

View file

@ -2374,6 +2374,13 @@ const translations = {
"kanban.allCycles": "所有周期",
"kanban.otherColumn": "其他",
"kanban.noCyclesAvailable": "无可用周期",
// Kanban Column Visibility
"Hide this column": "隐藏此列",
"Hidden Columns": "已隐藏的列",
"Columns that are currently hidden from view. Click the eye icon to show them again.":
"当前隐藏的列。点击眼睛图标可重新显示。",
"Unhide column": "显示列",
};
export default translations;

File diff suppressed because one or more lines are too long