diff --git a/src/components/TimekeepName.tsx b/src/components/TimekeepName.tsx index f2578fb..4dc9b0a 100644 --- a/src/components/TimekeepName.tsx +++ b/src/components/TimekeepName.tsx @@ -14,15 +14,23 @@ type Props = { export default function TimekeepName({ name }: Props) { const segments = parseNameSegments(name); - return segments.map((segment, index) => { - switch (segment.type) { - case NameSegmentType.Text: - return ; + return ( + <> + {segments.map((segment, index) => { + switch (segment.type) { + case NameSegmentType.Text: + return ( + + ); - case NameSegmentType.Link: - return ; - } - }); + case NameSegmentType.Link: + return ( + + ); + } + })} + + ); } function TimekeepNameText({ segment }: { segment: NameSegmentText }) { diff --git a/src/components/TimesheetRow.tsx b/src/components/TimesheetRow.tsx index ab68016..04f91a4 100644 --- a/src/components/TimesheetRow.tsx +++ b/src/components/TimesheetRow.tsx @@ -94,6 +94,13 @@ export default function TimesheetRow({ entry, indent }: Props) { className="timekeep-entry-name" title={entry.name} onClick={handleToggleCollapsed}> + {entry.folder && ( + + )} + {entry.subEntries !== null && ( diff --git a/src/styles.css b/src/styles.css index 2f04d73..670aab6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -219,6 +219,12 @@ vertical-align: middle; } +.timekeep-folder-icon { + margin-right: 0.5rem; + cursor: pointer; + vertical-align: middle; +} + .timekeep-start-note { font-size: 0.8rem; color: #777; diff --git a/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts new file mode 100644 index 0000000..d0635d0 --- /dev/null +++ b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts @@ -0,0 +1,44 @@ +import moment from "moment"; + +export const currentTime = moment(); + +export const input = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder", + startTime: null, + endTime: null, + subEntries: [ + { + id: "8054dee3-8c15-493b-ad31-f070e08c2699", + name: "New Entry", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + folder: true, +}; + +export const expected = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder", + startTime: null, + endTime: null, + subEntries: [ + { + id: "8054dee3-8c15-493b-ad31-f070e08c2699", + name: "New Entry", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + id: "5054dee3-8c15-493b-ad31-f070e08c2699", + name: "New Entry 2", + startTime: currentTime, + endTime: null, + subEntries: null, + }, + ], + folder: true, +}; diff --git a/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts new file mode 100644 index 0000000..51fda0d --- /dev/null +++ b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts @@ -0,0 +1,29 @@ +import moment from "moment"; + +export const currentTime = moment(); + +export const input = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder", + startTime: null, + endTime: null, + subEntries: null, + folder: true, +}; + +export const expected = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder", + startTime: null, + endTime: null, + subEntries: [ + { + id: "8054dee3-8c15-493b-ad31-f070e08c2699", + name: "New Entry", + startTime: currentTime, + endTime: null, + subEntries: null, + }, + ], + folder: true, +}; diff --git a/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts b/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts new file mode 100644 index 0000000..a14a0a8 --- /dev/null +++ b/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts @@ -0,0 +1,32 @@ +import moment from "moment"; + +export const currentTime = moment(); +export const entryToRemove = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Block 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, +}; + +export const entries = [ + { + id: "8054dee3-8c15-493b-ad31-f070e08c2699", + name: "Block 3", + startTime: null, + endTime: null, + subEntries: [entryToRemove], + folder: true, + }, +]; + +export const expectedEntries = [ + { + id: "8054dee3-8c15-493b-ad31-f070e08c2699", + name: "Block 3", + startTime: null, + endTime: null, + subEntries: [], + folder: true, + }, +]; diff --git a/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts b/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts new file mode 100644 index 0000000..dcf1a7a --- /dev/null +++ b/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts @@ -0,0 +1,33 @@ +import moment from "moment"; + +export const currentTime = moment(); + +export const targetEntry = { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder Entry", + startTime: null, + endTime: null, + subEntries: null, + folder: true, +}; + +export const input = [targetEntry]; + +export const expected = [ + { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Folder Entry", + startTime: null, + endTime: null, + subEntries: [ + { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Part 1", + startTime: currentTime, + endTime: null, + subEntries: null, + }, + ], + folder: true, + }, +]; diff --git a/src/timekeep/create.test.ts b/src/timekeep/create.test.ts index 8328558..7a829cd 100644 --- a/src/timekeep/create.test.ts +++ b/src/timekeep/create.test.ts @@ -51,6 +51,26 @@ describe("withSubEntry", () => { ); }); + it("adding first entry for folder should populate subentries", async () => { + const { input, currentTime, expected } = await import( + "./__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries" + ); + const output = withSubEntry(input, "New Entry", currentTime); + expect(stripEntryRuntimeData(output)).toEqual( + stripEntryRuntimeData(expected) + ); + }); + + it("adding first entry for folder should extend subentries", async () => { + const { input, currentTime, expected } = await import( + "./__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries" + ); + const output = withSubEntry(input, "New Entry 2", currentTime); + expect(stripEntryRuntimeData(output)).toEqual( + stripEntryRuntimeData(expected) + ); + }); + it("adding to group should extend sub entries", async () => { const { input, currentTime, expected } = await import( "./__fixtures__/manipulating/adding_sub_entry/addToGroupExtendSubEntries" diff --git a/src/timekeep/create.ts b/src/timekeep/create.ts index cfe61a8..3287513 100644 --- a/src/timekeep/create.ts +++ b/src/timekeep/create.ts @@ -112,6 +112,15 @@ function getSubEntryName(name: string, groupEntry: TimeEntryGroup) { * @returns The group entry */ function makeGroupEntry(entry: TimeEntry): TimeEntryGroup { + if (entry.folder) { + return { + ...entry, + subEntries: entry.subEntries ?? [], + startTime: null, + endTime: null, + }; + } + if (entry.subEntries !== null) { return entry; } diff --git a/src/timekeep/schema.ts b/src/timekeep/schema.ts index 9184cbe..0a10530 100644 --- a/src/timekeep/schema.ts +++ b/src/timekeep/schema.ts @@ -35,6 +35,9 @@ const TIME_ENTRY_SINGLE = z .transform((value) => (value === null ? null : moment(value))), // Single entries have no children subEntries: z.null(), + // Optional field to indicate the entry should stay as a group when non-started and should only create + // sub entries when starting + folder: z.boolean().optional(), }) // At runtime a unique ID is inserted .transform((entry) => ({ @@ -49,7 +52,11 @@ const TIME_ENTRY_GROUP_BASE = z.object({ endTime: z.null(), // Optional field to indicate the entry is collapsed collapsed: z.boolean().optional(), + // Optional field to indicate the entry should stay as a group when non-started and should only create + // sub entries when starting + folder: z.boolean().optional(), }); + // Schema for a time entry group const TIME_ENTRY_GROUP: z.ZodType< TimeEntryGroup, diff --git a/src/timekeep/start.test.ts b/src/timekeep/start.test.ts index ab98503..4f85edb 100644 --- a/src/timekeep/start.test.ts +++ b/src/timekeep/start.test.ts @@ -42,6 +42,17 @@ describe("startNewNestedEntry", () => { ); }); + it("starting a new entry within a folder should create a subentry", async () => { + const { currentTime, targetEntry, input, expected } = await import( + "./__fixtures__/manipulating/start_entry/startNestedFolderEntry" + ); + + const output = startNewNestedEntry(currentTime, targetEntry.id, input); + expect(stripEntriesRuntimeData(output)).toEqual( + stripEntriesRuntimeData(expected) + ); + }); + it("starting a new entry should stop any running entries", async () => { const { currentTime, targetEntry, input, expected } = await import( "./__fixtures__/manipulating/start_entry/startNotStartedEntry" diff --git a/src/timekeep/start.ts b/src/timekeep/start.ts index 9560ec5..8b51020 100644 --- a/src/timekeep/start.ts +++ b/src/timekeep/start.ts @@ -49,8 +49,12 @@ export function startNewNestedEntry( return entries; } - // If the entry has been started or is a group create a new child entry - if (currentEntry.subEntries !== null || currentEntry.startTime !== null) { + // If the entry has been started, is a group, or is a folder group create a new child entry + if ( + currentEntry.subEntries !== null || + currentEntry.startTime !== null || + currentEntry.folder + ) { return updateEntry( entries, // Ensure the current entry is ended diff --git a/src/timekeep/update.test.ts b/src/timekeep/update.test.ts index 972d88d..298fb42 100644 --- a/src/timekeep/update.test.ts +++ b/src/timekeep/update.test.ts @@ -106,6 +106,14 @@ describe("removeEntry", () => { const updated = removeEntry(entries, entryToRemove); expect(updated).toEqual(expectedEntries); }); + + it("should not collapse folder on empty entries", async () => { + const { entries, entryToRemove, expectedEntries } = await import( + "./__fixtures__/manipulating/remove_entry/removeEntryFolder" + ); + const updated = removeEntry(entries, entryToRemove); + expect(updated).toEqual(expectedEntries); + }); }); describe("removeSubEntry", () => { diff --git a/src/timekeep/update.ts b/src/timekeep/update.ts index 45d1a2b..9877f42 100644 --- a/src/timekeep/update.ts +++ b/src/timekeep/update.ts @@ -119,7 +119,8 @@ export function removeEntry( // Add non-empty entries to the accumulator if ( collapsedEntry.subEntries === null || - collapsedEntry.subEntries.length > 0 + collapsedEntry.subEntries.length > 0 || + collapsedEntry.folder ) { acc.push(collapsedEntry); } @@ -157,6 +158,11 @@ export function removeSubEntry(entry: TimeEntry, target: TimeEntry): TimeEntry { * @returns The collapsed entry */ function makeEntrySingle(target: TimeEntry): TimeEntry { + // Cannot collapse folders + if (target.folder) { + return target; + } + // Target has no entries to collapse if (target.subEntries === null) { return target;