Tighten relationship widgets and blocked cards

This commit is contained in:
callumalpass 2026-05-18 22:03:24 +10:00
parent 95175db358
commit 0e4758a448
11 changed files with 352 additions and 301 deletions

View file

@ -62,8 +62,6 @@ import {
shouldSkipMarkdownWidgetLeaf,
} from "./MarkdownWidgetContext";
import { insertAfterElement, insertAfterMetadataOrHeader } from "./MarkdownWidgetInsertion";
import type { RelationshipsDisplayMode } from "../types/settings";
import { getRelationshipsDisplayMode } from "../settings/relationshipSettings";
import { FilterUtils } from "../utils/FilterUtils";
import { collectCacheTags } from "../utils/tagExtraction";
import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils";
@ -79,63 +77,12 @@ interface HTMLElementWithComponent extends HTMLElement {
component?: Component;
}
export type RelationshipsWidgetState = {
isTaskNote: boolean;
isProjectNote: boolean;
hasSubtasks: boolean;
hasProjectLinks: boolean;
hasBlockingDependencies: boolean;
hasBlockedTasks: boolean;
};
export function hasRelationshipFieldValue(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some(hasRelationshipFieldValue);
}
if (typeof value === "string") {
return value.trim().length > 0;
}
if (value && typeof value === "object") {
return Object.keys(value).length > 0;
}
return value !== null && value !== undefined && value !== false;
}
export function shouldRenderRelationshipsWidget(
mode: RelationshipsDisplayMode,
state: RelationshipsWidgetState
): boolean {
if (mode === "never" || (!state.isTaskNote && !state.isProjectNote)) {
return false;
}
if (mode === "always") {
return true;
}
return (
state.hasSubtasks ||
state.hasProjectLinks ||
state.hasBlockingDependencies ||
state.hasBlockedTasks
);
}
function getHTMLElementChildren(element: HTMLElement): HTMLElement[] {
return Array.from(element.children).filter(
(child): child is HTMLElement => child.instanceOf(HTMLElement)
);
}
function getFrontmatterRecord(frontmatter: unknown): Record<string, unknown> {
return frontmatter && typeof frontmatter === "object"
? (frontmatter as Record<string, unknown>)
: {};
}
type ProjectMarkerMetadata = {
frontmatter?: Record<string, unknown> | null;
tags?: Array<{ tag?: string }>;
@ -174,30 +121,6 @@ function isProjectNoteForRelationships(
);
}
function getRelationshipsWidgetState(
plugin: TaskNotesPlugin,
file: TFile,
isTaskNote: boolean,
isProjectNote: boolean,
frontmatter: unknown
): RelationshipsWidgetState {
const frontmatterRecord = getFrontmatterRecord(frontmatter);
const projectsField = plugin.fieldMapper.toUserField("projects");
const blockedByField = plugin.fieldMapper.toUserField("blockedBy");
return {
isTaskNote,
isProjectNote,
hasSubtasks:
(plugin.dependencyCache?.getTasksReferencingProject(file.path).length ?? 0) > 0,
hasProjectLinks: hasRelationshipFieldValue(frontmatterRecord[projectsField]),
hasBlockingDependencies:
hasRelationshipFieldValue(frontmatterRecord[blockedByField]) ||
(plugin.dependencyCache?.getBlockingTaskPaths(file.path).length ?? 0) > 0,
hasBlockedTasks: (plugin.dependencyCache?.getTasksBlockedByTask(file.path).length ?? 0) > 0,
};
}
export function findRelationshipsBottomAnchor(container: HTMLElement): HTMLElement | null {
const cmContent = container.querySelector<HTMLElement>(".cm-content");
if (cmContent) {
@ -324,6 +247,7 @@ class RelationshipsDecorationsPlugin implements PluginValue {
private eventListeners: EventRef[] = [];
private dependencyCacheEventListeners: EventRef[] = [];
private injectionRunId = 0;
private bottomOffsetFrame: number | null = null;
constructor(
view: EditorView,
@ -348,6 +272,8 @@ class RelationshipsDecorationsPlugin implements PluginValue {
if (newFile !== this.currentFile) {
this.currentFile = newFile;
this.debouncedInjectWidget(update.view);
} else if (update.docChanged || update.geometryChanged || update.viewportChanged) {
this.scheduleBottomOffsetRefresh();
}
}
@ -359,6 +285,10 @@ class RelationshipsDecorationsPlugin implements PluginValue {
window.clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.bottomOffsetFrame !== null) {
window.cancelAnimationFrame(this.bottomOffsetFrame);
this.bottomOffsetFrame = null;
}
// Clean up the widget and its component
this.removeWidget();
@ -408,6 +338,27 @@ class RelationshipsDecorationsPlugin implements PluginValue {
}, 100);
}
private scheduleBottomOffsetRefresh(): void {
if (
!this.currentWidget ||
!this.widgetContainer ||
(this.plugin.settings.relationshipsPosition || "bottom") !== "bottom"
) {
return;
}
if (this.bottomOffsetFrame !== null) {
window.cancelAnimationFrame(this.bottomOffsetFrame);
}
this.bottomOffsetFrame = window.requestAnimationFrame(() => {
this.bottomOffsetFrame = null;
if (this.currentWidget?.isConnected && this.widgetContainer?.isConnected) {
applyRelationshipsBottomOffset(this.widgetContainer, this.currentWidget);
}
});
}
private getFileFromView(view: EditorView): unknown {
try {
// Get the file associated with this specific editor view
@ -479,6 +430,10 @@ class RelationshipsDecorationsPlugin implements PluginValue {
}
private removeWidget(): void {
if (this.bottomOffsetFrame !== null) {
window.cancelAnimationFrame(this.bottomOffsetFrame);
this.bottomOffsetFrame = null;
}
if (this.currentWidget) {
// Unload the component
this.currentWidget.component?.unload();
@ -527,8 +482,8 @@ class RelationshipsDecorationsPlugin implements PluginValue {
this.cleanupOrphanedWidgets(view);
try {
const displayMode = getRelationshipsDisplayMode(this.plugin.settings);
if (displayMode === "never") {
// Check if relationships widget is enabled
if (!this.plugin.settings.showRelationships) {
return;
}
@ -551,14 +506,9 @@ class RelationshipsDecorationsPlugin implements PluginValue {
isProjectNote = isProjectNoteForRelationships(this.plugin, file, metadata);
}
const widgetState = getRelationshipsWidgetState(
this.plugin,
file,
isTaskNote,
isProjectNote,
metadata?.frontmatter
);
if (!shouldRenderRelationshipsWidget(displayMode, widgetState)) {
// Only show widget if it's either a task note or a project note
if (!isTaskNote && !isProjectNote) {
// Not a task or project note - don't show relationships widget
return;
}
@ -657,8 +607,8 @@ async function injectReadingModeWidget(
return;
}
const displayMode = getRelationshipsDisplayMode(plugin.settings);
if (displayMode === "never") {
// Check if relationships widget is enabled
if (!plugin.settings.showRelationships) {
return;
}
@ -675,14 +625,7 @@ async function injectReadingModeWidget(
isProjectNote = isProjectNoteForRelationships(plugin, file, metadata);
}
const widgetState = getRelationshipsWidgetState(
plugin,
file,
isTaskNote,
isProjectNote,
metadata?.frontmatter
);
if (!shouldRenderRelationshipsWidget(displayMode, widgetState)) {
if (!isTaskNote && !isProjectNote) {
// Remove any existing widgets if conditions no longer met
try {
const previewView = view.previewMode;

View file

@ -1,17 +0,0 @@
import type { RelationshipsDisplayMode, TaskNotesSettings } from "../types/settings";
function isRelationshipsDisplayMode(value: unknown): value is RelationshipsDisplayMode {
return value === "always" || value === "whenPopulated" || value === "never";
}
export function getRelationshipsDisplayMode(
settings: Pick<TaskNotesSettings, "showRelationships" | "relationshipsDisplayMode">
): RelationshipsDisplayMode {
if (settings.showRelationships === false) {
return "never";
}
return isRelationshipsDisplayMode(settings.relationshipsDisplayMode)
? settings.relationshipsDisplayMode
: "always";
}

View file

@ -12,7 +12,6 @@ import { PropertySelectorModal } from "../../modals/PropertySelectorModal";
import { getAvailableProperties, getPropertyLabels } from "../../utils/propertyHelpers";
import type { CalendarViewSettings } from "../../types/settings";
import { CALENDAR_END_TIME_MAX_HOUR, normalizeCalendarTimeValue } from "../../utils/calendarTime";
import { getRelationshipsDisplayMode } from "../relationshipSettings";
type CalendarDefaultView = CalendarViewSettings["defaultView"];
type CalendarFirstDay = CalendarViewSettings["firstDay"];
@ -675,43 +674,21 @@ export function renderAppearanceTab(
);
group.addSetting((setting) =>
void configureDropdownSetting(setting, {
void configureToggleSetting(setting, {
name: translate("settings.appearance.uiElements.showRelationshipsWidget.name"),
desc: translate(
"settings.appearance.uiElements.showRelationshipsWidget.description"
),
options: [
{
value: "always",
label: translate(
"settings.appearance.uiElements.showRelationshipsWidget.options.always"
),
},
{
value: "whenPopulated",
label: translate(
"settings.appearance.uiElements.showRelationshipsWidget.options.whenPopulated"
),
},
{
value: "never",
label: translate(
"settings.appearance.uiElements.showRelationshipsWidget.options.never"
),
},
],
getValue: () => getRelationshipsDisplayMode(plugin.settings),
setValue: async (value: string) => {
plugin.settings.relationshipsDisplayMode =
value === "whenPopulated" || value === "never" ? value : "always";
plugin.settings.showRelationships = value !== "never";
getValue: () => plugin.settings.showRelationships,
setValue: async (value: boolean) => {
plugin.settings.showRelationships = value;
save();
renderAppearanceTab(container, plugin, save);
},
})
);
if (getRelationshipsDisplayMode(plugin.settings) !== "never") {
if (plugin.settings.showRelationships) {
group.addSetting((setting) =>
void configureDropdownSetting(setting, {
name: translate(

View file

@ -417,8 +417,7 @@ function createStatusCycleHandler(
updatedTask,
plugin,
isCompleted,
effectiveStatus,
targetDate
effectiveStatus
);
};
@ -472,8 +471,7 @@ function updateCardCompletionState(
task: TaskInfo,
plugin: TaskNotesPlugin,
isCompleted: boolean,
effectiveStatus: string,
targetDate: Date
effectiveStatus: string
): void {
card.classList.toggle("task-card--completed", isCompleted);
card.classList.toggle(
@ -483,7 +481,7 @@ function updateCardCompletionState(
card.classList.toggle("task-card--archived", !!task.archived);
card.classList.toggle(
"task-card--actively-tracked",
plugin.getActiveTimeSession(task, targetDate) !== null
plugin.getActiveTimeSession(task) !== null
);
card.classList.toggle("task-card--recurring", !!task.recurrence);
card.classList.toggle(
@ -726,16 +724,78 @@ function createBlockedByToggleClickHandler(
task: TaskInfo,
plugin: TaskNotesPlugin,
card: HTMLElement,
toggle: HTMLElement
_control: HTMLElement
): () => void {
return () => {
void (async () => {
const expanded = toggle.classList.toggle("task-card__blocked-toggle--expanded");
await toggleBlockedByTasks(card, task, plugin, expanded);
})();
void toggleBlockedByExpansion(card, task, plugin);
};
}
function syncBlockedByExpansionControls(card: HTMLElement, expanded: boolean): void {
const toggle = card.querySelector<HTMLElement>(".task-card__blocked-toggle");
if (toggle) {
toggle.classList.toggle("task-card__blocked-toggle--expanded", expanded);
toggle.setAttribute("aria-expanded", String(expanded));
}
for (const pill of card.querySelectorAll<HTMLElement>(".task-card__metadata-pill--blocked")) {
if (pill.getAttribute("role") === "button") {
pill.setAttribute("aria-expanded", String(expanded));
}
}
}
async function toggleBlockedByExpansion(
card: HTMLElement,
task: TaskInfo,
plugin: TaskNotesPlugin
): Promise<void> {
const expanded = !card.querySelector(".task-card__blocked-by");
syncBlockedByExpansionControls(card, expanded);
await toggleBlockedByTasks(card, task, plugin, expanded);
}
function createBlockedMetadataPill(
metadataLine: HTMLElement,
card: HTMLElement,
task: TaskInfo,
plugin: TaskNotesPlugin
): HTMLElement | null {
if (!task.isBlocked) {
return null;
}
const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge");
const blockedByPaths = getBlockedByTaskPaths(task, plugin);
const blockedCount = task.blockedBy?.length ?? 0;
const pillText = blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel;
const blockedPill = metadataLine.createSpan({
cls: "task-card__metadata-pill task-card__metadata-pill--blocked",
text: pillText,
});
if (blockedByPaths.length > 0) {
const toggleLabel = `${blockedLabel} (${blockedByPaths.length})`;
prepareInteractiveControl(blockedPill);
blockedPill.setAttribute("aria-label", toggleLabel);
blockedPill.setAttribute(
"aria-expanded",
String(Boolean(card.querySelector(".task-card__blocked-by")))
);
setTooltip(blockedPill, toggleLabel, { placement: "top" });
blockedPill.addEventListener("click", (event) => {
event.stopPropagation();
void toggleBlockedByExpansion(card, task, plugin);
});
} else {
setTooltip(blockedPill, plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"), {
placement: "top",
});
}
return blockedPill;
}
/**
* Create a minimalist, unified task card element
*
@ -789,7 +849,7 @@ export function createTaskCard(
taskCardElement._taskPath = task.path;
taskCardElement._taskCardOptions = opts;
const isActivelyTracked = plugin.getActiveTimeSession(task, targetDate) !== null;
const isActivelyTracked = plugin.getActiveTimeSession(task) !== null;
const isCompleted = task.recurrence
? task.complete_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of complete_instances
: plugin.statusManager.isCompletedStatus(effectiveStatus); // Regular tasks use status config
@ -1094,18 +1154,8 @@ export function createTaskCard(
}
if (propertyId === "blocked") {
if (task.isBlocked) {
const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge");
const blockedCount = task.blockedBy?.length ?? 0;
const pillText =
blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel;
const blockedPill = metadataLine.createSpan({
cls: "task-card__metadata-pill task-card__metadata-pill--blocked",
text: pillText,
});
setTooltip(blockedPill, plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"), {
placement: "top",
});
const blockedPill = createBlockedMetadataPill(metadataLine, card, task, plugin);
if (blockedPill) {
metadataElements.push(blockedPill);
}
continue;
@ -1280,7 +1330,7 @@ export function updateTaskCard(
: task.status;
// Update main element classes using BEM structure
const isActivelyTracked = plugin.getActiveTimeSession(task, targetDate) !== null;
const isActivelyTracked = plugin.getActiveTimeSession(task) !== null;
const isCompleted = task.recurrence
? task.complete_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of complete_instances
: plugin.statusManager.isCompletedStatus(effectiveStatus); // Regular tasks use status config
@ -1650,20 +1700,8 @@ export function updateTaskCard(
}
if (propertyId === "blocked") {
if (task.isBlocked) {
const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge");
const blockedCount = task.blockedBy?.length ?? 0;
const pillText =
blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel;
const blockedPill = metadataLine.createSpan({
cls: "task-card__metadata-pill task-card__metadata-pill--blocked",
text: pillText,
});
setTooltip(
blockedPill,
plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"),
{ placement: "top" }
);
const blockedPill = createBlockedMetadataPill(metadataLine, element, task, plugin);
if (blockedPill) {
metadataElements.push(blockedPill);
}
continue;
@ -1689,9 +1727,15 @@ export function updateTaskCard(
continue;
}
const element = renderPropertyMetadata(metadataLine, propertyId, task, plugin, opts);
if (element) {
metadataElements.push(element);
const propertyElement = renderPropertyMetadata(
metadataLine,
propertyId,
task,
plugin,
opts
);
if (propertyElement) {
metadataElements.push(propertyElement);
}
}

View file

@ -30,6 +30,7 @@ export class DependencyCache extends Events {
// Project references index
private projectReferences: Map<string, Set<string>> = new Map(); // project path -> Set<task paths that reference it>
private projectReferenceSources: Map<string, Set<string>> = new Map(); // task path -> Set<project paths it references>
// Initialization state
private initialized = false;
@ -127,22 +128,24 @@ export class DependencyCache extends Events {
* Handle file changes
*/
private handleFileChanged(file: TFile, cache: unknown): void {
const before = this.getFileRelationshipSignature(file.path);
if (!this.isValidFile(file.path)) {
this.clearFileFromIndexes(file.path);
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
const metadata = this.app.metadataCache.getFileCache(file);
if (!metadata?.frontmatter) {
this.clearFileFromIndexes(file.path);
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
this.clearForwardDependencies(file.path);
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
if (!this.isTaskFileCallback(metadata.frontmatter)) {
this.clearFileFromIndexes(file.path);
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
this.clearForwardDependencies(file.path);
this.triggerIfFileRelationshipsChanged(file.path, before);
return;
}
@ -151,7 +154,31 @@ export class DependencyCache extends Events {
// Keep reverse dependencies intact - they'll be updated when other tasks change
this.clearForwardDependencies(file.path);
this.indexTaskFile(file.path, metadata.frontmatter);
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
this.triggerIfFileRelationshipsChanged(file.path, before);
}
private triggerIfFileRelationshipsChanged(path: string, before: string): void {
if (this.getFileRelationshipSignature(path) !== before) {
this.trigger(EVENT_DEPENDENCY_CACHE_CHANGED);
}
}
private getFileRelationshipSignature(path: string): string {
const blockingTasks = this.sortedSetValues(this.dependencySources.get(path));
const blockedTasks = this.sortedSetValues(this.dependencyTargets.get(path));
const referencedProjects = this.sortedSetValues(this.projectReferenceSources.get(path));
const projectTasks = this.sortedSetValues(this.projectReferences.get(path));
return JSON.stringify({
blockedTasks,
blockingTasks,
projectTasks,
referencedProjects,
});
}
private sortedSetValues(values: Set<string> | undefined): string[] {
return values ? Array.from(values).sort() : [];
}
/**
@ -252,6 +279,11 @@ export class DependencyCache extends Events {
this.projectReferences.set(resolvedPath, new Set());
}
this.projectReferences.get(resolvedPath)!.add(path);
if (!this.projectReferenceSources.has(path)) {
this.projectReferenceSources.set(path, new Set());
}
this.projectReferenceSources.get(path)!.add(resolvedPath);
}
}
}
@ -281,11 +313,18 @@ export class DependencyCache extends Events {
}
// Also clear project references since those are stored in this task's frontmatter
for (const [project, taskSet] of this.projectReferences.entries()) {
taskSet.delete(path);
if (taskSet.size === 0) {
this.projectReferences.delete(project);
const referencedProjects = this.projectReferenceSources.get(path);
if (referencedProjects) {
for (const project of referencedProjects) {
const taskSet = this.projectReferences.get(project);
if (taskSet) {
taskSet.delete(path);
if (taskSet.size === 0) {
this.projectReferences.delete(project);
}
}
}
this.projectReferenceSources.delete(path);
}
}
@ -326,12 +365,34 @@ export class DependencyCache extends Events {
this.dependencyTargets.delete(path);
}
// Clear from project references
for (const [project, taskSet] of this.projectReferences.entries()) {
taskSet.delete(path);
if (taskSet.size === 0) {
this.projectReferences.delete(project);
// Clear project references declared by this file
const referencedProjects = this.projectReferenceSources.get(path);
if (referencedProjects) {
for (const project of referencedProjects) {
const taskSet = this.projectReferences.get(project);
if (taskSet) {
taskSet.delete(path);
if (taskSet.size === 0) {
this.projectReferences.delete(project);
}
}
}
this.projectReferenceSources.delete(path);
}
// Clear this file as a project target
const referencingTasks = this.projectReferences.get(path);
if (referencingTasks) {
for (const taskPath of referencingTasks) {
const taskProjects = this.projectReferenceSources.get(taskPath);
if (taskProjects) {
taskProjects.delete(path);
if (taskProjects.size === 0) {
this.projectReferenceSources.delete(taskPath);
}
}
}
this.projectReferences.delete(path);
}
}
@ -352,20 +413,13 @@ export class DependencyCache extends Events {
* Get blocked task paths (tasks that depend on this task)
*/
getBlockedTaskPaths(taskPath: string): string[] {
if (this.isCompletedTask(taskPath)) {
return [];
if (!this.indexesBuilt) {
console.warn("DependencyCache: getBlockedTaskPaths called before indexes built, building now...");
this.buildIndexesSync();
}
return this.getTasksBlockedByTask(taskPath);
}
/**
* Get task paths that declare this task as a dependency, regardless of status.
*/
getTasksBlockedByTask(taskPath: string): string[] {
if (!this.indexesBuilt) {
console.warn("DependencyCache: getTasksBlockedByTask called before indexes built, building now...");
this.buildIndexesSync();
if (this.isCompletedTask(taskPath)) {
return [];
}
const blocked = this.dependencyTargets.get(taskPath);
@ -489,6 +543,7 @@ export class DependencyCache extends Events {
this.dependencySources.clear();
this.dependencyTargets.clear();
this.projectReferences.clear();
this.projectReferenceSources.clear();
}
/**

View file

@ -1158,7 +1158,7 @@ body.is-mobile .tasknotes-plugin .task-card__context-menu svg {
}
}
/* Compact mode for smaller screens - Todoist style */
/* Compact spacing for smaller screens */
@media (max-width: 768px) {
.tasknotes-plugin .task-card {
padding: 10px 14px;
@ -1167,14 +1167,6 @@ body.is-mobile .tasknotes-plugin .task-card__context-menu svg {
.tasknotes-plugin .task-card__content {
gap: 1px;
}
.tasknotes-plugin .task-card__title {
font-size: 13px;
}
.tasknotes-plugin .task-card__metadata {
font-size: 11px;
}
}
/* =================================================================

View file

@ -1,63 +0,0 @@
import {
hasRelationshipFieldValue,
shouldRenderRelationshipsWidget,
type RelationshipsWidgetState,
} from "../../../src/editor/RelationshipsDecorations";
import { getRelationshipsDisplayMode } from "../../../src/settings/relationshipSettings";
const emptyTaskState: RelationshipsWidgetState = {
isTaskNote: true,
isProjectNote: false,
hasSubtasks: false,
hasProjectLinks: false,
hasBlockingDependencies: false,
hasBlockedTasks: false,
};
describe("Issue #1216: relationships widget display mode", () => {
it("keeps legacy disabled settings mapped to Never", () => {
expect(
getRelationshipsDisplayMode({
showRelationships: false,
relationshipsDisplayMode: "always",
})
).toBe("never");
});
it("shows empty task widgets in Always mode for backwards compatibility", () => {
expect(shouldRenderRelationshipsWidget("always", emptyTaskState)).toBe(true);
});
it("hides empty task widgets in When populated mode", () => {
expect(shouldRenderRelationshipsWidget("whenPopulated", emptyTaskState)).toBe(false);
});
it("shows populated widgets when any relationship bucket has content", () => {
const populatedStates: RelationshipsWidgetState[] = [
{ ...emptyTaskState, hasSubtasks: true },
{ ...emptyTaskState, hasProjectLinks: true },
{ ...emptyTaskState, hasBlockingDependencies: true },
{ ...emptyTaskState, hasBlockedTasks: true },
];
for (const state of populatedStates) {
expect(shouldRenderRelationshipsWidget("whenPopulated", state)).toBe(true);
}
});
it("does not render on unrelated notes even in Always mode", () => {
expect(
shouldRenderRelationshipsWidget("always", {
...emptyTaskState,
isTaskNote: false,
isProjectNote: false,
})
).toBe(false);
});
it("treats non-empty relationship arrays and dependency objects as populated", () => {
expect(hasRelationshipFieldValue(["[[Projects/Home]]"])).toBe(true);
expect(hasRelationshipFieldValue([{ uid: "[[Tasks/Blocking task]]" }])).toBe(true);
expect(hasRelationshipFieldValue(["", null, undefined])).toBe(false);
});
});

View file

@ -97,4 +97,64 @@ describe("Issue #1227: relationships widget project detection refresh", () => {
expect(dependencyCache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true);
expect(changeHandler).toHaveBeenCalledTimes(1);
});
it("does not emit a dependency-cache change for task body-only metadata changes", async () => {
const app = createMockApp();
const projectFile = createFile(app, "Projects/Alpha.md");
const taskFile = createFile(app, "Tasks/Task.md", {
title: "Task",
tags: ["task"],
projects: ["[[Alpha]]"],
});
app.metadataCache.getFirstLinkpathDest = jest.fn((linkpath: string) =>
linkpath === "Alpha" ? projectFile : null
);
const dependencyCache = createDependencyCache(app);
const changeHandler = jest.fn();
dependencyCache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
await dependencyCache.buildIndexes();
changeHandler.mockClear();
dependencyCache.initialize();
app.__metadataChangedHandlers[0](taskFile, null, {
frontmatter: {
title: "Task",
tags: ["task"],
projects: ["[[Alpha]]"],
},
});
expect(dependencyCache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true);
expect(changeHandler).not.toHaveBeenCalled();
});
it("preserves non-task project targets when the project note metadata changes", async () => {
const app = createMockApp();
const projectFile = createFile(app, "Projects/Alpha.md");
createFile(app, "Tasks/Task.md", {
title: "Task",
tags: ["task"],
projects: ["[[Alpha]]"],
});
app.metadataCache.getFirstLinkpathDest = jest.fn((linkpath: string) =>
linkpath === "Alpha" ? projectFile : null
);
const dependencyCache = createDependencyCache(app);
const changeHandler = jest.fn();
dependencyCache.on(EVENT_DEPENDENCY_CACHE_CHANGED, changeHandler);
await dependencyCache.buildIndexes();
changeHandler.mockClear();
dependencyCache.initialize();
app.__metadataChangedHandlers[0](projectFile, null, {});
expect(dependencyCache.isFileUsedAsProject("Projects/Alpha.md")).toBe(true);
expect(changeHandler).not.toHaveBeenCalled();
});
});

View file

@ -6,6 +6,7 @@
*/
import {
applyRelationshipsBottomOffset,
findRelationshipsBottomAnchor,
insertRelationshipsWidgetAtBottom,
} from "../../../src/editor/RelationshipsDecorations";
@ -62,6 +63,41 @@ describe("Issue #1329: relationships widget bottom placement", () => {
);
});
it("recomputes the live preview offset when editor content grows", () => {
const sizer = el("cm-sizer");
const contentContainer = el("cm-contentContainer");
const cmContent = el("cm-content cm-lineWrapping");
const lastLine = el("cm-line");
const widget = el("tasknotes-relationships-widget");
let lastLineBottom = 100;
cmContent.append(lastLine);
contentContainer.append(cmContent);
sizer.append(contentContainer, widget);
Object.defineProperty(contentContainer, "getBoundingClientRect", {
value: () => ({ bottom: 224 }),
});
Object.defineProperty(lastLine, "getBoundingClientRect", {
value: () => ({ bottom: lastLineBottom }),
});
applyRelationshipsBottomOffset(sizer, widget);
expect(widget.style.getPropertyValue("--tn-relationships-widget-margin-top")).toBe(
"-124px"
);
lastLineBottom = 210;
applyRelationshipsBottomOffset(sizer, widget);
expect(widget.style.getPropertyValue("--tn-relationships-widget-margin-top")).toBe(
"-14px"
);
lastLineBottom = 224;
applyRelationshipsBottomOffset(sizer, widget);
expect(widget.style.getPropertyValue("--tn-relationships-widget-margin-top")).toBe("");
});
it("anchors reading mode widgets after the last content section", () => {
const sizer = el("markdown-preview-sizer");
const firstSection = el("markdown-preview-section");

View file

@ -24,11 +24,7 @@
*/
import { describe, it, expect } from '@jest/globals';
import {
isProjectNoteByAutosuggestMarkers,
shouldRenderRelationshipsWidget,
type RelationshipsWidgetState,
} from '../../../src/editor/RelationshipsDecorations';
import { isProjectNoteByAutosuggestMarkers } from '../../../src/editor/RelationshipsDecorations';
import type TaskNotesPlugin from '../../../src/main';
import type { ProjectAutosuggestSettings } from '../../../src/types/settings';
@ -59,15 +55,6 @@ function createPluginWithProjectAutosuggest(
}
describe('Issue #763: Relationships widget for empty tagged/property projects', () => {
const emptyProjectState: RelationshipsWidgetState = {
isTaskNote: false,
isProjectNote: true,
hasSubtasks: false,
hasProjectLinks: false,
hasBlockingDependencies: false,
hasBlockedTasks: false,
};
it('treats notes matching project autosuggest required tags as project notes', () => {
const plugin = createPluginWithProjectAutosuggest({
requiredTags: ['project'],
@ -82,7 +69,6 @@ describe('Issue #763: Relationships widget for empty tagged/property projects',
};
expect(isProjectNoteByAutosuggestMarkers(plugin, metadata)).toBe(true);
expect(shouldRenderRelationshipsWidget('always', emptyProjectState)).toBe(true);
});
it('treats notes matching the configured project property as project notes', () => {
@ -99,7 +85,6 @@ describe('Issue #763: Relationships widget for empty tagged/property projects',
};
expect(isProjectNoteByAutosuggestMarkers(plugin, metadata)).toBe(true);
expect(shouldRenderRelationshipsWidget('always', emptyProjectState)).toBe(true);
});
it('does not turn folder-only autosuggest filters into empty project widgets', () => {

View file

@ -1,8 +1,9 @@
/**
* Issue #922: dependency display behavior on task cards.
*
* Blocked tasks should show their visible blocked status, and TaskDependency
* entries in the blockedBy property should render as clickable task links.
* Blocked tasks should show their visible blocked status, let that blocked
* indicator expand the blocker cards, and render TaskDependency entries in the
* blockedBy property as clickable task links.
*
* @see https://github.com/callumalpass/tasknotes/issues/922
*/
@ -12,7 +13,13 @@ import type TaskNotesPlugin from "../../../src/main";
import { createTaskCard } from "../../../src/ui/TaskCard";
import { PluginFactory, TaskFactory } from "../../helpers/mock-factories";
function createPlugin(): TaskNotesPlugin {
function waitForAsyncRender(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 20));
}
function createPlugin(
blocker = TaskFactory.createTask({ path: "Tasks/blocker.md" })
): TaskNotesPlugin {
const plugin = PluginFactory.createMockPlugin({
priorityManager: {
getPriorityConfig: jest.fn().mockReturnValue({ color: "#666666", label: "Normal" }),
@ -36,6 +43,9 @@ function createPlugin(): TaskNotesPlugin {
.mockImplementation((path: string) =>
path === "Tasks/blocker.md" ? new TFile("Tasks/blocker.md") : null
);
plugin.cacheManager.getTaskInfo = jest.fn(async (path: string) =>
path === blocker.path ? blocker : null
);
plugin.i18n.translate = jest.fn((key: string, vars?: Record<string, string | number>) => {
const translations: Record<string, string> = {
"ui.taskCard.blockedBadge": "Blocked",
@ -74,6 +84,35 @@ describe("Issue #922: dependency display behavior", () => {
expect(blockedPill?.textContent).toBe("Blocked (1)");
});
it("lets the blocked visible property expand blocker task cards", async () => {
const blocker = TaskFactory.createTask({
path: "Tasks/blocker.md",
title: "Blocking prerequisite",
});
const plugin = createPlugin(blocker);
const task = TaskFactory.createTask({
path: "Tasks/blocked.md",
blockedBy: [{ uid: "[[Tasks/blocker.md]]", reltype: "FINISHTOSTART" }],
isBlocked: true,
});
const card = createTaskCard(task, plugin, ["blocked"], { showSecondaryBadges: false });
document.body.appendChild(card);
expect(card.querySelector(".task-card__blocked-toggle")).toBeNull();
card.querySelector<HTMLElement>(".task-card__metadata-pill--blocked")?.click();
await waitForAsyncRender();
const renderedBlockers = Array.from(
card.querySelectorAll<HTMLElement>(".task-card__blocked-by > .task-card")
);
expect(renderedBlockers.map((blockerCard) => blockerCard.dataset.taskPath)).toEqual([
blocker.path,
]);
});
it("renders blockedBy TaskDependency entries as clickable task links", () => {
const plugin = createPlugin();
const task = TaskFactory.createTask({