feat(KAN-20): unify dialogs feedback and protect ticket drafts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user