1974 lines
93 KiB
Vue
1974 lines
93 KiB
Vue
<script setup lang="ts">
|
|
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
|
import {
|
|
AlignLeft,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
CircleAlert,
|
|
Clock3,
|
|
GitBranch,
|
|
GitBranchPlus,
|
|
GitPullRequest,
|
|
Link2,
|
|
ListChecks,
|
|
Network,
|
|
SlidersHorizontal,
|
|
XCircle,
|
|
} 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';
|
|
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';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import AppLayout from '@/layouts/AppLayout.vue';
|
|
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
|
import { index as boardIndex } from '@/routes/projects/board';
|
|
import { index as projectIndex } from '@/routes/projects/index';
|
|
import { show as showTicket } from '@/routes/projects/tickets';
|
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
|
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();
|
|
const appName = String(page.props.name);
|
|
|
|
const visibleRepositoryBranches = computed(() =>
|
|
props.repositoryBranches.filter(
|
|
(repositoryBranch) =>
|
|
repositoryBranch.branches.length > 0 ||
|
|
repositoryBranch.error !== null ||
|
|
!repositoryBranch.repository_connection.has_personal_access,
|
|
),
|
|
);
|
|
|
|
const visibleRepositoryPullRequests = computed(() =>
|
|
props.repositoryPullRequests.filter(
|
|
(repositoryPullRequest) =>
|
|
repositoryPullRequest.pull_requests.length > 0 ||
|
|
repositoryPullRequest.error !== null ||
|
|
!repositoryPullRequest.repository_connection.has_personal_access,
|
|
),
|
|
);
|
|
|
|
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
|
props.repositoryBranches.map(
|
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
|
),
|
|
);
|
|
|
|
const canCreateBranches = computed(
|
|
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
|
);
|
|
const canEditAuthor = computed(
|
|
() => props.canEditTicket && props.canManageProject,
|
|
);
|
|
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(() =>
|
|
currentTicketStatus.value === null ||
|
|
currentTicketStatus.value.board_column_id === null
|
|
? 'backlog'
|
|
: 'board',
|
|
);
|
|
|
|
const defaultBranchRepositoryConnectionId = computed(() =>
|
|
String(
|
|
linkedRepositoryConnections.value.find(
|
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
|
)?.id ??
|
|
linkedRepositoryConnections.value[0]?.id ??
|
|
'',
|
|
),
|
|
);
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Projects',
|
|
href: projectIndex(),
|
|
},
|
|
{
|
|
title: props.project.name,
|
|
href: boardIndex(props.project.id),
|
|
},
|
|
{
|
|
title: props.ticket.ticket_key,
|
|
href: showTicket({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
},
|
|
];
|
|
|
|
const form = useForm<ProjectTicketUpdatePayload>({
|
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
|
props.ticket.acceptance_criteria,
|
|
),
|
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
|
author_user_id: props.ticket.author_user_id ?? '',
|
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
|
description: props.ticket.description ?? '',
|
|
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
|
|
const currentFieldValue = props.ticket.field_values?.find(
|
|
(fieldValue) =>
|
|
fieldValue.ticket_field_definition_id ===
|
|
ticketFieldDefinition.id,
|
|
);
|
|
|
|
return {
|
|
ticket_field_definition_id: ticketFieldDefinition.id,
|
|
value_number: currentFieldValue?.value_number ?? '',
|
|
value_user_id: currentFieldValue?.value_user_id ?? '',
|
|
};
|
|
}),
|
|
is_blocked: props.ticket.is_blocked,
|
|
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
|
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
|
ticket_type_id: props.ticket.ticket_type_id,
|
|
title: props.ticket.title,
|
|
story_points: props.ticket.story_points,
|
|
});
|
|
|
|
const queuedAutosave = ref<QueuedTicketAutosave | null>(null);
|
|
const isAutosaveRequestActive = ref(false);
|
|
const savingField = ref<TicketEditableField | null>(null);
|
|
const autosaveErrors = ref<Partial<Record<TicketEditableField, string>>>({});
|
|
const activeSelectField = ref<TicketEditableField | null>(null);
|
|
|
|
const currentTicketType = computed(
|
|
() =>
|
|
props.ticketTypes.find(
|
|
(ticketType) => ticketType.id === Number(form.ticket_type_id),
|
|
) ?? props.ticket.ticket_type,
|
|
);
|
|
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(
|
|
(parentTicket) => parentTicket.id === Number(form.parent_ticket_id),
|
|
) ?? null,
|
|
);
|
|
const currentParentTicket = computed(() => currentParentTicketOption.value);
|
|
const branchCount = computed(() =>
|
|
visibleRepositoryBranches.value.reduce(
|
|
(count, repositoryBranch) => count + repositoryBranch.branches.length,
|
|
0,
|
|
),
|
|
);
|
|
const pullRequestCount = computed(() =>
|
|
visibleRepositoryPullRequests.value.reduce(
|
|
(count, repositoryPullRequest) =>
|
|
count + repositoryPullRequest.pull_requests.length,
|
|
0,
|
|
),
|
|
);
|
|
const relationshipCount = computed(
|
|
() =>
|
|
props.linkedTickets.length +
|
|
props.childTicketTree.length +
|
|
(currentParentTicket.value === null ? 0 : 1),
|
|
);
|
|
const worklogCount = computed(() => props.ticket.worklog_entries?.length ?? 0);
|
|
const visibleAcceptanceCriteria = computed(() =>
|
|
form.acceptance_criteria.filter(
|
|
(criterion) => criterion.text.trim() !== '',
|
|
),
|
|
);
|
|
const completedAcceptanceCriteriaCount = computed(
|
|
() =>
|
|
visibleAcceptanceCriteria.value.filter((criterion) => criterion.checked)
|
|
.length,
|
|
);
|
|
|
|
const isCreateBranchDialogOpen = ref(false);
|
|
const isCheckingBranchName = ref(false);
|
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
|
|
|
const branchCreationForm = useForm<{
|
|
branch_name: string;
|
|
repository_connection_id: string;
|
|
}>({
|
|
branch_name: props.suggestedBranchName,
|
|
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
|
});
|
|
|
|
const selectedBranchRepositoryConnection = computed(
|
|
(): RepositoryConnectionModel | null =>
|
|
linkedRepositoryConnections.value.find(
|
|
(repositoryConnection) =>
|
|
String(repositoryConnection.id) ===
|
|
branchCreationForm.repository_connection_id,
|
|
) ?? null,
|
|
);
|
|
|
|
const selectedBranchRepositoryHasPersonalAccess = computed(
|
|
() =>
|
|
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
|
);
|
|
|
|
const initializeBranchCreationForm = (): void => {
|
|
branchCreationForm.clearErrors();
|
|
branchCreationForm.branch_name = props.suggestedBranchName;
|
|
branchCreationForm.repository_connection_id =
|
|
defaultBranchRepositoryConnectionId.value;
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
};
|
|
|
|
watch(
|
|
() => isCreateBranchDialogOpen.value,
|
|
(isOpen) => {
|
|
if (!isOpen) {
|
|
branchCreationForm.clearErrors();
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
initializeBranchCreationForm();
|
|
},
|
|
);
|
|
|
|
watch(
|
|
[
|
|
() => isCreateBranchDialogOpen.value,
|
|
() => branchCreationForm.repository_connection_id,
|
|
() => branchCreationForm.branch_name,
|
|
],
|
|
(
|
|
[isOpen, repositoryConnectionId, branchName],
|
|
_previousValues,
|
|
onCleanup,
|
|
) => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
|
|
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
|
|
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
if (!repositoryConnection.has_personal_access) {
|
|
branchNameValidation.value = {
|
|
is_valid: false,
|
|
message:
|
|
'Connect a matching Git connection in your user settings first.',
|
|
};
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
if (branchName.trim() === '') {
|
|
branchNameValidation.value = {
|
|
is_valid: false,
|
|
message: 'Branch name is required.',
|
|
};
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
const abortController = new AbortController();
|
|
const timeoutId = window.setTimeout(async () => {
|
|
isCheckingBranchName.value = true;
|
|
|
|
try {
|
|
const response = await fetch(
|
|
ProjectTicketBranchController.validate.url(
|
|
{
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
},
|
|
{
|
|
query: {
|
|
branch_name: branchName,
|
|
repository_connection_id:
|
|
repositoryConnectionId,
|
|
},
|
|
},
|
|
),
|
|
{
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
method: 'GET',
|
|
signal: abortController.signal,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Branch validation failed.');
|
|
}
|
|
|
|
branchNameValidation.value =
|
|
(await response.json()) as BranchNameValidationModel;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof DOMException &&
|
|
error.name === 'AbortError'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
branchNameValidation.value = {
|
|
is_valid: false,
|
|
message: `${appName} could not validate this branch name right now.`,
|
|
};
|
|
} finally {
|
|
if (!abortController.signal.aborted) {
|
|
isCheckingBranchName.value = false;
|
|
}
|
|
}
|
|
}, 300);
|
|
|
|
onCleanup(() => {
|
|
abortController.abort();
|
|
window.clearTimeout(timeoutId);
|
|
isCheckingBranchName.value = false;
|
|
});
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
const submitBranchCreation = (): void => {
|
|
if (
|
|
!canCreateBranches.value ||
|
|
!selectedBranchRepositoryHasPersonalAccess.value ||
|
|
branchNameValidation.value?.is_valid !== true
|
|
) {
|
|
return;
|
|
}
|
|
|
|
branchCreationForm.submit(
|
|
ProjectTicketBranchController.store({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
{
|
|
onSuccess: () => {
|
|
isCreateBranchDialogOpen.value = false;
|
|
},
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
};
|
|
|
|
const buildTicketPayload = (
|
|
acceptanceCriteria = form.acceptance_criteria,
|
|
): ProjectTicketUpdatePayload => ({
|
|
acceptance_criteria: normalizeAcceptanceCriteria(acceptanceCriteria),
|
|
assignee_user_id: form.assignee_user_id,
|
|
author_user_id: form.author_user_id,
|
|
blocked_comment: form.blocked_comment,
|
|
description: form.description,
|
|
field_values: form.field_values.map((fieldValue) => ({ ...fieldValue })),
|
|
is_blocked: form.is_blocked,
|
|
parent_ticket_id: form.parent_ticket_id,
|
|
story_points: form.story_points,
|
|
ticket_priority_id: form.ticket_priority_id,
|
|
ticket_type_id: form.ticket_type_id,
|
|
title: form.title,
|
|
});
|
|
|
|
const acceptanceCriteriaSignature = (
|
|
acceptanceCriteria: AcceptanceCriteriaModel,
|
|
): string =>
|
|
JSON.stringify(
|
|
normalizeAcceptanceCriteria(acceptanceCriteria).map((item) => ({
|
|
checked: item.checked,
|
|
indent: item.indent,
|
|
text: item.text,
|
|
})),
|
|
);
|
|
|
|
const clearAutosaveError = (field: TicketEditableField): void => {
|
|
const nextErrors = { ...autosaveErrors.value };
|
|
|
|
delete nextErrors[field];
|
|
autosaveErrors.value = nextErrors;
|
|
};
|
|
|
|
const extractAutosaveError = async (response: Response): Promise<string> => {
|
|
try {
|
|
const body = (await response.json()) as TicketAutosaveErrorResponse;
|
|
const firstValidationError = Object.values(body.errors ?? {})[0]?.[0];
|
|
|
|
return (
|
|
firstValidationError ??
|
|
body.message ??
|
|
'The ticket change could not be saved.'
|
|
);
|
|
} catch (_error) {
|
|
return 'The ticket change could not be saved.';
|
|
}
|
|
};
|
|
|
|
const runAutosaveQueue = async (): Promise<void> => {
|
|
if (
|
|
isAutosaveRequestActive.value ||
|
|
queuedAutosave.value === null ||
|
|
!props.canEditTicket
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const request = queuedAutosave.value;
|
|
const csrfToken = document
|
|
.querySelector('meta[name="csrf-token"]')
|
|
?.getAttribute('content');
|
|
|
|
queuedAutosave.value = null;
|
|
isAutosaveRequestActive.value = true;
|
|
savingField.value = request.field;
|
|
clearAutosaveError(request.field);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
ProjectTicketController.update.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(request.payload),
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(await extractAutosaveError(response));
|
|
}
|
|
|
|
const result = (await response.json()) as TicketAutosaveResponse;
|
|
|
|
if (
|
|
request.field === 'acceptance_criteria' &&
|
|
form.acceptance_criteria.every(
|
|
(criterion) => criterion.text.trim() !== '',
|
|
) &&
|
|
acceptanceCriteriaSignature(form.acceptance_criteria) ===
|
|
acceptanceCriteriaSignature(request.payload.acceptance_criteria)
|
|
) {
|
|
form.acceptance_criteria = normalizeAcceptanceCriteria(
|
|
result.acceptance_criteria,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
autosaveErrors.value = {
|
|
...autosaveErrors.value,
|
|
[request.field]:
|
|
error instanceof Error
|
|
? error.message
|
|
: 'The ticket change could not be saved.',
|
|
};
|
|
} finally {
|
|
isAutosaveRequestActive.value = false;
|
|
savingField.value = null;
|
|
|
|
if (queuedAutosave.value !== null) {
|
|
void runAutosaveQueue();
|
|
}
|
|
}
|
|
};
|
|
|
|
const requestTicketAutosave = (
|
|
field: TicketEditableField,
|
|
acceptanceCriteria = form.acceptance_criteria,
|
|
): void => {
|
|
if (!props.canEditTicket) {
|
|
return;
|
|
}
|
|
|
|
clearAutosaveError(field);
|
|
queuedAutosave.value = {
|
|
field,
|
|
payload: buildTicketPayload(acceptanceCriteria),
|
|
};
|
|
void runAutosaveQueue();
|
|
};
|
|
|
|
const saveStateFor = (
|
|
field: TicketEditableField,
|
|
): AcceptanceCriteriaSaveState => {
|
|
if (savingField.value === field) {
|
|
return 'saving';
|
|
}
|
|
|
|
if (autosaveErrors.value[field] !== undefined) {
|
|
return 'error';
|
|
}
|
|
|
|
return 'idle';
|
|
};
|
|
|
|
const errorFor = (field: TicketEditableField): string =>
|
|
autosaveErrors.value[field] ?? '';
|
|
|
|
const commitTextField = (
|
|
field: 'blocked_comment' | 'description' | 'story_points' | 'title',
|
|
): void => {
|
|
if (field === 'title') {
|
|
form.title = form.title.trim();
|
|
|
|
if (form.title === '') {
|
|
autosaveErrors.value = {
|
|
...autosaveErrors.value,
|
|
title: 'Title is required.',
|
|
};
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
requestTicketAutosave(field);
|
|
};
|
|
|
|
const commitSelectField = (field: TicketEditableField): void => {
|
|
activeSelectField.value = null;
|
|
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 => {
|
|
requestTicketAutosave('acceptance_criteria', commit.items);
|
|
};
|
|
|
|
const toggleBlocked = (): void => {
|
|
form.is_blocked = !form.is_blocked;
|
|
requestTicketAutosave('is_blocked');
|
|
};
|
|
|
|
const formatDateTime = (value: string): string => {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(new Date(value));
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
|
<Head :title="ticket.ticket_key" />
|
|
|
|
<div class="app-page max-w-none gap-5">
|
|
<header
|
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="min-w-0 flex-1 space-y-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Badge
|
|
variant="outline"
|
|
class="rounded px-2.5 py-1 font-semibold"
|
|
>
|
|
{{ ticket.ticket_key }}
|
|
</Badge>
|
|
<Badge variant="secondary" class="rounded">
|
|
{{ currentTicketType.name }}
|
|
</Badge>
|
|
<Badge
|
|
v-if="currentTicketStatus"
|
|
variant="outline"
|
|
class="rounded"
|
|
>
|
|
{{ currentTicketStatus.name }}
|
|
</Badge>
|
|
<Badge
|
|
v-if="form.is_blocked"
|
|
variant="destructive"
|
|
class="rounded"
|
|
>
|
|
Blocked
|
|
</Badge>
|
|
<Badge
|
|
v-if="!canEditTicket"
|
|
variant="outline"
|
|
class="rounded"
|
|
>
|
|
Read only
|
|
</Badge>
|
|
</div>
|
|
|
|
<InlineTextEditor
|
|
:value="form.title"
|
|
:editable="canEditTicket"
|
|
placeholder="Untitled ticket"
|
|
variant="title"
|
|
:save-state="saveStateFor('title')"
|
|
:error="errorFor('title')"
|
|
@update:value="form.title = String($event ?? '')"
|
|
@commit="commitTextField('title')"
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="overflow-x-auto">
|
|
<ProjectTabs
|
|
:active="activeProjectSection"
|
|
:can-manage-project="canManageProject"
|
|
:project="project"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="status" class="status-success">
|
|
{{ status }}
|
|
</div>
|
|
|
|
<div
|
|
class="grid items-start gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
|
>
|
|
<main class="min-w-0 space-y-4">
|
|
<section
|
|
class="border-outline-variant bg-card overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant bg-surface-container-low flex items-center gap-2 border-b px-4 py-2.5"
|
|
>
|
|
<AlignLeft class="text-primary size-4" />
|
|
<h2
|
|
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
|
|
>
|
|
Description
|
|
</h2>
|
|
</div>
|
|
<div class="px-4 py-4 sm:px-5 sm:py-5">
|
|
<InlineTextEditor
|
|
:value="form.description"
|
|
:editable="canEditTicket"
|
|
multiline
|
|
placeholder="Add a description with the relevant context and expected behavior."
|
|
:save-state="saveStateFor('description')"
|
|
:error="errorFor('description')"
|
|
@update:value="
|
|
form.description = String($event ?? '')
|
|
"
|
|
@commit="commitTextField('description')"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
v-if="form.is_blocked"
|
|
class="overflow-hidden rounded border border-amber-300 bg-amber-50/80 dark:border-amber-900/70 dark:bg-amber-950/25"
|
|
>
|
|
<div
|
|
class="flex items-start justify-between gap-4 px-4 py-3 sm:px-5"
|
|
>
|
|
<div class="flex min-w-0 flex-1 items-start gap-3">
|
|
<CircleAlert
|
|
class="mt-0.5 size-4 shrink-0 text-amber-700 dark:text-amber-300"
|
|
/>
|
|
<div class="min-w-0 flex-1 space-y-1">
|
|
<p
|
|
class="text-[11px] font-bold uppercase tracking-[0.12em] text-amber-900 dark:text-amber-100"
|
|
>
|
|
Blocked
|
|
</p>
|
|
<InlineTextEditor
|
|
:value="form.blocked_comment"
|
|
:editable="canEditTicket"
|
|
multiline
|
|
placeholder="Describe what currently blocks this ticket."
|
|
:save-state="
|
|
saveStateFor('blocked_comment')
|
|
"
|
|
:error="errorFor('blocked_comment')"
|
|
@update:value="
|
|
form.blocked_comment = String(
|
|
$event ?? '',
|
|
)
|
|
"
|
|
@commit="
|
|
commitTextField('blocked_comment')
|
|
"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
v-if="canEditTicket"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
class="shrink-0"
|
|
:disabled="
|
|
saveStateFor('is_blocked') === 'saving'
|
|
"
|
|
@click="toggleBlocked"
|
|
>
|
|
Unblock
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
class="border-outline-variant bg-card overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant bg-surface-container-low flex flex-wrap items-center justify-between gap-3 border-b px-4 py-2.5"
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<ListChecks class="text-primary size-4" />
|
|
<h2
|
|
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
|
|
>
|
|
Acceptance criteria
|
|
</h2>
|
|
</div>
|
|
<span class="text-muted-foreground text-xs">
|
|
{{ completedAcceptanceCriteriaCount }} /
|
|
{{ visibleAcceptanceCriteria.length }} complete
|
|
</span>
|
|
</div>
|
|
<div class="px-4 py-4 sm:px-5 sm:py-5">
|
|
<AcceptanceCriteriaEditor
|
|
v-model="form.acceptance_criteria"
|
|
:editable="canEditTicket"
|
|
:save-state="
|
|
saveStateFor('acceptance_criteria')
|
|
"
|
|
:error="errorFor('acceptance_criteria')"
|
|
@commit="commitAcceptanceCriteria"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<aside
|
|
class="border-outline-variant bg-card overflow-hidden rounded border xl:sticky xl:top-20"
|
|
>
|
|
<div
|
|
class="border-outline-variant bg-surface-container-low flex items-center gap-2 border-b px-4 py-2.5"
|
|
>
|
|
<SlidersHorizontal class="text-primary size-4" />
|
|
<h2
|
|
class="text-foreground text-[11px] font-bold uppercase tracking-[0.12em]"
|
|
>
|
|
Properties
|
|
</h2>
|
|
</div>
|
|
|
|
<div class="divide-outline-variant divide-y">
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Status</span
|
|
>
|
|
<div class="min-w-0">
|
|
<PropertySelectDropdown
|
|
v-model="selectedTicketStatusId"
|
|
:options="ticketStatusOptions"
|
|
:editable="canEditTicket"
|
|
:disabled="isStatusMoving"
|
|
label="Status"
|
|
:searchable="true"
|
|
@commit="moveTicketStatus"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="statusMoveError ? 'error' : 'idle'"
|
|
:error="statusMoveError"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Assignee</span
|
|
>
|
|
<div class="min-w-0">
|
|
<UserSelectDropdown
|
|
v-model="form.assignee_user_id"
|
|
:users="users"
|
|
:editable="canEditTicket"
|
|
label="Assignee"
|
|
empty-label="Unassigned"
|
|
@commit="
|
|
commitSelectField('assignee_user_id')
|
|
"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('assignee_user_id')"
|
|
:error="errorFor('assignee_user_id')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Type</span
|
|
>
|
|
<div class="min-w-0">
|
|
<PropertySelectDropdown
|
|
v-model="form.ticket_type_id"
|
|
:options="ticketTypeOptions"
|
|
:editable="canEditTicket"
|
|
label="Type"
|
|
@commit="
|
|
commitSelectField('ticket_type_id')
|
|
"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('ticket_type_id')"
|
|
:error="errorFor('ticket_type_id')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Priority</span
|
|
>
|
|
<div class="min-w-0">
|
|
<PropertySelectDropdown
|
|
v-model="form.ticket_priority_id"
|
|
:options="ticketPriorityOptions"
|
|
:editable="canEditTicket"
|
|
label="Priority"
|
|
:searchable="true"
|
|
@commit="
|
|
commitSelectField('ticket_priority_id')
|
|
"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('ticket_priority_id')"
|
|
:error="errorFor('ticket_priority_id')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="project.show_story_points"
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Points</span
|
|
>
|
|
<InlineTextEditor
|
|
:value="form.story_points"
|
|
:editable="canEditTicket"
|
|
input-type="number"
|
|
placeholder="Not estimated"
|
|
variant="property"
|
|
:save-state="saveStateFor('story_points')"
|
|
:error="errorFor('story_points')"
|
|
@update:value="form.story_points = $event"
|
|
@commit="commitTextField('story_points')"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="canViewAuthor"
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Author</span
|
|
>
|
|
<div class="min-w-0">
|
|
<UserSelectDropdown
|
|
v-model="form.author_user_id"
|
|
:users="users"
|
|
:editable="canEditAuthor"
|
|
label="Author"
|
|
empty-label="No author"
|
|
@commit="
|
|
commitSelectField('author_user_id')
|
|
"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('author_user_id')"
|
|
:error="errorFor('author_user_id')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Parent</span
|
|
>
|
|
<div class="min-w-0">
|
|
<select
|
|
v-if="
|
|
activeSelectField === 'parent_ticket_id'
|
|
"
|
|
v-model="form.parent_ticket_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="
|
|
commitSelectField('parent_ticket_id')
|
|
"
|
|
@blur="activeSelectField = null"
|
|
>
|
|
<option value="">No parent</option>
|
|
<option
|
|
v-for="parentTicketOption in parentTicketOptions"
|
|
:key="parentTicketOption.id"
|
|
:value="parentTicketOption.id"
|
|
>
|
|
{{ parentTicketOption.ticket_key }} —
|
|
{{ parentTicketOption.title }}
|
|
</option>
|
|
</select>
|
|
<button
|
|
v-else-if="canEditTicket"
|
|
type="button"
|
|
class="hover:text-primary focus-visible:ring-ring min-h-7 w-full truncate rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
@click="
|
|
activeSelectField = 'parent_ticket_id'
|
|
"
|
|
>
|
|
{{
|
|
currentParentTicketOption?.ticket_key ??
|
|
'No parent'
|
|
}}
|
|
</button>
|
|
<p
|
|
v-else
|
|
class="min-h-7 truncate text-sm font-medium"
|
|
>
|
|
{{
|
|
currentParentTicketOption?.ticket_key ??
|
|
'No parent'
|
|
}}
|
|
</p>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('parent_ticket_id')"
|
|
:error="errorFor('parent_ticket_id')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-for="(
|
|
ticketFieldDefinition, index
|
|
) in ticketFieldDefinitions"
|
|
:key="ticketFieldDefinition.id"
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs">{{
|
|
ticketFieldDefinition.label
|
|
}}</span>
|
|
<div
|
|
v-if="
|
|
ticketFieldDefinition.field_type === 'user'
|
|
"
|
|
class="min-w-0"
|
|
>
|
|
<UserSelectDropdown
|
|
v-model="
|
|
form.field_values[index].value_user_id
|
|
"
|
|
:users="users"
|
|
:editable="canEditTicket"
|
|
:label="ticketFieldDefinition.label"
|
|
empty-label="Unassigned"
|
|
@commit="
|
|
commitSelectField(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
/>
|
|
<InlineSaveStatus
|
|
:state="
|
|
saveStateFor(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
:error="
|
|
errorFor(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
/>
|
|
</div>
|
|
<InlineTextEditor
|
|
v-else
|
|
:value="form.field_values[index].value_number"
|
|
:editable="canEditTicket"
|
|
input-type="number"
|
|
placeholder="Not set"
|
|
variant="property"
|
|
:save-state="
|
|
saveStateFor(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
:error="
|
|
errorFor(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
@update:value="
|
|
form.field_values[index].value_number =
|
|
$event
|
|
"
|
|
@commit="
|
|
requestTicketAutosave(
|
|
`field-${ticketFieldDefinition.id}`,
|
|
)
|
|
"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="grid grid-cols-[5.75rem_minmax(0,1fr)] gap-3 px-4 py-3"
|
|
>
|
|
<span class="text-muted-foreground pt-1 text-xs"
|
|
>Blocked</span
|
|
>
|
|
<div>
|
|
<button
|
|
v-if="canEditTicket"
|
|
type="button"
|
|
class="hover:text-primary focus-visible:ring-ring min-h-7 rounded text-left text-sm font-medium outline-none focus-visible:ring-2"
|
|
@click="toggleBlocked"
|
|
>
|
|
{{ form.is_blocked ? 'Yes' : 'No' }}
|
|
</button>
|
|
<p v-else class="min-h-7 text-sm font-medium">
|
|
{{ form.is_blocked ? 'Yes' : 'No' }}
|
|
</p>
|
|
<InlineSaveStatus
|
|
:state="saveStateFor('is_blocked')"
|
|
:error="errorFor('is_blocked')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<Collapsible
|
|
v-slot="{ open }"
|
|
class="border-outline-variant bg-card overflow-hidden rounded border"
|
|
>
|
|
<CollapsibleTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<GitBranch
|
|
class="text-primary size-4 shrink-0"
|
|
/>
|
|
<div class="min-w-0">
|
|
<h2 class="text-sm font-semibold">
|
|
Development
|
|
</h2>
|
|
<p class="text-muted-foreground text-xs">
|
|
{{ branchCount }} branches ·
|
|
{{ pullRequestCount }} pull requests
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
class="text-muted-foreground size-4 shrink-0 transition-transform"
|
|
:class="open ? 'rotate-180' : ''"
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div
|
|
class="border-outline-variant grid gap-4 border-t px-4 py-4 sm:px-5 xl:grid-cols-2"
|
|
>
|
|
<section
|
|
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div>
|
|
<h3
|
|
class="flex items-center gap-2 text-sm font-semibold"
|
|
>
|
|
<GitBranch class="size-4" />
|
|
Branches
|
|
</h3>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-xs leading-5"
|
|
>
|
|
Branches whose names start with
|
|
{{ ticket.ticket_key }}.
|
|
</p>
|
|
</div>
|
|
<Dialog
|
|
v-model:open="isCreateBranchDialogOpen"
|
|
>
|
|
<Button
|
|
v-if="canCreateBranches"
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
@click="
|
|
isCreateBranchDialogOpen = true
|
|
"
|
|
>
|
|
<GitBranchPlus class="size-4" />
|
|
Create branch
|
|
</Button>
|
|
<DialogContent class="sm:max-w-lg">
|
|
<DialogHeader class="space-y-3">
|
|
<DialogTitle
|
|
>Create branch from
|
|
ticket</DialogTitle
|
|
>
|
|
<DialogDescription
|
|
>Pick a linked repository
|
|
and confirm the suggested
|
|
branch
|
|
name.</DialogDescription
|
|
>
|
|
</DialogHeader>
|
|
<form
|
|
class="grid gap-4"
|
|
@submit.prevent="
|
|
submitBranchCreation
|
|
"
|
|
>
|
|
<div class="grid gap-2">
|
|
<Label
|
|
for="repository_connection_id"
|
|
>Repository</Label
|
|
>
|
|
<select
|
|
id="repository_connection_id"
|
|
v-model="
|
|
branchCreationForm.repository_connection_id
|
|
"
|
|
:disabled="
|
|
branchCreationForm.processing
|
|
"
|
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] disabled:opacity-60"
|
|
>
|
|
<option
|
|
v-for="repositoryConnection in linkedRepositoryConnections"
|
|
:key="
|
|
repositoryConnection.id
|
|
"
|
|
:value="
|
|
String(
|
|
repositoryConnection.id,
|
|
)
|
|
"
|
|
>
|
|
{{
|
|
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
|
}}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:message="
|
|
branchCreationForm
|
|
.errors
|
|
.repository_connection_id
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-2">
|
|
<Label for="branch_name"
|
|
>Branch name</Label
|
|
>
|
|
<Input
|
|
id="branch_name"
|
|
v-model="
|
|
branchCreationForm.branch_name
|
|
"
|
|
:disabled="
|
|
branchCreationForm.processing ||
|
|
!selectedBranchRepositoryHasPersonalAccess
|
|
"
|
|
/>
|
|
<InputError
|
|
:message="
|
|
branchCreationForm
|
|
.errors
|
|
.branch_name
|
|
"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="rounded border px-4 py-3"
|
|
:class="
|
|
branchNameValidation?.is_valid
|
|
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
|
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
|
"
|
|
>
|
|
<div
|
|
class="flex items-start gap-3"
|
|
>
|
|
<CheckCircle2
|
|
v-if="
|
|
branchNameValidation?.is_valid
|
|
"
|
|
class="mt-0.5 size-4 shrink-0"
|
|
/>
|
|
<XCircle
|
|
v-else-if="
|
|
branchNameValidation !==
|
|
null
|
|
"
|
|
class="mt-0.5 size-4 shrink-0"
|
|
/>
|
|
<p class="text-sm">
|
|
{{
|
|
isCheckingBranchName
|
|
? 'Checking whether this branch name is valid and still available...'
|
|
: (branchNameValidation?.message ??
|
|
'Select a linked repository to validate the branch name.')
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="
|
|
!selectedBranchRepositoryHasPersonalAccess &&
|
|
selectedBranchRepositoryConnection !==
|
|
null
|
|
"
|
|
class="border-outline-variant rounded border border-dashed px-4 py-4"
|
|
>
|
|
<p
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
Connect a matching
|
|
personal Git account
|
|
before creating a
|
|
branch.
|
|
</p>
|
|
<Button
|
|
as-child
|
|
variant="outline"
|
|
size="sm"
|
|
class="mt-3"
|
|
><Link
|
|
:href="
|
|
editGitConnections()
|
|
"
|
|
>Open Git
|
|
connections</Link
|
|
></Button
|
|
>
|
|
</div>
|
|
<DialogFooter class="gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
@click="
|
|
isCreateBranchDialogOpen = false
|
|
"
|
|
>Cancel</Button
|
|
>
|
|
<Button
|
|
type="submit"
|
|
:disabled="
|
|
branchCreationForm.processing ||
|
|
isCheckingBranchName ||
|
|
branchNameValidation?.is_valid !==
|
|
true
|
|
"
|
|
>Create now</Button
|
|
>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
<div class="space-y-3 p-4">
|
|
<div
|
|
v-if="
|
|
visibleRepositoryBranches.length ===
|
|
0
|
|
"
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
No matching branches were found.
|
|
</div>
|
|
<div
|
|
v-for="repositoryBranch in visibleRepositoryBranches"
|
|
v-else
|
|
:key="
|
|
repositoryBranch
|
|
.repository_connection.id
|
|
"
|
|
class="border-outline-variant bg-background rounded border p-3"
|
|
>
|
|
<div
|
|
class="flex flex-wrap items-center gap-2"
|
|
>
|
|
<Badge variant="secondary">{{
|
|
repositoryBranch
|
|
.repository_connection
|
|
.provider_label
|
|
}}</Badge
|
|
><span
|
|
class="text-sm font-medium"
|
|
>{{
|
|
repositoryBranch
|
|
.repository_connection
|
|
.repository_full_name
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div
|
|
v-if="
|
|
!repositoryBranch
|
|
.repository_connection
|
|
.has_personal_access
|
|
"
|
|
class="mt-3 space-y-3"
|
|
>
|
|
<p
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
Connect a matching Git account
|
|
to inspect branches.
|
|
</p>
|
|
<Button
|
|
as-child
|
|
variant="outline"
|
|
size="sm"
|
|
><Link
|
|
:href="editGitConnections()"
|
|
>Open Git connections</Link
|
|
></Button
|
|
>
|
|
</div>
|
|
<div
|
|
v-else-if="repositoryBranch.error"
|
|
class="mt-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
>
|
|
{{ repositoryBranch.error }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="mt-3 flex flex-wrap gap-2"
|
|
>
|
|
<Badge
|
|
v-for="branch in repositoryBranch.branches"
|
|
:key="branch.name"
|
|
variant="outline"
|
|
>{{ branch.name }}</Badge
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant border-b px-4 py-3"
|
|
>
|
|
<h3
|
|
class="flex items-center gap-2 text-sm font-semibold"
|
|
>
|
|
<GitPullRequest class="size-4" /> Pull
|
|
requests
|
|
</h3>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-xs leading-5"
|
|
>
|
|
Detected when the source branch starts
|
|
with {{ ticket.ticket_key }}.
|
|
</p>
|
|
</div>
|
|
<div class="space-y-3 p-4">
|
|
<div
|
|
v-if="
|
|
visibleRepositoryPullRequests.length ===
|
|
0
|
|
"
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
No matching pull requests were found.
|
|
</div>
|
|
<div
|
|
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
|
v-else
|
|
:key="
|
|
repositoryPullRequest
|
|
.repository_connection.id
|
|
"
|
|
class="border-outline-variant bg-background rounded border p-3"
|
|
>
|
|
<div
|
|
class="flex flex-wrap items-center gap-2"
|
|
>
|
|
<Badge variant="secondary">{{
|
|
repositoryPullRequest
|
|
.repository_connection
|
|
.provider_label
|
|
}}</Badge
|
|
><span
|
|
class="text-sm font-medium"
|
|
>{{
|
|
repositoryPullRequest
|
|
.repository_connection
|
|
.repository_full_name
|
|
}}</span
|
|
>
|
|
</div>
|
|
<div
|
|
v-if="
|
|
!repositoryPullRequest
|
|
.repository_connection
|
|
.has_personal_access
|
|
"
|
|
class="mt-3 space-y-3"
|
|
>
|
|
<p
|
|
class="text-muted-foreground text-sm"
|
|
>
|
|
Connect a matching Git account
|
|
to inspect pull requests.
|
|
</p>
|
|
<Button
|
|
as-child
|
|
variant="outline"
|
|
size="sm"
|
|
><Link
|
|
:href="editGitConnections()"
|
|
>Open Git connections</Link
|
|
></Button
|
|
>
|
|
</div>
|
|
<div
|
|
v-else-if="
|
|
repositoryPullRequest.error
|
|
"
|
|
class="mt-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
>
|
|
{{ repositoryPullRequest.error }}
|
|
</div>
|
|
<div v-else class="mt-3 space-y-2">
|
|
<a
|
|
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
|
:key="pullRequest.html_url"
|
|
:href="pullRequest.html_url"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
class="border-outline-variant hover:border-primary/50 block rounded border px-3 py-2 transition"
|
|
>
|
|
<div
|
|
class="flex items-center gap-2"
|
|
>
|
|
<Badge variant="outline"
|
|
>#{{
|
|
pullRequest.number
|
|
}}</Badge
|
|
><span
|
|
class="text-sm font-medium"
|
|
>{{
|
|
pullRequest.title
|
|
}}</span
|
|
>
|
|
</div>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-xs"
|
|
>
|
|
{{
|
|
pullRequest.source_branch
|
|
}}
|
|
· {{ pullRequest.status }}
|
|
</p>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible
|
|
v-slot="{ open }"
|
|
class="border-outline-variant bg-card overflow-hidden rounded border"
|
|
>
|
|
<CollapsibleTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<Network class="text-primary size-4 shrink-0" />
|
|
<div class="min-w-0">
|
|
<h2 class="text-sm font-semibold">
|
|
Relationships
|
|
</h2>
|
|
<p class="text-muted-foreground text-xs">
|
|
{{ relationshipCount }} linked or
|
|
related tickets
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
class="text-muted-foreground size-4 shrink-0 transition-transform"
|
|
:class="open ? 'rotate-180' : ''"
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div
|
|
class="border-outline-variant grid gap-4 border-t px-4 py-4 sm:px-5 xl:grid-cols-2"
|
|
>
|
|
<section
|
|
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant border-b px-4 py-3"
|
|
>
|
|
<h3
|
|
class="flex items-center gap-2 text-sm font-semibold"
|
|
>
|
|
<Link2 class="size-4" /> Linked tickets
|
|
</h3>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-xs"
|
|
>
|
|
Project-defined cross-ticket
|
|
relationships.
|
|
</p>
|
|
</div>
|
|
<div class="space-y-3 p-4">
|
|
<Form
|
|
v-if="
|
|
canEditTicket &&
|
|
ticketLinkTypes.length > 0 &&
|
|
ticketLinkOptions.length > 0
|
|
"
|
|
v-bind="
|
|
ProjectTicketLinkController.store.form(
|
|
{
|
|
project: project.id,
|
|
ticket: ticket.id,
|
|
},
|
|
)
|
|
"
|
|
class="border-outline-variant grid gap-3 rounded border border-dashed p-3"
|
|
v-slot="{ errors, processing }"
|
|
>
|
|
<div class="grid gap-1.5">
|
|
<Label for="ticket_link_type_id"
|
|
>Link type</Label
|
|
><select
|
|
id="ticket_link_type_id"
|
|
name="ticket_link_type_id"
|
|
class="border-input bg-background rounded border px-3 py-2 text-sm"
|
|
>
|
|
<option
|
|
v-for="ticketLinkType in ticketLinkTypes"
|
|
:key="ticketLinkType.id"
|
|
:value="ticketLinkType.id"
|
|
>
|
|
{{ ticketLinkType.label }}
|
|
</option></select
|
|
><InputError
|
|
:message="
|
|
errors.ticket_link_type_id
|
|
"
|
|
/>
|
|
</div>
|
|
<div class="grid gap-1.5">
|
|
<Label for="linked_ticket_id"
|
|
>Ticket</Label
|
|
><select
|
|
id="linked_ticket_id"
|
|
name="linked_ticket_id"
|
|
class="border-input bg-background rounded border px-3 py-2 text-sm"
|
|
>
|
|
<option
|
|
v-for="ticketLinkOption in ticketLinkOptions"
|
|
:key="ticketLinkOption.id"
|
|
:value="ticketLinkOption.id"
|
|
>
|
|
{{
|
|
ticketLinkOption.ticket_key
|
|
}}
|
|
—
|
|
{{ ticketLinkOption.title }}
|
|
</option></select
|
|
><InputError
|
|
:message="
|
|
errors.linked_ticket_id
|
|
"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
:disabled="processing"
|
|
>Save link</Button
|
|
>
|
|
</Form>
|
|
<div
|
|
v-if="linkedTickets.length === 0"
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
No ticket links are configured yet.
|
|
</div>
|
|
<div
|
|
v-for="linkedTicket in linkedTickets"
|
|
v-else
|
|
:key="linkedTicket.id"
|
|
class="border-outline-variant bg-background rounded border p-3"
|
|
>
|
|
<div
|
|
class="flex flex-wrap items-center gap-2"
|
|
>
|
|
<Badge variant="secondary">{{
|
|
linkedTicket.ticket_link_type
|
|
.label
|
|
}}</Badge
|
|
><Link
|
|
:href="
|
|
showTicket({
|
|
project: project.id,
|
|
ticket: linkedTicket
|
|
.linked_ticket.id,
|
|
})
|
|
"
|
|
class="hover:text-primary text-sm font-semibold"
|
|
>{{
|
|
linkedTicket.linked_ticket
|
|
.ticket_key
|
|
}}</Link
|
|
>
|
|
</div>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-sm"
|
|
>
|
|
{{
|
|
linkedTicket.linked_ticket.title
|
|
}}
|
|
</p>
|
|
<Form
|
|
v-if="canEditTicket"
|
|
v-bind="
|
|
ProjectTicketLinkController.destroy.form(
|
|
{
|
|
project: project.id,
|
|
ticket: ticket.id,
|
|
ticketLink:
|
|
linkedTicket.id,
|
|
},
|
|
)
|
|
"
|
|
class="mt-2 flex justify-end"
|
|
v-slot="{ processing: deleting }"
|
|
><Button
|
|
type="submit"
|
|
variant="destructive"
|
|
size="sm"
|
|
:disabled="deleting"
|
|
>Remove</Button
|
|
></Form
|
|
>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
class="border-outline-variant bg-surface-container-low overflow-hidden rounded border"
|
|
>
|
|
<div
|
|
class="border-outline-variant border-b px-4 py-3"
|
|
>
|
|
<h3 class="text-sm font-semibold">
|
|
Ticket hierarchy
|
|
</h3>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-xs"
|
|
>
|
|
Parent and child relationships for this
|
|
ticket.
|
|
</p>
|
|
</div>
|
|
<div class="p-4">
|
|
<div v-if="ticket.ticket_type?.is_parent">
|
|
<div
|
|
v-if="childTicketTree.length === 0"
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
No child tickets are assigned yet.
|
|
</div>
|
|
<TicketTreeList
|
|
v-else
|
|
:nodes="childTicketTree"
|
|
:project-id="project.id"
|
|
/>
|
|
</div>
|
|
<Link
|
|
v-else-if="currentParentTicket !== null"
|
|
:href="
|
|
showTicket({
|
|
project: project.id,
|
|
ticket: currentParentTicket.id,
|
|
})
|
|
"
|
|
class="border-outline-variant hover:border-primary/50 bg-background block rounded border p-3 transition"
|
|
><p class="text-sm font-semibold">
|
|
{{ currentParentTicket.ticket_key }}
|
|
</p>
|
|
<p
|
|
class="text-muted-foreground mt-1 text-sm"
|
|
>
|
|
{{ currentParentTicket.title }}
|
|
</p></Link
|
|
>
|
|
<div
|
|
v-else
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
This ticket has no parent or child
|
|
tickets.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<Collapsible
|
|
v-slot="{ open }"
|
|
class="border-outline-variant bg-card overflow-hidden rounded border"
|
|
>
|
|
<CollapsibleTrigger as-child>
|
|
<button
|
|
type="button"
|
|
class="hover:bg-primary/[0.025] focus-visible:ring-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:px-5"
|
|
>
|
|
<div class="flex min-w-0 items-center gap-3">
|
|
<Clock3 class="text-primary size-4 shrink-0" />
|
|
<div class="min-w-0">
|
|
<h2 class="text-sm font-semibold">
|
|
Worklog
|
|
</h2>
|
|
<p class="text-muted-foreground text-xs">
|
|
{{ worklogCount }} recorded changes
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
class="text-muted-foreground size-4 shrink-0 transition-transform"
|
|
:class="open ? 'rotate-180' : ''"
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div
|
|
class="border-outline-variant border-t px-4 py-4 sm:px-5"
|
|
>
|
|
<div
|
|
v-if="worklogCount === 0"
|
|
class="border-outline-variant text-muted-foreground rounded border border-dashed px-4 py-6 text-sm"
|
|
>
|
|
No worklog entries exist yet.
|
|
</div>
|
|
<ol
|
|
v-else
|
|
class="border-outline-variant relative space-y-0 border-l pl-5"
|
|
>
|
|
<li
|
|
v-for="worklogEntry in ticket.worklog_entries"
|
|
:key="worklogEntry.id"
|
|
class="relative pb-5 last:pb-0"
|
|
>
|
|
<span
|
|
class="border-outline-variant bg-background absolute -left-[1.45rem] top-1.5 size-2.5 rounded-full border"
|
|
/>
|
|
<div
|
|
class="flex flex-wrap items-center gap-2"
|
|
>
|
|
<Badge variant="outline">{{
|
|
worklogEntry.entry_type
|
|
}}</Badge
|
|
><span class="text-sm font-medium">{{
|
|
worklogEntry.actor?.name ?? 'System'
|
|
}}</span
|
|
><span
|
|
class="text-muted-foreground text-xs"
|
|
>{{
|
|
formatDateTime(
|
|
worklogEntry.created_at,
|
|
)
|
|
}}</span
|
|
>
|
|
</div>
|
|
<p class="mt-2 text-sm">
|
|
{{ worklogEntry.summary }}
|
|
</p>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
</template>
|