diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 4e54698..2d7ca63 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService; use App\Actions\Projects\BuildProjectTicketDetailPayload; use App\Actions\Projects\BuildProjectTicketOptionsPayload; use App\Actions\Tickets\CreateProjectTicket; +use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper; use App\Actions\Tickets\UpdateProjectTicket; use App\Enums\Permission; use App\Http\Requests\StoreProjectTicketRequest; @@ -115,6 +116,9 @@ class ProjectTicketController extends Controller if ($request->expectsJson()) { return response()->json([ + 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds( + $ticket->acceptanceCriteria()->get(), + ), 'message' => $statusMessage, 'updated' => $wasUpdated, ]); diff --git a/resources/js/components/projects/AcceptanceCriteriaEditor.vue b/resources/js/components/projects/AcceptanceCriteriaEditor.vue new file mode 100644 index 0000000..073022a --- /dev/null +++ b/resources/js/components/projects/AcceptanceCriteriaEditor.vue @@ -0,0 +1,490 @@ + + + + + + + No acceptance criteria have been added yet. + + + + Add criterion + + + + + + + + + + + + {{ item.text || 'Describe a verifiable outcome' }} + + + + {{ item.text }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add criterion + + + + + + Saving + + + + Saved + + + + {{ error || 'Could not save changes.' }} + + + + diff --git a/resources/js/components/projects/InlineSaveStatus.vue b/resources/js/components/projects/InlineSaveStatus.vue new file mode 100644 index 0000000..ada224a --- /dev/null +++ b/resources/js/components/projects/InlineSaveStatus.vue @@ -0,0 +1,30 @@ + + + + + + + Saving + + + + Saved + + + + {{ + error || 'Could not save.' + }} + + + diff --git a/resources/js/components/projects/InlineTextEditor.vue b/resources/js/components/projects/InlineTextEditor.vue new file mode 100644 index 0000000..40f8337 --- /dev/null +++ b/resources/js/components/projects/InlineTextEditor.vue @@ -0,0 +1,205 @@ + + + + + + + + + {{ + multiline + ? 'Cmd/Ctrl+Enter to save · Escape to cancel' + : 'Enter to save · Escape to cancel' + }} + + + + + + {{ displayValue() }} + + + + + + + {{ displayValue() }} + + + + + + + Saving + + + + Saved + + + + {{ + error || 'Could not save.' + }} + + + + diff --git a/resources/js/lib/acceptance-criteria.ts b/resources/js/lib/acceptance-criteria.ts index 3224291..d96dbe6 100644 --- a/resources/js/lib/acceptance-criteria.ts +++ b/resources/js/lib/acceptance-criteria.ts @@ -96,6 +96,226 @@ export const syncAcceptanceCriteriaDescendantCheckedState = ( }); }; +export const acceptanceCriteriaSubtreeEnd = ( + items: AcceptanceCriteriaModel, + index: number, +): number => { + const indent = items[index]?.indent; + + if (indent === undefined) { + return index; + } + + let end = index + 1; + + while (end < items.length && items[end].indent > indent) { + end++; + } + + return end; +}; + +export const previousAcceptanceCriteriaSiblingIndex = ( + items: AcceptanceCriteriaModel, + index: number, +): number | null => { + const indent = items[index]?.indent; + + if (indent === undefined) { + return null; + } + + for ( + let candidateIndex = index - 1; + candidateIndex >= 0; + candidateIndex-- + ) { + const candidateIndent = items[candidateIndex].indent; + + if (candidateIndent < indent) { + return null; + } + + if (candidateIndent === indent) { + return candidateIndex; + } + } + + return null; +}; + +export const nextAcceptanceCriteriaSiblingIndex = ( + items: AcceptanceCriteriaModel, + index: number, +): number | null => { + const indent = items[index]?.indent; + const candidateIndex = acceptanceCriteriaSubtreeEnd(items, index); + + if ( + indent === undefined || + candidateIndex >= items.length || + items[candidateIndex].indent !== indent + ) { + return null; + } + + return candidateIndex; +}; + +export const addAcceptanceCriterionAfter = ( + items: AcceptanceCriteriaModel, + index: number | null, +): { index: number; items: AcceptanceCriteriaModel } => { + const nextItems = items.map((item) => ({ ...item })); + const insertionIndex = + index === null + ? nextItems.length + : acceptanceCriteriaSubtreeEnd(nextItems, index); + const indent = index === null ? 0 : (nextItems[index]?.indent ?? 0); + + nextItems.splice(insertionIndex, 0, { + checked: false, + indent, + text: '', + }); + + return { + index: insertionIndex, + items: nextItems, + }; +}; + +export const indentAcceptanceCriterionSubtree = ( + items: AcceptanceCriteriaModel, + index: number, +): AcceptanceCriteriaModel => { + const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex( + items, + index, + ); + const item = items[index]; + + if ( + item === undefined || + item.indent >= MAX_INDENT_LEVEL || + previousSiblingIndex === null + ) { + return items; + } + + const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index); + + return items.map((criterion, criterionIndex) => ({ + ...criterion, + indent: + criterionIndex >= index && criterionIndex < subtreeEnd + ? criterion.indent + 1 + : criterion.indent, + })); +}; + +export const outdentAcceptanceCriterionSubtree = ( + items: AcceptanceCriteriaModel, + index: number, +): AcceptanceCriteriaModel => { + const item = items[index]; + + if (item === undefined || item.indent === 0) { + return items; + } + + const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index); + + return items.map((criterion, criterionIndex) => ({ + ...criterion, + indent: + criterionIndex >= index && criterionIndex < subtreeEnd + ? criterion.indent - 1 + : criterion.indent, + })); +}; + +export const removeAcceptanceCriterion = ( + items: AcceptanceCriteriaModel, + index: number, +): AcceptanceCriteriaModel => { + const item = items[index]; + + if (item === undefined) { + return items; + } + + const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index); + + return items + .filter((_criterion, criterionIndex) => criterionIndex !== index) + .map((criterion, criterionIndex) => { + const originalIndex = + criterionIndex >= index ? criterionIndex + 1 : criterionIndex; + + return { + ...criterion, + indent: + originalIndex > index && originalIndex < subtreeEnd + ? criterion.indent - 1 + : criterion.indent, + }; + }); +}; + +export const moveAcceptanceCriterionSubtree = ( + items: AcceptanceCriteriaModel, + index: number, + direction: 'down' | 'up', +): AcceptanceCriteriaModel => { + const subtreeEnd = acceptanceCriteriaSubtreeEnd(items, index); + const currentSubtree = items + .slice(index, subtreeEnd) + .map((item) => ({ ...item })); + + if (direction === 'up') { + const previousSiblingIndex = previousAcceptanceCriteriaSiblingIndex( + items, + index, + ); + + if (previousSiblingIndex === null) { + return items; + } + + return [ + ...items + .slice(0, previousSiblingIndex) + .map((item) => ({ ...item })), + ...currentSubtree, + ...items + .slice(previousSiblingIndex, index) + .map((item) => ({ ...item })), + ...items.slice(subtreeEnd).map((item) => ({ ...item })), + ]; + } + + const nextSiblingIndex = nextAcceptanceCriteriaSiblingIndex(items, index); + + if (nextSiblingIndex === null) { + return items; + } + + const nextSubtreeEnd = acceptanceCriteriaSubtreeEnd( + items, + nextSiblingIndex, + ); + + return [ + ...items.slice(0, index).map((item) => ({ ...item })), + ...items + .slice(nextSiblingIndex, nextSubtreeEnd) + .map((item) => ({ ...item })), + ...currentSubtree, + ...items.slice(nextSubtreeEnd).map((item) => ({ ...item })), + ]; +}; + export const acceptanceCriteriaToEditorState = ( value: AcceptanceCriteriaModel | null | undefined, ): AcceptanceCriteriaEditorState => { @@ -121,9 +341,7 @@ const parseChecklistLine = ( return null; } - const indentLevel = clampIndent( - Math.floor(match.groups.indent.length / 2), - ); + const indentLevel = clampIndent(Math.floor(match.groups.indent.length / 2)); let content = match.groups.content.trim(); let checked = false; diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index 5fba327..4d3010c 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -1,30 +1,40 @@
+ No acceptance criteria have been added yet. +
+ {{ + multiline + ? 'Cmd/Ctrl+Enter to save · Escape to cancel' + : 'Enter to save · Escape to cancel' + }} +
+ {{ displayValue() }} +