KAN-35 ticket changes
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
<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>
|
||||
@@ -1,273 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
|
||||
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||
|
||||
defineProps<TicketDetailGitProps>();
|
||||
|
||||
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 FormFeedback from '@/components/FormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type {
|
||||
TicketDetailGitProps,
|
||||
TicketBranchFormData,
|
||||
} from '@/types/ticket-detail';
|
||||
const props = defineProps<TicketDetailGitProps>();
|
||||
const { t } = useLocalization();
|
||||
const appName = String(usePage().props.name);
|
||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||
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 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<TicketBranchFormData>(`${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 =>
|
||||
linkedRepositoryConnections.value.find(
|
||||
(repositoryConnection) =>
|
||||
String(repositoryConnection.id) ===
|
||||
branchCreationForm.repository_connection_id,
|
||||
) ?? null,
|
||||
);
|
||||
|
||||
const selectedBranchRepositoryHasPersonalAccess = computed(
|
||||
() =>
|
||||
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
||||
);
|
||||
|
||||
const initializeBranchCreationForm = (): void => {
|
||||
if (branchCreationForm.isDirty) {
|
||||
return;
|
||||
}
|
||||
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: t('ticketDetail.connectGitFirst'),
|
||||
};
|
||||
isCheckingBranchName.value = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (branchName.trim() === '') {
|
||||
branchNameValidation.value = {
|
||||
is_valid: false,
|
||||
message: t('ticketDetail.branchRequired'),
|
||||
};
|
||||
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: 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 },
|
||||
);
|
||||
|
||||
const submitBranchCreation = (): void => {
|
||||
if (
|
||||
!canCreateBranches.value ||
|
||||
!selectedBranchRepositoryHasPersonalAccess.value ||
|
||||
branchNameValidation.value?.is_valid !== true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
branchProtection.submit(
|
||||
ProjectTicketBranchController.store({
|
||||
project: props.project.id,
|
||||
ticket: props.ticket.id,
|
||||
}),
|
||||
{
|
||||
onSuccess: () => {
|
||||
isCreateBranchDialogOpen.value = false;
|
||||
},
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
|
||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||
{{ t('ticketDetail.git') }}
|
||||
</h2>
|
||||
|
||||
<div class="grid min-w-0 gap-6">
|
||||
<Card class="h-fit">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.branchesDescription')
|
||||
}}</CardDescription>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
:open="isCreateBranchDialogOpen"
|
||||
@update:open="updateBranchDialogOpen"
|
||||
>
|
||||
<Button
|
||||
v-if="canCreateBranches"
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="isCreateBranchDialogOpen = true"
|
||||
>
|
||||
<GitBranchPlus class="size-4" />{{
|
||||
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="submitBranchCreation"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="branchProtection.state.value"
|
||||
@retry="submitBranchCreation"
|
||||
/>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="repository_connection_id">{{
|
||||
t('ticketDetail.repository')
|
||||
}}</Label>
|
||||
<select
|
||||
id="repository_connection_id"
|
||||
v-model="
|
||||
branchCreationForm.repository_connection_id
|
||||
"
|
||||
:disabled="
|
||||
branchCreationForm.processing
|
||||
"
|
||||
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 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 min-w-0 gap-2">
|
||||
<Label for="branch_name">{{
|
||||
t('ticketDetail.branchName')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="branch_name"
|
||||
v-model="branchCreationForm.branch_name"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
!selectedBranchRepositoryHasPersonalAccess
|
||||
"
|
||||
/>
|
||||
<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
|
||||
? t(
|
||||
'ticketDetail.checkingBranch',
|
||||
)
|
||||
: (branchNameValidation?.message ??
|
||||
t(
|
||||
'ticketDetail.selectRepository',
|
||||
))
|
||||
}}
|
||||
</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">
|
||||
{{ t('ticketDetail.branchAccess') }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="updateBranchDialogOpen(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="
|
||||
branchCreationForm.processing ||
|
||||
isCheckingBranchName ||
|
||||
branchNameValidation?.is_valid !==
|
||||
true
|
||||
"
|
||||
>{{
|
||||
t('ticketDetail.createNow')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<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">
|
||||
{{ t('ticketDetail.inspectBranches') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</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>
|
||||
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
||||
<CardDescription>{{
|
||||
t('ticketDetail.pullRequestsDescription')
|
||||
}}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<FeedbackState
|
||||
v-if="visibleRepositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
||||
:key="
|
||||
repositoryPullRequest.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">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-sm">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.base_url
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
class="mt-3 space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('ticketDetail.inspectPullRequests') }}
|
||||
</p>
|
||||
|
||||
<Button as-child variant="outline" size="sm">
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="repositoryPullRequest.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"
|
||||
>
|
||||
{{ repositoryPullRequest.error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
#{{ pullRequest.number }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ pullRequest.status }}
|
||||
</Badge>
|
||||
<a
|
||||
:href="pullRequest.html_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-foreground hover:text-primary font-medium transition"
|
||||
>
|
||||
{{ pullRequest.title }}
|
||||
</a>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground mt-2 text-sm"
|
||||
>
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TicketGitBranches
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:can-edit-ticket="canEditTicket"
|
||||
:repository-branches="repositoryBranches"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
<TicketGitPullRequests
|
||||
:repository-pull-requests="repositoryPullRequests"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitBranch } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitBranchesProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryBranches.filter(
|
||||
(repositoryBranch) =>
|
||||
repositoryBranch.branches.length > 0 ||
|
||||
repositoryBranch.error !== null ||
|
||||
!repositoryBranch.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||
props.repositoryBranches.map(
|
||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||
),
|
||||
);
|
||||
const canCreateBranch = computed(
|
||||
() => props.canEditTicket && repositoryConnections.value.length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitBranch
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.branches') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.branchesDescription') }}</CardDescription
|
||||
>
|
||||
</div>
|
||||
|
||||
<TicketBranchCreateDialog
|
||||
v-if="canCreateBranch"
|
||||
:ticket="ticket"
|
||||
:project="project"
|
||||
:repository-connections="repositoryConnections"
|
||||
:suggested-branch-name="suggestedBranchName"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryBranches.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noBranches')"
|
||||
:description="t('tickets.noBranchesDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryBranch in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryBranch.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryBranch.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryBranch.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryBranch.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{ repositoryBranch.repository_connection.base_url }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryBranch.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectBranches')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryBranch.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.branchesUnavailable')"
|
||||
:description="repositoryBranch.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<li
|
||||
v-for="branch in repositoryBranch.branches"
|
||||
:key="branch.name"
|
||||
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<GitBranch
|
||||
class="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="wrap-anywhere font-mono text-xs">{{
|
||||
branch.name
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
|
||||
|
||||
const props = defineProps<TicketGitPullRequestsProps>();
|
||||
const { t } = useLocalization();
|
||||
|
||||
const visibleRepositories = computed(() =>
|
||||
props.repositoryPullRequests.filter(
|
||||
(repositoryPullRequest) =>
|
||||
repositoryPullRequest.pull_requests.length > 0 ||
|
||||
repositoryPullRequest.error !== null ||
|
||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
<GitPullRequest
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('ticketDetail.pullRequests') }}
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="grid gap-4 pt-6">
|
||||
<FeedbackState
|
||||
v-if="repositoryPullRequests.length === 0"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.noLinkedRepositories')"
|
||||
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||
/>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="visibleRepositories.length === 0"
|
||||
state="empty"
|
||||
:title="t('tickets.noPullRequests')"
|
||||
:description="t('tickets.noPullRequestsDescription')"
|
||||
/>
|
||||
|
||||
<article
|
||||
v-for="repositoryPullRequest in visibleRepositories"
|
||||
v-else
|
||||
:key="repositoryPullRequest.repository_connection.id"
|
||||
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<header class="grid min-w-0 gap-2">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<a
|
||||
:href="
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openRepositoryExternal', {
|
||||
repository:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.repository_full_name,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
repositoryPullRequest.repository_connection
|
||||
.repository_full_name
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
repositoryPullRequest.repository_connection.base_url
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="
|
||||
!repositoryPullRequest.repository_connection
|
||||
.has_personal_access
|
||||
"
|
||||
state="empty"
|
||||
:title="t('ticketDetail.gitAccessRequired')"
|
||||
:description="t('ticketDetail.inspectPullRequests')"
|
||||
>
|
||||
<Button
|
||||
as-child
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="justify-self-start"
|
||||
>
|
||||
<Link :href="editGitConnections()">{{
|
||||
t('ticketDetail.openGitConnections')
|
||||
}}</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<FeedbackState
|
||||
v-else-if="repositoryPullRequest.error"
|
||||
state="error"
|
||||
:title="t('ticketDetail.pullRequestsUnavailable')"
|
||||
:description="repositoryPullRequest.error"
|
||||
/>
|
||||
|
||||
<ul v-else class="grid min-w-0 gap-3">
|
||||
<li
|
||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||
:key="pullRequest.html_url"
|
||||
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
#{{ pullRequest.number }}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ pullRequest.status }}
|
||||
</Badge>
|
||||
<a
|
||||
:href="pullRequest.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="
|
||||
t('ticketDetail.openPullRequestExternal', {
|
||||
number: pullRequest.number,
|
||||
provider:
|
||||
repositoryPullRequest
|
||||
.repository_connection
|
||||
.provider_label,
|
||||
})
|
||||
"
|
||||
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||
>
|
||||
<span class="wrap-anywhere">{{
|
||||
pullRequest.title
|
||||
}}</span>
|
||||
<ExternalLink
|
||||
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{{
|
||||
t('ticketDetail.sourceBranch', {
|
||||
name: pullRequest.source_branch,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -491,8 +491,20 @@ export const en = {
|
||||
'ticketDetail.branchName': 'Branch name',
|
||||
'ticketDetail.branchAccess':
|
||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||
'ticketDetail.gitAccessRequired': 'Personal Git access required',
|
||||
'ticketDetail.personalAccessRequired': 'personal Git access required',
|
||||
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||
'ticketDetail.createNow': 'Create now',
|
||||
'ticketDetail.creatingBranch': 'Creating branch…',
|
||||
'ticketDetail.noLinkedRepositories': 'No linked repositories',
|
||||
'ticketDetail.noLinkedRepositoriesDescription':
|
||||
'A project administrator needs to link a repository before Git activity can appear here.',
|
||||
'ticketDetail.branchesUnavailable': 'Branches unavailable',
|
||||
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
|
||||
'ticketDetail.openRepositoryExternal':
|
||||
'Open {repository} on {provider} (opens in a new tab)',
|
||||
'ticketDetail.openPullRequestExternal':
|
||||
'Open pull request #{number} on {provider} (opens in a new tab)',
|
||||
'ticketDetail.inspectBranches':
|
||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||
'ticketDetail.pullRequests': 'Pull requests',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
export type TicketDetailHeaderProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'canEditTicket'
|
||||
@@ -32,6 +33,24 @@ export type TicketDetailGitProps = Pick<
|
||||
| 'repositoryPullRequests'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketGitBranchesProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
| 'project'
|
||||
| 'canEditTicket'
|
||||
| 'repositoryBranches'
|
||||
| 'suggestedBranchName'
|
||||
>;
|
||||
export type TicketGitPullRequestsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'repositoryPullRequests'
|
||||
>;
|
||||
export type TicketBranchCreateDialogProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
'ticket' | 'project' | 'suggestedBranchName'
|
||||
> & {
|
||||
repositoryConnections: RepositoryConnectionModel[];
|
||||
};
|
||||
export type TicketDetailRelationshipsProps = Pick<
|
||||
ProjectTicketPageProps,
|
||||
| 'ticket'
|
||||
|
||||
@@ -342,6 +342,47 @@ try {
|
||||
.waitFor();
|
||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await visit('?slowBranch&serverError');
|
||||
await page
|
||||
.getByRole('button', { name: 'Create branch', exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByLabel('Branch name', { exact: true })
|
||||
.fill('feature/ENG-42-preserve-this-name');
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
const failedBranchCreation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/branches') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||
await page.getByText('Creating branch…', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('button[type="submit"]')
|
||||
.last()
|
||||
.getAttribute('aria-busy'),
|
||||
'true',
|
||||
);
|
||||
assert.equal((await failedBranchCreation).status(), 503);
|
||||
await page
|
||||
.getByText('The request could not be completed', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.getByLabel('Branch name', { exact: true }).inputValue(),
|
||||
'feature/ENG-42-preserve-this-name',
|
||||
);
|
||||
assert.equal(await page.getByRole('dialog').isVisible(), true);
|
||||
await page.getByLabel('Branch name', { exact: true }).fill('eng-42-cache');
|
||||
await page
|
||||
.getByText('Branch name is available.', { exact: true })
|
||||
.waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
const linkSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/links') &&
|
||||
|
||||
@@ -42,6 +42,7 @@ const repository = {
|
||||
provider_label: 'Gitea',
|
||||
base_url: 'https://git.example.test',
|
||||
repository_full_name: 'Engineering/platform',
|
||||
repository_url: 'https://git.example.test/Engineering/platform',
|
||||
has_personal_access: true,
|
||||
};
|
||||
const parent = {
|
||||
@@ -354,6 +355,9 @@ if (serve) {
|
||||
const data = body ? JSON.parse(body) : {};
|
||||
const previous = new URL(request.headers.referer);
|
||||
const params = previous.searchParams;
|
||||
if (request.url.includes('/branches') && params.has('slowBranch')) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
if (params.has('serverError')) {
|
||||
response.writeHead(503);
|
||||
response.end('Simulated failure');
|
||||
@@ -550,6 +554,14 @@ if (serve) {
|
||||
assert.match(html, /aria-label="Criterion 1"/);
|
||||
assert.match(html, /Add criterion/);
|
||||
assert.match(html, /Create branch/);
|
||||
assert.match(
|
||||
html,
|
||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||
);
|
||||
assert.match(
|
||||
html,
|
||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||
);
|
||||
assert.match(html, /Save link/);
|
||||
assert.match(html, /Outgoing/);
|
||||
const incoming = await render('?incoming');
|
||||
@@ -579,8 +591,7 @@ if (serve) {
|
||||
'Unassigned',
|
||||
'No author',
|
||||
'Not set',
|
||||
'No matching branches',
|
||||
'No matching pull requests',
|
||||
'No linked repositories',
|
||||
'No worklog entries exist',
|
||||
]) {
|
||||
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||
@@ -590,11 +601,13 @@ if (serve) {
|
||||
assert.match(hierarchy, /Platform delivery/);
|
||||
assert.match(hierarchy, /Nested child/);
|
||||
assert.match(hierarchy, /Change parent/);
|
||||
assert.match(await render('?noAccess'), /Open Git connections/);
|
||||
assert.match(
|
||||
await render('?gitError'),
|
||||
/Repository temporarily unavailable/,
|
||||
);
|
||||
const noAccess = await render('?noAccess');
|
||||
assert.match(noAccess, /Personal Git access required/);
|
||||
assert.match(noAccess, /Open Git connections/);
|
||||
const gitError = await render('?gitError');
|
||||
assert.match(gitError, /Branches unavailable/);
|
||||
assert.match(gitError, /Pull requests unavailable/);
|
||||
assert.match(gitError, /Repository temporarily unavailable/);
|
||||
const create = await render('?source=board', true);
|
||||
assert.match(create, /Create ticket/);
|
||||
assert.match(create, /Delivery \/ In progress/);
|
||||
|
||||
Reference in New Issue
Block a user