diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index 4dfdc57..2579a76 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -251,36 +251,44 @@ export class MockVault { export class MockComponent { private children: Component[] = []; private loaded = false; - private events: EventRef[] = []; private callbacks: VoidFunction[] = []; + constructor() {} + load() { + if (this.loaded) { + return; + } + this.loaded = true; this.onload(); - this.children.map((c) => c.load()); + this.children.forEach((c) => c.load()); } unload() { - this.children.forEach((c) => c.unload()); - this.onunload(); - - for (const eventRef of this.events) { - const ref = eventRef as MockEventRef; - ref.callback(); + if (!this.loaded) { + return; } + this.loaded = false; + + this.children.forEach((c) => c.unload()); + for (const callback of this.callbacks) { callback(); } - this.loaded = false; + this.onunload(); } onload() {} onunload() {} registerEvent(eventRef: EventRef) { - this.events.push(eventRef); + this.callbacks.push(() => { + const ref = eventRef as MockEventRef; + ref.callback(); + }); } register(callback: VoidFunction) { diff --git a/src/components/timesheetApp.ts b/src/components/timesheetApp.ts index 1a17097..2fdb0f7 100644 --- a/src/components/timesheetApp.ts +++ b/src/components/timesheetApp.ts @@ -9,7 +9,8 @@ import { Timekeep } from "@/timekeep/schema"; import { DomComponent } from "./domComponent"; import { TimesheetCounters } from "./timesheetCounters"; import { TimesheetExportActions } from "./timesheetExportActions"; -import { TimesheetStart } from "./timesheetStart"; +import { TimesheetStartContainer } from "./timesheetStartContainer"; +import { TimesheetStartForm } from "./timesheetStartForm"; import { TimesheetTable } from "./timesheetTable"; /** @@ -53,15 +54,19 @@ export class TimesheetApp extends DomComponent { this.wrapperEl = wrapperEl; + console.log(this); + const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep); - const start = new TimesheetStart( + const startForm = new TimesheetStartForm( wrapperEl, this.timekeep, this.settings, this.autocomplete ); + const startContainer = new TimesheetStartContainer(wrapperEl, this.timekeep, this.settings); + const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings); const exportActions = new TimesheetExportActions( @@ -73,7 +78,8 @@ export class TimesheetApp extends DomComponent { ); this.addChild(counters); - this.addChild(start); + this.addChild(startForm); + this.addChild(startContainer); this.addChild(table); this.addChild(exportActions); } diff --git a/src/components/timesheetStart.test.ts b/src/components/timesheetStart.test.ts deleted file mode 100644 index f0fc2db..0000000 --- a/src/components/timesheetStart.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// @vitest-environment happy-dom - -import { beforeEach, it, describe } from "vitest"; - -import type { TimekeepSettings } from "@/settings"; -import type { Store } from "@/store"; -import type { Timekeep } from "@/timekeep/schema"; - -import { createMockContainer, MockVault } from "@/__mocks__/obsidian"; -import { TimekeepAutocomplete } from "@/service/autocomplete"; -import { TimekeepRegistry } from "@/service/registry"; -import { defaultSettings } from "@/settings"; -import { createStore } from "@/store"; - -import { TimesheetStart } from "./timesheetStart"; - -describe("TimesheetStart", () => { - let containerEl: HTMLElement; - let vault: MockVault; - - let timekeep: Store; - let settings: Store; - let registry: TimekeepRegistry; - let autocomplete: TimekeepAutocomplete; - - beforeEach(() => { - vault = new MockVault(); - containerEl = createMockContainer(); - timekeep = createStore({ entries: [] }); - settings = createStore(defaultSettings); - - registry = new TimekeepRegistry(vault.asVault(), settings); - autocomplete = new TimekeepAutocomplete(registry, settings); - }); - - it("should load without error", () => { - const component = new TimesheetStart(containerEl, timekeep, settings, autocomplete); - component.load(); - }); -}); diff --git a/src/components/timesheetStartContainer.test.ts b/src/components/timesheetStartContainer.test.ts new file mode 100644 index 0000000..0e4c4d0 --- /dev/null +++ b/src/components/timesheetStartContainer.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom + +import moment from "moment"; +import { v4 } from "uuid"; +import { beforeEach, it, describe, expect, afterEach } from "vitest"; + +import type { TimekeepSettings } from "@/settings"; +import type { Store } from "@/store"; +import type { Timekeep } from "@/timekeep/schema"; + +import { createMockContainer } from "@/__mocks__/obsidian"; +import { defaultSettings } from "@/settings"; +import { createStore } from "@/store"; + +import { TimesheetStartContainer } from "./timesheetStartContainer"; +import { TimesheetStartEditing } from "./timesheetStartEditing"; +import { TimesheetStartRunning } from "./timesheetStartRunning"; + +describe("TimesheetStartContainer", () => { + let containerEl: HTMLElement; + let timekeep: Store; + let settings: Store; + let component: TimesheetStartContainer; + + beforeEach(() => { + containerEl = createMockContainer(); + timekeep = createStore({ entries: [] }); + settings = createStore(defaultSettings); + }); + + afterEach(() => { + if (component) component.unload(); + }); + + it("should load without error", () => { + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + }); + + it("should be undefined content without a running entry", () => { + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + expect(component.getContent()).toBeUndefined(); + }); + + it("should be TimesheetStartRunning when theres a running entry", () => { + const start = moment(); + timekeep.setState({ + entries: [ + { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + + expect(component.getContent()).toBeInstanceOf(TimesheetStartRunning); + }); + + it("should be TimesheetStartEditing when theres a running entry and edit is pressed", () => { + const start = moment(); + timekeep.setState({ + entries: [ + { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + + const content = component.getContent() as TimesheetStartRunning; + content.onStartEditing(); + + expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing); + }); + + it("timekeep updates when editing should update the running entry", () => { + const start = moment(); + timekeep.setState({ + entries: [ + { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + + const content = component.getContent() as TimesheetStartRunning; + content.onStartEditing(); + + expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing); + + timekeep.setState(timekeep.getState()); + + expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing); + }); + + it("after a timekeep update when editing if theres no more current entry should return to undefined state", () => { + const start = moment(); + timekeep.setState({ + entries: [ + { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + + component = new TimesheetStartContainer(containerEl, timekeep, settings); + component.load(); + + const content = component.getContent() as TimesheetStartRunning; + content.onStartEditing(); + + expect(component.getContent()).toBeInstanceOf(TimesheetStartEditing); + + timekeep.setState({ entries: [] }); + + expect(component.getContent()).toBeUndefined(); + }); +}); diff --git a/src/components/timesheetStartContainer.ts b/src/components/timesheetStartContainer.ts new file mode 100644 index 0000000..20340bf --- /dev/null +++ b/src/components/timesheetStartContainer.ts @@ -0,0 +1,97 @@ +import type { TimekeepSettings } from "@/settings"; +import type { Store } from "@/store"; +import type { Timekeep } from "@/timekeep/schema"; + +import { getRunningEntry } from "@/timekeep/queries"; +import { assert } from "@/utils/assert"; + +import { ContentComponent } from "./contentComponent"; +import { TimesheetStartEditing } from "./timesheetStartEditing"; +import { TimesheetStartRunning } from "./timesheetStartRunning"; + +export class TimesheetStartContainer extends ContentComponent< + TimesheetStartRunning | TimesheetStartEditing +> { + /** Access to the timekeep */ + timekeep: Store; + /** Access to the timekeep settings */ + settings: Store; + + constructor( + containerEl: HTMLElement, + timekeep: Store, + settings: Store + ) { + super(containerEl); + + this.timekeep = timekeep; + this.settings = settings; + } + + onload(): void { + super.onload(); + + const onUpdate = this.onUpdate.bind(this); + this.register(this.timekeep.subscribe(onUpdate)); + onUpdate(); + } + + onUpdate() { + if (this.getContent() instanceof TimesheetStartEditing) { + this.setEditingView(); + return; + } + + this.setCurrentView(); + } + + /** + * Switch to the editing view + */ + setEditingView() { + const contentEl = this.containerEl; + assert(contentEl, "Content element should be defined"); + + const timekeep = this.timekeep.getState(); + const currentEntry = getRunningEntry(timekeep.entries); + if (!currentEntry) { + this.setContent(undefined); + return; + } + + this.setContent( + new TimesheetStartEditing( + contentEl, + this.timekeep, + this.settings, + currentEntry.name, + this.setCurrentView.bind(this) + ) + ); + } + + /** + * Switch to the default creation view + */ + setCurrentView() { + const contentEl = this.containerEl; + assert(contentEl, "Content element should be defined"); + + const timekeep = this.timekeep.getState(); + const currentEntry = getRunningEntry(timekeep.entries); + if (!currentEntry) { + this.setContent(undefined); + return; + } + + this.setContent( + new TimesheetStartRunning( + contentEl, + this.timekeep, + this.settings, + currentEntry, + this.setEditingView.bind(this) + ) + ); + } +} diff --git a/src/components/timesheetStartEditing.test.ts b/src/components/timesheetStartEditing.test.ts index 39b2078..7ccfe7d 100644 --- a/src/components/timesheetStartEditing.test.ts +++ b/src/components/timesheetStartEditing.test.ts @@ -1,6 +1,8 @@ // @vitest-environment happy-dom -import { beforeEach, it, describe, vi } from "vitest"; +import moment from "moment"; +import { v4 } from "uuid"; +import { beforeEach, it, describe, vi, expect } from "vitest"; import type { TimekeepSettings } from "@/settings"; import type { Store } from "@/store"; @@ -35,4 +37,69 @@ describe("TimesheetStartEditing", () => { ); component.load(); }); + + it("should save nothing if the running entry doesn't exist", () => { + const component = new TimesheetStartEditing( + containerEl, + timekeep, + settings, + "Test Entry", + onFinishedEditing + ); + + const onSave = vi.spyOn(component, "onSave"); + + component.load(); + + const formEl = component.wrapperEl!; + (formEl as HTMLFormElement).dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: false }) + ); + expect(onSave).toHaveBeenCalled(); + }); + + it("should be able to save the running entry", () => { + const start = moment(); + const end = moment().add(1, "hour"); + vi.setSystemTime(end.toDate()); + + const entry = { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }; + + timekeep.setState({ + entries: [entry], + }); + + const component = new TimesheetStartEditing( + containerEl, + timekeep, + settings, + "Test Entry", + onFinishedEditing + ); + + const onSave = vi.spyOn(component, "onSave"); + + component.load(); + + const formEl = component.wrapperEl!; + + const nameInputEl = formEl.querySelector(".timekeep-name"); + expect(nameInputEl).not.toBeNull(); + (nameInputEl as HTMLInputElement).value = "New Name"; + + (formEl as HTMLFormElement).dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: false }) + ); + expect(onSave).toHaveBeenCalled(); + + expect(timekeep.getState()).toEqual({ + entries: [{ ...entry, name: "New Name" }], + }); + }); }); diff --git a/src/components/timesheetStartForm.test.ts b/src/components/timesheetStartForm.test.ts new file mode 100644 index 0000000..abce5fd --- /dev/null +++ b/src/components/timesheetStartForm.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import moment from "moment"; +import { beforeEach, it, describe, afterEach, vi, expect } from "vitest"; + +import type { TimekeepSettings } from "@/settings"; +import type { Store } from "@/store"; +import type { Timekeep } from "@/timekeep/schema"; + +import { createMockContainer, MockVault } from "@/__mocks__/obsidian"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; +import { TimekeepRegistry } from "@/service/registry"; +import { defaultSettings } from "@/settings"; +import { createStore } from "@/store"; + +import { TimesheetStartForm } from "./timesheetStartForm"; + +vi.mock(import("uuid"), async (importOriginal) => { + return { + ...(await importOriginal()), + v4: vi.fn(() => "mocked-uuid"), + }; +}); + +describe("TimesheetStart", () => { + let containerEl: HTMLElement; + let vault: MockVault; + + let timekeep: Store; + let settings: Store; + let registry: TimekeepRegistry; + let autocomplete: TimekeepAutocomplete; + let component: TimesheetStartForm; + + beforeEach(() => { + vault = new MockVault(); + containerEl = createMockContainer(); + timekeep = createStore({ entries: [] }); + settings = createStore(defaultSettings); + + registry = new TimekeepRegistry(vault.asVault(), settings); + autocomplete = new TimekeepAutocomplete(registry, settings); + }); + + afterEach(() => { + if (component) component.unload(); + }); + + it("should load without error", () => { + component = new TimesheetStartForm(containerEl, timekeep, settings, autocomplete); + component.load(); + }); + + it("clicking start should start a new entry with the name", () => { + const start = moment(); + + vi.setSystemTime(start.toDate()); + + component = new TimesheetStartForm(containerEl, timekeep, settings, autocomplete); + + const onStart = vi.spyOn(component, "onStart"); + component.load(); + + const formEl = component.wrapperEl as HTMLFormElement; + const nameInputEl = formEl.querySelector("#timekeepBlockName"); + (nameInputEl as HTMLInputElement).value = "Test"; + + formEl.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); + expect(onStart).toHaveBeenCalled(); + + expect(timekeep.getState()).toEqual({ + entries: [ + { + id: "mocked-uuid", + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + }); +}); diff --git a/src/components/timesheetStart.ts b/src/components/timesheetStartForm.ts similarity index 63% rename from src/components/timesheetStart.ts rename to src/components/timesheetStartForm.ts index 018954a..bec45ca 100644 --- a/src/components/timesheetStart.ts +++ b/src/components/timesheetStartForm.ts @@ -9,18 +9,14 @@ import { getRunningEntry } from "@/timekeep/queries"; import { startNewEntry } from "@/timekeep/start"; import { assert } from "@/utils/assert"; -import { ContentComponent } from "./contentComponent"; +import { DomComponent } from "./domComponent"; import { createObsidianIcon } from "./obsidianIcon"; import { TimesheetNameInput } from "./timesheetNameInput"; -import { TimesheetStartEditing } from "./timesheetStartEditing"; -import { TimesheetStartRunning } from "./timesheetStartRunning"; /** * The start section above the timesheet table */ -export class TimesheetStart extends ContentComponent< - TimesheetStartRunning | TimesheetStartEditing -> { +export class TimesheetStartForm extends DomComponent { /** Access to the timekeep */ timekeep: Store; /** Access to the timekeep settings */ @@ -28,9 +24,6 @@ export class TimesheetStart extends ContentComponent< /** Access to autocomplete */ autocomplete: TimekeepAutocomplete; - /** Content container element */ - #contentEl: HTMLElement | undefined; - /** Name input for starting entries */ #nameInput: TimesheetNameInput | undefined; @@ -54,16 +47,10 @@ export class TimesheetStart extends ContentComponent< onload(): void { super.onload(); - const wrapperEl = this.containerEl.createDiv(); - this.wrapperEl = wrapperEl; - - const contentEl = wrapperEl.createDiv(); - this.#contentEl = contentEl; - - this.setCurrentView(); - - const formEl = wrapperEl.createEl("form", { cls: "timekeep-start-area" }); + const formEl = this.containerEl.createEl("form", { cls: "timekeep-start-area" }); formEl.setAttribute("data-area", "start"); + this.wrapperEl = formEl; + this.registerDomEvent(formEl, "submit", this.onStart.bind(this)); const nameWrapperEl = formEl.createDiv({ cls: "timekeep-name-wrapper" }); @@ -92,85 +79,23 @@ export class TimesheetStart extends ContentComponent< this.#startButtonEl = startButton; const onUpdate = this.onUpdate.bind(this); - const unsubscribeTimekeep = this.timekeep.subscribe(onUpdate); + this.register(this.timekeep.subscribe(onUpdate)); onUpdate(); - - this.register(unsubscribeTimekeep); } onUpdate() { - this.updateRunning(); + const timekeep = this.timekeep.getState(); + const currentEntry = getRunningEntry(timekeep.entries); - if (this.getContent() instanceof TimesheetStartEditing) { - this.setEditingView(); - return; - } - - this.setCurrentView(); - } - - updateRunning() { const blockPauseWarningEl = this.#blockPauseWarningEl; const startButtonEl = this.#startButtonEl; assert(blockPauseWarningEl && startButtonEl, "Elements should be defined"); - const timekeep = this.timekeep.getState(); - const currentEntry = getRunningEntry(timekeep.entries); const isTimekeepRunning = currentEntry !== null; - blockPauseWarningEl.hidden = currentEntry === null || currentEntry.startTime === null; startButtonEl.title = isTimekeepRunning ? "Stop and start" : "Start"; } - /** - * Switch to the editing view - */ - setEditingView() { - const contentEl = this.#contentEl; - assert(contentEl, "Content element should be defined"); - - const timekeep = this.timekeep.getState(); - const currentEntry = getRunningEntry(timekeep.entries); - if (!currentEntry) { - return this.setContent(undefined); - } - - this.setContent( - new TimesheetStartEditing( - contentEl, - this.timekeep, - this.settings, - currentEntry.name, - this.setCurrentView.bind(this) - ) - ); - } - - /** - * Switch to the default creation view - */ - setCurrentView() { - const contentEl = this.#contentEl; - assert(contentEl, "Content element should be defined"); - - const timekeep = this.timekeep.getState(); - const currentEntry = getRunningEntry(timekeep.entries); - - if (currentEntry === null || currentEntry.startTime === null) { - return this.setContent(undefined); - } - - this.setContent( - new TimesheetStartRunning( - contentEl, - this.timekeep, - this.settings, - currentEntry, - this.setEditingView.bind(this) - ) - ); - } - onStart(event: Event) { // Prevent form submission from reloading Obsidian event.preventDefault(); diff --git a/src/components/timesheetStartRunning.test.ts b/src/components/timesheetStartRunning.test.ts index ab8d01c..1491fc6 100644 --- a/src/components/timesheetStartRunning.test.ts +++ b/src/components/timesheetStartRunning.test.ts @@ -2,7 +2,7 @@ import moment from "moment"; import { v4 } from "uuid"; -import { describe, it, vi, beforeEach } from "vitest"; +import { describe, it, vi, beforeEach, afterEach, expect } from "vitest"; import type { TimeEntry, Timekeep } from "@/timekeep/schema"; @@ -17,6 +17,7 @@ describe("TimesheetStartRunning", () => { let timekeep: Store; let settings: Store; let onStartEditing: VoidFunction; + let component: TimesheetStartRunning; const start = moment(); const entry: TimeEntry = { @@ -42,8 +43,12 @@ describe("TimesheetStartRunning", () => { onStartEditing = vi.fn(); }); + afterEach(() => { + if (component) component.unload(); + }); + it("should load without error", () => { - const component = new TimesheetStartRunning( + component = new TimesheetStartRunning( containerEl, timekeep, settings, @@ -52,4 +57,107 @@ describe("TimesheetStartRunning", () => { ); component.load(); }); + + it("should be able to stop the entry", () => { + const start = moment(); + const end = moment().add(1, "hour"); + vi.setSystemTime(end.toDate()); + + const id = v4(); + + timekeep.setState({ + entries: [ + { + id, + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }, + ], + }); + + component = new TimesheetStartRunning( + containerEl, + timekeep, + settings, + entry, + onStartEditing + ); + + const onStop = vi.spyOn(component, "onStop"); + + component.load(); + + const formEl = component.wrapperEl; + expect(formEl).not.toBeNull(); + (formEl as HTMLFormElement).dispatchEvent( + new SubmitEvent("submit", { bubbles: true, cancelable: true }) + ); + + expect(onStop).toHaveBeenCalled(); + + expect(timekeep.getState()).toEqual({ + entries: [ + { + id, + name: "Test", + startTime: moment(start), + endTime: moment(end), + subEntries: null, + }, + ], + }); + }); + + it("nested running entry should have a path", () => { + const start = moment(); + const end = moment().add(1, "hour"); + vi.setSystemTime(end.toDate()); + + const entry = { + id: v4(), + name: "Test", + startTime: moment(start), + endTime: null, + subEntries: null, + }; + + timekeep.setState({ + entries: [ + { + id: v4(), + name: "Outer", + startTime: null, + endTime: null, + subEntries: [ + { + id: v4(), + name: "Inner", + startTime: null, + endTime: null, + subEntries: [entry], + }, + ], + }, + ], + }); + + component = new TimesheetStartRunning( + containerEl, + timekeep, + settings, + entry, + onStartEditing + ); + + component.load(); + + const pathEls = component.wrapperEl!.querySelectorAll(".timekeep-path-to-entry__segment"); + expect(pathEls.length).toBe(3); + + expect(pathEls.item(0).textContent).toBe("Outer >"); + expect(pathEls.item(1).textContent).toBe("Inner >"); + expect(pathEls.item(2).textContent).toBe("Test"); + }); }); diff --git a/src/components/timesheetStartRunning.ts b/src/components/timesheetStartRunning.ts index 41e8c16..8a5aa5e 100644 --- a/src/components/timesheetStartRunning.ts +++ b/src/components/timesheetStartRunning.ts @@ -131,12 +131,12 @@ export class TimesheetStartRunning extends DomComponent { pathEl.empty(); const pathToEntry = getPathToEntry(timekeep.entries, currentEntry); - if (pathToEntry && pathToEntry.length > 0) { - for (let i = 0; i < pathToEntry.length; i++) { - const path = pathToEntry[i]; - const text = `${path.name} ${i < pathToEntry.length - 1 ? " >" : ""}`; - pathEl.createSpan({ cls: "timekeep-path-to-entry__segment", text }); - } + assert(pathToEntry, "Entry path should exist"); + + for (let i = 0; i < pathToEntry.length; i++) { + const path = pathToEntry[i]; + const text = `${path.name}${i < pathToEntry.length - 1 ? " >" : ""}`; + pathEl.createSpan({ cls: "timekeep-path-to-entry__segment", text }); } }