diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 25257cb..e973fb3 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -47,6 +47,7 @@ class BuildProjectTicketDetailPayload 'fieldValues.userValue', 'parentTicket', 'ticketPriority', + 'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.boardColumn', 'ticketType', 'worklogEntries.actor', diff --git a/app/Http/Controllers/ProjectBoardController.php b/app/Http/Controllers/ProjectBoardController.php index 511f68f..7606a21 100644 --- a/app/Http/Controllers/ProjectBoardController.php +++ b/app/Http/Controllers/ProjectBoardController.php @@ -7,8 +7,10 @@ use App\Actions\Projects\BuildProjectBoardPayload; use App\Actions\Tickets\MoveProjectTicket; use App\Enums\Permission; use App\Http\Requests\MoveProjectTicketRequest; +use App\Http\Resources\TicketStatusResource; use App\Models\Project; use App\Models\Ticket; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -42,7 +44,7 @@ class ProjectBoardController extends Controller MoveProjectTicketRequest $request, Project $project, Ticket $ticket, - ): RedirectResponse { + ): RedirectResponse|JsonResponse { $user = $request->user(); abort_unless( @@ -56,13 +58,32 @@ class ProjectBoardController extends Controller : 'projects.board.index'; $result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated); + $statusMessage = $result['moved'] + ? "{$ticket->ticket_key} moved to {$result['destination_label']}." + : "{$ticket->ticket_key} already belongs to {$result['destination_label']}."; + + if ($request->expectsJson()) { + $ticket->refresh()->load([ + 'ticketStatus.allowedTargetStatuses.boardColumn', + 'ticketStatus.boardColumn', + ]); + + return response()->json([ + 'message' => $statusMessage, + 'moved' => $result['moved'], + 'ticket_status' => $ticket->ticketStatus === null + ? null + : TicketStatusResource::make($ticket->ticketStatus)->resolve(), + 'ticket_status_id' => $ticket->ticket_status_id, + ]); + } if (! $result['moved']) { return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}."); + ->with('status', $statusMessage); } return to_route($redirectRoute, $project) - ->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}."); + ->with('status', $statusMessage); } } diff --git a/resources/js/components/projects/AcceptanceCriteriaEditor.vue b/resources/js/components/projects/AcceptanceCriteriaEditor.vue index 073022a..38a8d58 100644 --- a/resources/js/components/projects/AcceptanceCriteriaEditor.vue +++ b/resources/js/components/projects/AcceptanceCriteriaEditor.vue @@ -2,11 +2,9 @@ import { ArrowDown, ArrowUp, - Check, CircleAlert, IndentDecrease, IndentIncrease, - LoaderCircle, Plus, Trash2, } from 'lucide-vue-next'; @@ -465,21 +463,7 @@ const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => { - - Saving - - - - Saved - - diff --git a/resources/js/components/projects/InlineSaveStatus.vue b/resources/js/components/projects/InlineSaveStatus.vue index ada224a..79cb7da 100644 --- a/resources/js/components/projects/InlineSaveStatus.vue +++ b/resources/js/components/projects/InlineSaveStatus.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index f18333a..32f121b 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -17,6 +17,7 @@ import { } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; import { normalizeAcceptanceCriteria } from '@/lib/acceptance-criteria'; +import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController'; import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; @@ -24,6 +25,7 @@ import InputError from '@/components/InputError.vue'; import AcceptanceCriteriaEditor from '@/components/projects/AcceptanceCriteriaEditor.vue'; import InlineSaveStatus from '@/components/projects/InlineSaveStatus.vue'; import InlineTextEditor from '@/components/projects/InlineTextEditor.vue'; +import PropertySelectDropdown from '@/components/projects/PropertySelectDropdown.vue'; import ProjectTabs from '@/components/projects/ProjectTabs.vue'; import UserSelectDropdown from '@/components/projects/UserSelectDropdown.vue'; import TicketTreeList from '@/components/TicketTreeList.vue'; @@ -56,14 +58,17 @@ import type { AcceptanceCriteriaEditorCommit, AcceptanceCriteriaSaveState, BreadcrumbItem, + PropertySelectOption, ProjectTicketPageProps, ProjectTicketUpdatePayload, QueuedTicketAutosave, TicketAutosaveErrorResponse, TicketAutosaveResponse, TicketEditableField, + TicketStatusMoveResponse, } from '@/types'; import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model'; +import type { TicketStatusModel } from '@/types/models/ticket-status-model'; const props = defineProps(); const page = usePage(); @@ -102,8 +107,19 @@ const canEditAuthor = computed( const canViewAuthor = computed( () => props.project.show_author || props.canManageProject, ); +const currentTicketStatus = ref( + props.ticket.ticket_status, +); +const selectedTicketStatusId = ref( + props.ticket.ticket_status_id ?? '', +); +const isStatusMoving = ref(false); +const statusMoveError = ref(''); const activeProjectSection = computed(() => - props.ticket.ticket_status?.board_column_id === null ? 'backlog' : 'board', + currentTicketStatus.value === null || + currentTicketStatus.value.board_column_id === null + ? 'backlog' + : 'board', ); const defaultBranchRepositoryConnectionId = computed(() => @@ -166,25 +182,52 @@ const form = useForm({ const queuedAutosave = ref(null); const isAutosaveRequestActive = ref(false); const savingField = ref(null); -const recentlySavedField = ref(null); const autosaveErrors = ref>>({}); const activeSelectField = ref(null); -let recentlySavedTimeout: number | undefined; - const currentTicketType = computed( () => props.ticketTypes.find( (ticketType) => ticketType.id === Number(form.ticket_type_id), ) ?? props.ticket.ticket_type, ); -const currentTicketPriority = computed( - () => - props.ticketPriorities.find( - (ticketPriority) => - ticketPriority.id === Number(form.ticket_priority_id), - ) ?? props.ticket.ticket_priority, +const ticketTypeOptions = computed(() => + props.ticketTypes.map((ticketType) => ({ + label: ticketType.name, + value: ticketType.id, + })), ); +const ticketPriorityOptions = computed(() => + props.ticketPriorities.map((ticketPriority) => ({ + label: ticketPriority.name, + value: ticketPriority.id, + })), +); +const ticketStatusOptions = computed(() => { + const currentStatus = currentTicketStatus.value; + + if (currentStatus === null) { + return []; + } + + return [currentStatus, ...(currentStatus.allowed_target_statuses ?? [])] + .filter( + (ticketStatus, index, statuses) => + statuses.findIndex( + (candidateStatus) => candidateStatus.id === ticketStatus.id, + ) === index, + ) + .map((ticketStatus) => ({ + description: + ticketStatus.board_column !== null && + ticketStatus.board_column !== undefined && + ticketStatus.board_column.name !== ticketStatus.name + ? ticketStatus.board_column.name + : null, + label: ticketStatus.name, + value: ticketStatus.id, + })); +}); const currentParentTicketOption = computed( () => props.parentTicketOptions.find( @@ -440,20 +483,6 @@ const clearAutosaveError = (field: TicketEditableField): void => { autosaveErrors.value = nextErrors; }; -const markFieldAsSaved = (field: TicketEditableField): void => { - recentlySavedField.value = field; - - if (recentlySavedTimeout !== undefined) { - window.clearTimeout(recentlySavedTimeout); - } - - recentlySavedTimeout = window.setTimeout(() => { - if (recentlySavedField.value === field) { - recentlySavedField.value = null; - } - }, 1800); -}; - const extractAutosaveError = async (response: Response): Promise => { try { const body = (await response.json()) as TicketAutosaveErrorResponse; @@ -525,8 +554,6 @@ const runAutosaveQueue = async (): Promise => { result.acceptance_criteria, ); } - - markFieldAsSaved(request.field); } catch (error) { autosaveErrors.value = { ...autosaveErrors.value, @@ -572,7 +599,7 @@ const saveStateFor = ( return 'error'; } - return recentlySavedField.value === field ? 'saved' : 'idle'; + return 'idle'; }; const errorFor = (field: TicketEditableField): string => @@ -602,6 +629,60 @@ const commitSelectField = (field: TicketEditableField): void => { requestTicketAutosave(field); }; +const moveTicketStatus = async (value: number | string): Promise => { + if (!props.canEditTicket || isStatusMoving.value) { + return; + } + + const previousStatusId = currentTicketStatus.value?.id ?? ''; + const csrfToken = document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); + + isStatusMoving.value = true; + statusMoveError.value = ''; + + try { + const response = await fetch( + ProjectBoardController.move.url({ + project: props.project.id, + ticket: props.ticket.id, + }), + { + method: 'PATCH', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken ?? '', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + redirect_to: activeProjectSection.value, + ticket_status_id: Number(value), + }), + }, + ); + + if (!response.ok) { + throw new Error(await extractAutosaveError(response)); + } + + const result = (await response.json()) as TicketStatusMoveResponse; + + currentTicketStatus.value = result.ticket_status; + selectedTicketStatusId.value = result.ticket_status_id ?? ''; + } catch (error) { + selectedTicketStatusId.value = previousStatusId; + statusMoveError.value = + error instanceof Error + ? error.message + : 'The ticket status could not be changed.'; + } finally { + isStatusMoving.value = false; + } +}; + const commitAcceptanceCriteria = ( commit: AcceptanceCriteriaEditorCommit, ): void => { @@ -641,11 +722,11 @@ const formatDateTime = (value: string): string => { {{ currentTicketType.name }} - {{ ticket.ticket_status.name }} + {{ currentTicketStatus.name }} {
- Status - {{ - ticket.ticket_status?.name ?? 'Backlog' - }} +
+ + +
{ >Type
- - -

- {{ currentTicketType.name }} -

+ /> { >Priority
- - -

- {{ currentTicketPriority.name }} -

+ /> create(); + $project = Project::factory()->create([ + 'key' => 'CORE', + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $todoColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Todo', + 'position' => 1, + ]); + $reviewColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'Review', + 'position' => 2, + ]); + $todoStatus = $this->createColumnStatus($todoColumn); + $reviewStatus = $this->createColumnStatus($reviewColumn); + $backlogStatus = $this->backlogStatus($project); + $this->allowMove($backlogStatus, $todoStatus); + $this->allowMove($todoStatus, $reviewStatus); + + $priority = TicketPriority::factory()->for($project)->create([ + 'name' => 'High', + 'position' => 2, + ]); + + $ticket = Ticket::factory()->create([ + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $backlogStatus->id, + 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, + 'title' => 'Inline status ticket', + ]); + + $this->syncProjectPermissions($project, $user, ['edit', 'view']); + + $this->actingAs($user) + ->patchJson(route('projects.tickets.status.update', [$project, $ticket]), [ + 'redirect_to' => 'backlog', + 'ticket_status_id' => $todoStatus->id, + ]) + ->assertOk() + ->assertJsonPath('message', 'CORE-1 moved to Todo.') + ->assertJsonPath('moved', true) + ->assertJsonPath('ticket_status.id', $todoStatus->id) + ->assertJsonPath('ticket_status.board_column.id', $todoColumn->id) + ->assertJsonPath('ticket_status.allowed_target_statuses.0.id', $reviewStatus->id) + ->assertJsonPath('ticket_status.allowed_target_statuses.0.board_column.id', $reviewColumn->id) + ->assertJsonPath('ticket_status_id', $todoStatus->id); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'ticket_status_id' => $todoStatus->id, + ]); + $this->assertDatabaseHas('ticket_worklog_entries', [ + 'actor_id' => $user->id, + 'entry_type' => 'status_changed', + 'summary' => 'Moved from Backlog to Todo.', + 'ticket_id' => $ticket->id, + ]); + } + /** * Ensure board tickets can move between columns. */ diff --git a/tests/Feature/Projects/ProjectTicketControllerTest.php b/tests/Feature/Projects/ProjectTicketControllerTest.php index 956adbe..ac80154 100644 --- a/tests/Feature/Projects/ProjectTicketControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketControllerTest.php @@ -70,6 +70,21 @@ class ProjectTicketControllerTest extends TestCase 'ticket_type_id' => $this->nonParentType($project)->id, 'title' => 'Ticket detail test', ]); + $targetColumn = BoardColumn::factory()->for($project)->create([ + 'name' => 'In Progress', + 'position' => 1, + ]); + $targetStatus = TicketStatus::factory() + ->for($project) + ->for($targetColumn) + ->create([ + 'name' => 'In Progress', + 'position' => 1, + ]); + TicketStatus::query() + ->findOrFail($ticket->ticket_status_id) + ->allowedTargetStatuses() + ->syncWithoutDetaching([$targetStatus->id]); $rootCriterion = AcceptanceCriterion::query()->create([ 'ticket_id' => $ticket->id, 'parent_acceptance_criterion_id' => null, @@ -131,6 +146,8 @@ class ProjectTicketControllerTest extends TestCase ->where('ticket.acceptance_criteria.1.checked', true) ->where('ticket.acceptance_criteria.1.indent', 1) ->where('ticket.assignee.name', 'Assigned User') + ->where('ticket.ticket_status.allowed_target_statuses.0.id', $targetStatus->id) + ->where('ticket.ticket_status.allowed_target_statuses.0.board_column.id', $targetColumn->id) ->where('ticket.author', null) ->where('ticket.author_user_id', null) ->where('ticket.field_values.0.ticket_field_definition.label', 'Complexity')