Add inline ticket property dropdowns
This commit is contained in:
@@ -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<ProjectTicketPageProps>();
|
||||
const page = usePage();
|
||||
@@ -102,8 +107,19 @@ const canEditAuthor = computed(
|
||||
const canViewAuthor = computed(
|
||||
() => props.project.show_author || props.canManageProject,
|
||||
);
|
||||
const currentTicketStatus = ref<TicketStatusModel | null>(
|
||||
props.ticket.ticket_status,
|
||||
);
|
||||
const selectedTicketStatusId = ref<number | string>(
|
||||
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<ProjectTicketUpdatePayload>({
|
||||
const queuedAutosave = ref<QueuedTicketAutosave | null>(null);
|
||||
const isAutosaveRequestActive = ref(false);
|
||||
const savingField = ref<TicketEditableField | null>(null);
|
||||
const recentlySavedField = ref<TicketEditableField | null>(null);
|
||||
const autosaveErrors = ref<Partial<Record<TicketEditableField, string>>>({});
|
||||
const activeSelectField = ref<TicketEditableField | null>(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<PropertySelectOption[]>(() =>
|
||||
props.ticketTypes.map((ticketType) => ({
|
||||
label: ticketType.name,
|
||||
value: ticketType.id,
|
||||
})),
|
||||
);
|
||||
const ticketPriorityOptions = computed<PropertySelectOption[]>(() =>
|
||||
props.ticketPriorities.map((ticketPriority) => ({
|
||||
label: ticketPriority.name,
|
||||
value: ticketPriority.id,
|
||||
})),
|
||||
);
|
||||
const ticketStatusOptions = computed<PropertySelectOption[]>(() => {
|
||||
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<string> => {
|
||||
try {
|
||||
const body = (await response.json()) as TicketAutosaveErrorResponse;
|
||||
@@ -525,8 +554,6 @@ const runAutosaveQueue = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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 }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="ticket.ticket_status"
|
||||
v-if="currentTicketStatus"
|
||||
variant="outline"
|
||||
class="rounded"
|
||||
>
|
||||
{{ ticket.ticket_status.name }}
|
||||
{{ currentTicketStatus.name }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="form.is_blocked"
|
||||
@@ -828,12 +909,23 @@ const formatDateTime = (value: string): string => {
|
||||
<div
|
||||
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
||||
>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
<span class="text-muted-foreground pt-1 text-xs"
|
||||
>Status</span
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
ticket.ticket_status?.name ?? 'Backlog'
|
||||
}}</span>
|
||||
<div class="min-w-0">
|
||||
<PropertySelectDropdown
|
||||
v-model="selectedTicketStatusId"
|
||||
:options="ticketStatusOptions"
|
||||
:editable="canEditTicket"
|
||||
:disabled="isStatusMoving"
|
||||
label="Status"
|
||||
@commit="moveTicketStatus"
|
||||
/>
|
||||
<InlineSaveStatus
|
||||
:state="statusMoveError ? 'error' : 'idle'"
|
||||
:error="statusMoveError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -867,39 +959,15 @@ const formatDateTime = (value: string): string => {
|
||||
>Type</span
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<select
|
||||
v-if="
|
||||
activeSelectField === 'ticket_type_id'
|
||||
"
|
||||
<PropertySelectDropdown
|
||||
v-model="form.ticket_type_id"
|
||||
autofocus
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
||||
@change="
|
||||
:options="ticketTypeOptions"
|
||||
:editable="canEditTicket"
|
||||
label="Type"
|
||||
@commit="
|
||||
commitSelectField('ticket_type_id')
|
||||
"
|
||||
@blur="activeSelectField = null"
|
||||
>
|
||||
<option
|
||||
v-for="ticketType in ticketTypes"
|
||||
:key="ticketType.id"
|
||||
:value="ticketType.id"
|
||||
>
|
||||
{{ ticketType.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-else-if="canEditTicket"
|
||||
type="button"
|
||||
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
||||
@click="
|
||||
activeSelectField = 'ticket_type_id'
|
||||
"
|
||||
>
|
||||
{{ currentTicketType.name }}
|
||||
</button>
|
||||
<p v-else class="min-h-7 text-sm font-medium">
|
||||
{{ currentTicketType.name }}
|
||||
</p>
|
||||
/>
|
||||
<InlineSaveStatus
|
||||
:state="saveStateFor('ticket_type_id')"
|
||||
:error="errorFor('ticket_type_id')"
|
||||
@@ -914,40 +982,15 @@ const formatDateTime = (value: string): string => {
|
||||
>Priority</span
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<select
|
||||
v-if="
|
||||
activeSelectField ===
|
||||
'ticket_priority_id'
|
||||
"
|
||||
<PropertySelectDropdown
|
||||
v-model="form.ticket_priority_id"
|
||||
autofocus
|
||||
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-2 py-1.5 text-sm outline-none focus-visible:ring-[3px]"
|
||||
@change="
|
||||
:options="ticketPriorityOptions"
|
||||
:editable="canEditTicket"
|
||||
label="Priority"
|
||||
@commit="
|
||||
commitSelectField('ticket_priority_id')
|
||||
"
|
||||
@blur="activeSelectField = null"
|
||||
>
|
||||
<option
|
||||
v-for="ticketPriority in ticketPriorities"
|
||||
:key="ticketPriority.id"
|
||||
:value="ticketPriority.id"
|
||||
>
|
||||
{{ ticketPriority.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-else-if="canEditTicket"
|
||||
type="button"
|
||||
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
||||
@click="
|
||||
activeSelectField = 'ticket_priority_id'
|
||||
"
|
||||
>
|
||||
{{ currentTicketPriority.name }}
|
||||
</button>
|
||||
<p v-else class="min-h-7 text-sm font-medium">
|
||||
{{ currentTicketPriority.name }}
|
||||
</p>
|
||||
/>
|
||||
<InlineSaveStatus
|
||||
:state="saveStateFor('ticket_priority_id')"
|
||||
:error="errorFor('ticket_priority_id')"
|
||||
|
||||
Reference in New Issue
Block a user