From 5d51e8b46fa121218ef3858acaa585f679eeaaf4 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 24 Jan 2026 13:43:09 +0100 Subject: [PATCH] feat: add parent / child feature --- .../task/infra/adaptStepsToTextarea.test.ts | 131 +++++++++++++++++- .../task/infra/adaptStepsToTextarea.ts | 58 ++++++-- 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/src/modules/task/infra/adaptStepsToTextarea.test.ts b/src/modules/task/infra/adaptStepsToTextarea.test.ts index 192d5b0..126f139 100644 --- a/src/modules/task/infra/adaptStepsToTextarea.test.ts +++ b/src/modules/task/infra/adaptStepsToTextarea.test.ts @@ -77,7 +77,9 @@ describe('adapt steps to textarea value', () => { const [step] = adaptTextareaToSteps(stepInTextarea) - expect(step.id).toMatchInlineSnapshot(`"66f312736335fce1df9a8b95c7be3fce-1"`) + expect(step.id).toMatchInlineSnapshot( + `"66f312736335fce1df9a8b95c7be3fce-1"` + ) }) it('creates generated ids based on title and estimation and indexes when duplicated', () => { @@ -129,4 +131,131 @@ describe('adapt steps to textarea value', () => { expect(adaptTextareaToSteps(stepInTextarea)).toEqual([expectedStep]) }) + + describe('subtask support', () => { + it('flattens indented subtasks with parent prefix', () => { + const stepInTextarea = `- Parent task | 5 + - Child task 1 | 3 + - Child task 2 | 2` + + const expectedSteps = [ + fixtureStep({ + id: expect.any(String), + title: '(Parent task) - Child task 1', + estimation: 3 + }), + fixtureStep({ + id: expect.any(String), + title: '(Parent task) - Child task 2', + estimation: 2 + }) + ] + + expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps) + }) + + it('ignores parent estimation when it has children', () => { + const stepInTextarea = `- Parent | 10 + - Child | 3` + + const steps = adaptTextareaToSteps(stepInTextarea) + + expect(steps).toHaveLength(1) + expect(steps[0].title).toBe('(Parent) - Child') + expect(steps[0].estimation).toBe(3) + }) + + it('treats standalone items (no children) as regular steps', () => { + const stepInTextarea = `- Standalone task | 4` + + const expectedSteps = [ + fixtureStep({ + id: expect.any(String), + title: 'Standalone task', + estimation: 4 + }) + ] + + expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps) + }) + + it('handles mixed subtasks and standalone items', () => { + const stepInTextarea = `- Parent task | 5 + - Child task 1 | 3 + - Child task 2 | 2 +- Standalone | 4` + + const expectedSteps = [ + fixtureStep({ + id: expect.any(String), + title: '(Parent task) - Child task 1', + estimation: 3 + }), + fixtureStep({ + id: expect.any(String), + title: '(Parent task) - Child task 2', + estimation: 2 + }), + fixtureStep({ + id: expect.any(String), + title: 'Standalone', + estimation: 4 + }) + ] + + expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps) + }) + + it('handles tab indentation', () => { + const stepInTextarea = `- Parent | 5 +\t- Child | 3` + + const steps = adaptTextareaToSteps(stepInTextarea) + + expect(steps).toHaveLength(1) + expect(steps[0].title).toBe('(Parent) - Child') + }) + + it('treats orphan indented line as regular step', () => { + const stepInTextarea = ` - Orphan indented | 3` + + const expectedSteps = [ + fixtureStep({ + id: expect.any(String), + title: 'Orphan indented', + estimation: 3 + }) + ] + + expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps) + }) + + it('handles multiple parents with their children', () => { + const stepInTextarea = `- Parent 1 | 5 + - Child 1a | 2 + - Child 1b | 3 +- Parent 2 | 4 + - Child 2a | 1` + + const expectedSteps = [ + fixtureStep({ + id: expect.any(String), + title: '(Parent 1) - Child 1a', + estimation: 2 + }), + fixtureStep({ + id: expect.any(String), + title: '(Parent 1) - Child 1b', + estimation: 3 + }), + fixtureStep({ + id: expect.any(String), + title: '(Parent 2) - Child 2a', + estimation: 1 + }) + ] + + expect(adaptTextareaToSteps(stepInTextarea)).toEqual(expectedSteps) + }) + }) }) diff --git a/src/modules/task/infra/adaptStepsToTextarea.ts b/src/modules/task/infra/adaptStepsToTextarea.ts index e815db3..30087b9 100644 --- a/src/modules/task/infra/adaptStepsToTextarea.ts +++ b/src/modules/task/infra/adaptStepsToTextarea.ts @@ -4,6 +4,8 @@ import type { Stepable } from '../interfaces/stepable' export const adaptStepsToTextarea = (steps: Stepable[]) => steps.map((step) => `- ${step.title} | ${step.estimation}`).join('\n') +const isIndented = (line: string): boolean => /^[\t ]+/.test(line) + const extractTitleAndEstimationFromStep = ( rawStep: string ): [string, number] => { @@ -23,22 +25,58 @@ const extractTitleAndEstimationFromStep = ( return [title, estimation] } -export const adaptTextareaToSteps = (textareaValue: string): Stepable[] => - textareaValue - .split('\n') - .map((rawStep) => { - const [title, estimation] = extractTitleAndEstimationFromStep(rawStep) +export const adaptTextareaToSteps = (textareaValue: string): Stepable[] => { + const lines = textareaValue.split('\n') + const result: Array<{ title: string; estimation: number }> = [] - if (!title) { - return null + let currentParent: { title: string; estimation: number } | null = null + let parentHasChildren = false + + const flushParent = () => { + if (currentParent && !parentHasChildren) { + result.push(currentParent) + } + currentParent = null + parentHasChildren = false + } + + for (const line of lines) { + const [title, estimation] = extractTitleAndEstimationFromStep(line) + + if (!title) { + continue + } + + if (isIndented(line)) { + // Indented line - flatten with parent prefix if parent exists + if (currentParent && currentParent.title) { + const flattenedTitle = `(${currentParent.title}) - ${title}` + result.push({ title: flattenedTitle, estimation }) + parentHasChildren = true + } else { + // Orphan indented line - treat as regular step + result.push({ title, estimation }) } + } else { + // Non-indented line - potential parent + flushParent() + currentParent = { title, estimation } + } + } - return { id: generateId(`${title}-${estimation}`), title, estimation } - }) - .filter((step): step is Stepable => step !== null) + // Flush any remaining parent + flushParent() + + return result + .map(({ title, estimation }) => ({ + id: generateId(`${title}-${estimation}`), + title, + estimation + })) .map((step, index, steps) => { const subSteps = steps.slice(0, index + 1) const duplicates = subSteps.filter((s) => s.id === step.id).length return { ...step, id: `${step.id}-${duplicates}` } }) +}