495 lines
18 KiB
Vue
495 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
|
import { computed, ref, watch } from 'vue';
|
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
|
import {
|
|
frostedAsideCardClass,
|
|
inputClass,
|
|
nativeSelectClass,
|
|
} from '@/lib/ticket-styles';
|
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
|
import type { TicketRepositoryBranchModel } from '@/types/models/ticket-repository-branch-model';
|
|
|
|
const props = defineProps<{
|
|
canEditTicket: boolean;
|
|
projectId: number;
|
|
repositoryBranches: TicketRepositoryBranchModel[];
|
|
suggestedBranchName: string;
|
|
ticketId: number;
|
|
}>();
|
|
|
|
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 linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
|
props.repositoryBranches.map(
|
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
|
),
|
|
);
|
|
|
|
const canCreateBranches = computed(
|
|
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
|
);
|
|
|
|
const defaultBranchRepositoryConnectionId = computed(() =>
|
|
String(
|
|
linkedRepositoryConnections.value.find(
|
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
|
)?.id ??
|
|
linkedRepositoryConnections.value[0]?.id ??
|
|
'',
|
|
),
|
|
);
|
|
|
|
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.projectId,
|
|
ticket: props.ticketId,
|
|
},
|
|
{
|
|
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.projectId,
|
|
ticket: props.ticketId,
|
|
}),
|
|
{
|
|
onSuccess: () => {
|
|
isCreateBranchDialogOpen.value = false;
|
|
},
|
|
preserveScroll: true,
|
|
},
|
|
);
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Card :class="frostedAsideCardClass">
|
|
<CardHeader
|
|
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="space-y-1.5">
|
|
<CardTitle>Branches</CardTitle>
|
|
<CardDescription>
|
|
Matching branches are discovered from linked repositories
|
|
when the branch name starts with this ticket key.
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<Dialog v-model:open="isCreateBranchDialogOpen">
|
|
<Button
|
|
v-if="canCreateBranches"
|
|
type="button"
|
|
variant="outline"
|
|
class="gap-2"
|
|
@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, confirm the suggested
|
|
branch name, and create it directly in Gitea.
|
|
</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="nativeSelectClass"
|
|
>
|
|
<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
|
|
"
|
|
:class="inputClass"
|
|
/>
|
|
<InputError
|
|
:message="branchCreationForm.errors.branch_name"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="rounded-xl 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-border rounded-xl border border-dashed px-4 py-4"
|
|
>
|
|
<p class="text-muted-foreground text-sm">
|
|
You can only create branches in repositories
|
|
that match one of your personal Git connections.
|
|
</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>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-4">
|
|
<div
|
|
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>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="repositoryBranch in visibleRepositoryBranches"
|
|
:key="repositoryBranch.repository_connection.id"
|
|
class="border-border rounded-xl border px-4 py-4"
|
|
>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Badge variant="secondary">
|
|
{{
|
|
repositoryBranch.repository_connection
|
|
.provider_label
|
|
}}
|
|
</Badge>
|
|
<span class="font-medium">
|
|
{{
|
|
repositoryBranch.repository_connection
|
|
.repository_full_name
|
|
}}
|
|
</span>
|
|
</div>
|
|
|
|
<p class="text-muted-foreground mt-2 text-sm">
|
|
{{ repositoryBranch.repository_connection.base_url }}
|
|
</p>
|
|
|
|
<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 in your user settings
|
|
to inspect branches for this repository.
|
|
</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-xl border border-amber-200 bg-amber-50 px-4 py-3 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"
|
|
class="px-3 py-1"
|
|
>
|
|
{{ branch.name }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</template>
|