feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:17:10 +02:00
parent 850ae337ca
commit f4a8bdd134
27 changed files with 1793 additions and 329 deletions
+90 -23
View File
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
import {
Form,
useForm,
useRemember,
Head,
Link,
usePage,
} from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { computed, ref, toRef, unref, watch } from 'vue';
import {
acceptanceCriteriaToEditorState,
buildAcceptanceCriteria,
@@ -14,6 +21,10 @@ import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectT
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -45,6 +56,7 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const { t } = useLocalization();
const page = usePage();
const appName = String(page.props.name);
@@ -122,8 +134,14 @@ const breadcrumbs: BreadcrumbItem[] = [
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
props.ticket.acceptance_criteria,
);
const acceptanceCriteriaChecklist = ref(
acceptanceCriteriaEditorState.checklist,
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: acceptanceCriteriaEditorState.checklist },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const savedAcceptanceCriteria = ref(
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
@@ -131,7 +149,7 @@ const savedAcceptanceCriteria = ref(
const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref('');
const form = useForm({
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
@@ -170,6 +188,12 @@ const hasUnsavedAcceptanceCriteriaDraft = computed(
formatChecklistLines(savedAcceptanceCriteria.value),
);
const protection = useFormProtection(form, {
isDirty: () =>
props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
});
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
@@ -187,10 +211,20 @@ const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>({
}>(`${draftKey}:branch`, {
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const branchProtection = useFormProtection(branchCreationForm, {
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
});
const updateBranchDialogOpen = (open: boolean): void => {
if (!open && !branchProtection.confirmLeave()) {
return;
}
isCreateBranchDialogOpen.value = open;
};
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
@@ -207,6 +241,9 @@ const selectedBranchRepositoryHasPersonalAccess = computed(
);
const initializeBranchCreationForm = (): void => {
if (branchCreationForm.isDirty) {
return;
}
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
@@ -348,7 +385,7 @@ const submitBranchCreation = (): void => {
return;
}
branchCreationForm.submit(
branchProtection.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
@@ -482,13 +519,21 @@ const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
form.submit(
protection.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
onSuccess: () => {
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
props.ticket.acceptance_criteria,
);
acceptanceCriteriaChecklist.value = formatChecklistLines(
savedAcceptanceCriteria.value,
);
},
},
);
};
@@ -574,16 +619,25 @@ const formatDateTime = (value: string): string => {
<CardContent>
<form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
:disabled="
!canEditTicket || form.processing
"
/>
<InputError :message="form.errors.title" />
<InputError
id="title-error"
:message="form.errors.title"
/>
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
@@ -995,7 +1049,10 @@ const formatDateTime = (value: string): string => {
</CardDescription>
</div>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Dialog
:open="isCreateBranchDialogOpen"
@update:open="updateBranchDialogOpen"
>
<Button
v-if="canCreateBranches"
type="button"
@@ -1023,6 +1080,12 @@ const formatDateTime = (value: string): string => {
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<FormFeedback
:state="
branchProtection.state.value
"
@retry="submitBranchCreation"
/>
<div class="grid gap-2">
<Label
for="repository_connection_id"
@@ -1153,7 +1216,9 @@ const formatDateTime = (value: string): string => {
type="button"
variant="secondary"
@click="
isCreateBranchDialogOpen = false
updateBranchDialogOpen(
false,
)
"
>
Cancel
@@ -1177,13 +1242,14 @@ const formatDateTime = (value: string): string => {
</CardHeader>
<CardContent class="space-y-4">
<div
<FeedbackState
v-if="visibleRepositoryBranches.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching branches were found across the
linked repositories you can access.
</div>
state="empty"
:title="t('tickets.noBranches')"
:description="
t('tickets.noBranchesDescription')
"
/>
<div v-else class="space-y-3">
<div
@@ -1434,15 +1500,16 @@ const formatDateTime = (value: string): string => {
</CardHeader>
<CardContent class="space-y-4">
<div
<FeedbackState
v-if="
visibleRepositoryPullRequests.length === 0
"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching pull requests were found across the
linked repositories you can access.
</div>
state="empty"
:title="t('tickets.noPullRequests')"
:description="
t('tickets.noPullRequestsDescription')
"
/>
<div v-else class="space-y-3">
<div
+34 -8
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -62,9 +64,17 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const acceptanceCriteriaChecklist = ref('');
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
{ checklist: '' },
`${draftKey}:checklist`,
);
const acceptanceCriteriaChecklist = toRef(
unref(acceptanceCriteriaDraft),
'checklist',
);
const form = useForm({
const form = useForm(draftKey, {
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
),
@@ -80,6 +90,10 @@ const form = useForm({
title: '',
});
const protection = useFormProtection(form, {
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
});
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
@@ -99,9 +113,12 @@ const submit = (): void => {
acceptanceCriteriaChecklist.value,
);
form.submit(ProjectTicketController.store({ project: props.project.id }), {
preserveScroll: true,
});
protection.submit(
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
},
);
};
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
@@ -165,17 +182,26 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<CardContent>
<form class="grid gap-6" @submit.prevent="submit">
<FormFeedback
:state="protection.state.value"
@retry="submit"
/>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
required
autofocus
:disabled="form.processing"
placeholder="Implement repository sync"
/>
<InputError :message="form.errors.title" />
<InputError
id="title-error"
:message="form.errors.title"
/>
</div>
<div