417 lines
14 KiB
Vue
417 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
|
import {
|
|
CheckCircle2,
|
|
CircleAlert,
|
|
GitBranchPlus,
|
|
LoaderCircle,
|
|
} from 'lucide-vue-next';
|
|
import { computed, ref, watch } from 'vue';
|
|
import {
|
|
store,
|
|
validate as validateBranch,
|
|
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
|
import FormFeedback from '@/components/FormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
|
import type {
|
|
TicketBranchCreateDialogProps,
|
|
TicketBranchFormData,
|
|
} from '@/types/ticket-detail';
|
|
|
|
const props = defineProps<TicketBranchCreateDialogProps>();
|
|
const { t } = useLocalization();
|
|
const appName = String(usePage().props.name);
|
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
|
|
const isOpen = ref(false);
|
|
const isCheckingBranchName = ref(false);
|
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
|
|
|
const defaultRepositoryConnectionId = computed(() =>
|
|
String(
|
|
props.repositoryConnections.find(
|
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
|
)?.id ??
|
|
props.repositoryConnections[0]?.id ??
|
|
'',
|
|
),
|
|
);
|
|
|
|
const form = useForm<TicketBranchFormData>(draftKey, {
|
|
branch_name: props.suggestedBranchName,
|
|
repository_connection_id: defaultRepositoryConnectionId.value,
|
|
});
|
|
const protection = useFormProtection(form, {
|
|
isDirty: () => isOpen.value && form.isDirty,
|
|
});
|
|
|
|
const selectedRepositoryConnection = computed(
|
|
() =>
|
|
props.repositoryConnections.find(
|
|
(repositoryConnection) =>
|
|
String(repositoryConnection.id) ===
|
|
form.repository_connection_id,
|
|
) ?? null,
|
|
);
|
|
const selectedRepositoryHasPersonalAccess = computed(
|
|
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
|
|
);
|
|
const validationStateClasses = computed(() => {
|
|
if (isCheckingBranchName.value) {
|
|
return 'border-primary/30 bg-primary/5 text-foreground';
|
|
}
|
|
|
|
if (branchNameValidation.value?.is_valid) {
|
|
return 'border-success/40 bg-success/10 text-success-foreground';
|
|
}
|
|
|
|
if (branchNameValidation.value !== null) {
|
|
return 'border-warning/40 bg-warning/10 text-warning-foreground';
|
|
}
|
|
|
|
return 'border-border bg-muted/30 text-muted-foreground';
|
|
});
|
|
|
|
function updateOpen(open: boolean): void {
|
|
if (!open && !protection.confirmLeave()) {
|
|
return;
|
|
}
|
|
|
|
isOpen.value = open;
|
|
}
|
|
|
|
function initializeForm(): void {
|
|
if (form.isDirty) {
|
|
return;
|
|
}
|
|
|
|
form.clearErrors();
|
|
protection.clearFeedback();
|
|
form.branch_name = props.suggestedBranchName;
|
|
form.repository_connection_id = defaultRepositoryConnectionId.value;
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
}
|
|
|
|
watch(isOpen, (open) => {
|
|
if (!open) {
|
|
form.clearErrors();
|
|
protection.clearFeedback();
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
initializeForm();
|
|
});
|
|
|
|
watch(
|
|
[isOpen, () => form.repository_connection_id, () => form.branch_name],
|
|
(
|
|
[open, repositoryConnectionId, branchName],
|
|
_previousValues,
|
|
onCleanup,
|
|
) => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
const repositoryConnection = selectedRepositoryConnection.value;
|
|
|
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
if (!repositoryConnection.has_personal_access) {
|
|
branchNameValidation.value = {
|
|
is_valid: false,
|
|
message: t('ticketDetail.connectGitFirst'),
|
|
};
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
if (branchName.trim() === '') {
|
|
branchNameValidation.value = {
|
|
is_valid: false,
|
|
message: t('ticketDetail.branchRequired'),
|
|
};
|
|
isCheckingBranchName.value = false;
|
|
|
|
return;
|
|
}
|
|
|
|
branchNameValidation.value = null;
|
|
isCheckingBranchName.value = true;
|
|
|
|
const abortController = new AbortController();
|
|
const timeoutId = window.setTimeout(async () => {
|
|
try {
|
|
const response = await fetch(
|
|
validateBranch.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: t('ticketDetail.branchValidationFailed', {
|
|
app: appName,
|
|
}),
|
|
};
|
|
} finally {
|
|
if (!abortController.signal.aborted) {
|
|
isCheckingBranchName.value = false;
|
|
}
|
|
}
|
|
}, 300);
|
|
|
|
onCleanup(() => {
|
|
abortController.abort();
|
|
window.clearTimeout(timeoutId);
|
|
isCheckingBranchName.value = false;
|
|
});
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
function submit(): void {
|
|
if (
|
|
!selectedRepositoryHasPersonalAccess.value ||
|
|
branchNameValidation.value?.is_valid !== true
|
|
) {
|
|
return;
|
|
}
|
|
|
|
protection.submit(
|
|
store({
|
|
project: props.project.id,
|
|
ticket: props.ticket.id,
|
|
}),
|
|
{
|
|
onSuccess: () => {
|
|
isOpen.value = false;
|
|
},
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog :open="isOpen" @update:open="updateOpen">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
class="w-full gap-2 sm:w-auto"
|
|
@click="isOpen = true"
|
|
>
|
|
<GitBranchPlus class="size-4" aria-hidden="true" />
|
|
{{ t('ticketDetail.createBranch') }}
|
|
</Button>
|
|
|
|
<DialogContent class="sm:max-w-lg">
|
|
<DialogHeader class="space-y-3">
|
|
<DialogTitle>{{
|
|
t('ticketDetail.createBranchTitle')
|
|
}}</DialogTitle>
|
|
<DialogDescription>{{
|
|
t('ticketDetail.createBranchDescription')
|
|
}}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form class="grid gap-4" @submit.prevent="submit">
|
|
<FormFeedback
|
|
:state="protection.state.value"
|
|
:title="
|
|
protection.state.value === 'loading'
|
|
? t('ticketDetail.creatingBranch')
|
|
: undefined
|
|
"
|
|
@retry="submit"
|
|
/>
|
|
|
|
<fieldset
|
|
:disabled="form.processing"
|
|
class="grid min-w-0 gap-4"
|
|
>
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="repository_connection_id">{{
|
|
t('ticketDetail.repository')
|
|
}}</Label>
|
|
<select
|
|
id="repository_connection_id"
|
|
v-model="form.repository_connection_id"
|
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
<option
|
|
v-for="repositoryConnection in repositoryConnections"
|
|
:key="repositoryConnection.id"
|
|
:value="String(repositoryConnection.id)"
|
|
>
|
|
{{ repositoryConnection.repository_full_name
|
|
}}{{
|
|
repositoryConnection.has_personal_access
|
|
? ''
|
|
: ` — ${t('ticketDetail.personalAccessRequired')}`
|
|
}}
|
|
</option>
|
|
</select>
|
|
<InputError
|
|
:message="form.errors.repository_connection_id"
|
|
/>
|
|
</div>
|
|
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="branch_name">{{
|
|
t('ticketDetail.branchName')
|
|
}}</Label>
|
|
<Input
|
|
id="branch_name"
|
|
v-model="form.branch_name"
|
|
:disabled="!selectedRepositoryHasPersonalAccess"
|
|
autocomplete="off"
|
|
/>
|
|
<InputError :message="form.errors.branch_name" />
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div
|
|
role="status"
|
|
aria-live="polite"
|
|
:aria-busy="isCheckingBranchName || undefined"
|
|
class="rounded-lg border px-4 py-3 transition-colors"
|
|
:class="validationStateClasses"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<LoaderCircle
|
|
v-if="isCheckingBranchName"
|
|
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
|
aria-hidden="true"
|
|
/>
|
|
<CheckCircle2
|
|
v-else-if="branchNameValidation?.is_valid"
|
|
class="mt-0.5 size-4 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
<CircleAlert
|
|
v-else-if="branchNameValidation !== null"
|
|
class="mt-0.5 size-4 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
<p class="text-sm">
|
|
{{
|
|
isCheckingBranchName
|
|
? t('ticketDetail.checkingBranch')
|
|
: (branchNameValidation?.message ??
|
|
t('ticketDetail.selectRepository'))
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="
|
|
!selectedRepositoryHasPersonalAccess &&
|
|
selectedRepositoryConnection !== null
|
|
"
|
|
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
|
|
>
|
|
<div class="grid gap-1">
|
|
<p class="font-semibold">
|
|
{{ t('ticketDetail.gitAccessRequired') }}
|
|
</p>
|
|
<p class="text-muted-foreground text-sm">
|
|
{{ t('ticketDetail.branchAccess') }}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
as-child
|
|
variant="outline"
|
|
size="sm"
|
|
class="justify-self-start"
|
|
>
|
|
<Link :href="editGitConnections()">{{
|
|
t('ticketDetail.openGitConnections')
|
|
}}</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<DialogFooter class="gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
:disabled="form.processing"
|
|
@click="updateOpen(false)"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
:disabled="
|
|
isCheckingBranchName ||
|
|
branchNameValidation?.is_valid !== true
|
|
"
|
|
:loading="form.processing"
|
|
:loading-label="t('ticketDetail.creatingBranch')"
|
|
>
|
|
{{ t('ticketDetail.createNow') }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|