mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
Tighten relationship widgets and blocked cards
This commit is contained in:
parent
95175db358
commit
0e4758a448
11 changed files with 352 additions and 301 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue