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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ selectedOption?.label ?? 'Not set' }}
+
+
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')