Compare commits
12
Commits
v0.2.2
..
f61298fda4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d |
@@ -33,9 +33,6 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -1,72 +1,18 @@
|
|||||||
name: "Lint and Test API"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite for the Laravel API"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::api/composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
.
|
||||||
with:
|
|
||||||
path: ${{ steps.composer_cache.outputs.dir }}
|
|
||||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
|
||||||
with:
|
|
||||||
package-json-file: package.json
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: "pnpm"
|
|
||||||
cache-dependency-path: "pnpm-lock.yaml"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Prepare environment file
|
|
||||||
run: cp .env.example .env
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --no-progress
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Generate application key
|
|
||||||
run: php artisan key:generate
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Generate Wayfinder routes and actions
|
|
||||||
run: php artisan wayfinder:generate --with-form
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Build Vite app
|
|
||||||
run: pnpm run build
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Run ci check
|
|
||||||
run: composer run ci:check
|
|
||||||
shell: bash
|
|
||||||
|
|||||||
+2
-1
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
|
|||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
|
||||||
RUN touch .env \
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
&& composer audit --locked --no-interaction \
|
&& composer audit --locked --no-interaction \
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
IndentDecrease,
|
||||||
|
IndentIncrease,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
canIndentAcceptanceCriterion,
|
||||||
|
indentAcceptanceCriterion,
|
||||||
|
removeAcceptanceCriterion,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: AcceptanceCriteriaModel;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
persistToggles?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
persistToggles: false,
|
||||||
|
allowEditing: true,
|
||||||
|
saving: undefined,
|
||||||
|
dirty: false,
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [items: AcceptanceCriteriaModel];
|
||||||
|
toggle: [index: number, checked: boolean];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const id = useId();
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editing = ref(!props.persistToggles);
|
||||||
|
const completed = computed(
|
||||||
|
() => props.modelValue.filter((item) => item.checked).length,
|
||||||
|
);
|
||||||
|
const locked = computed(() => props.readonly || props.disabled);
|
||||||
|
const update = (items: AcceptanceCriteriaModel): void => {
|
||||||
|
if (!locked.value) {
|
||||||
|
emit('update:modelValue', items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusRow = async (index: number): Promise<void> => {
|
||||||
|
await nextTick();
|
||||||
|
const inputs =
|
||||||
|
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||||
|
if (inputs?.[index]) {
|
||||||
|
inputs[index].focus();
|
||||||
|
} else {
|
||||||
|
root.value
|
||||||
|
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const add = (): void => {
|
||||||
|
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
|
||||||
|
void focusRow(props.modelValue.length);
|
||||||
|
};
|
||||||
|
const remove = (index: number): void => {
|
||||||
|
update(removeAcceptanceCriterion(props.modelValue, index));
|
||||||
|
void focusRow(Math.max(0, index - 1));
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
root.value
|
||||||
|
?.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||||
|
.forEach((input) => {
|
||||||
|
input.setCustomValidity(
|
||||||
|
input.value.trim() === '' ? t('criteria.required') : '',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const changeText = (index: number, event: Event): void => {
|
||||||
|
const text = (event.target as HTMLTextAreaElement).value;
|
||||||
|
update(
|
||||||
|
props.modelValue.map((item, position) =>
|
||||||
|
position === index ? { ...item, text } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const toggle = (index: number, event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const checked = target.checked;
|
||||||
|
target.checked = props.modelValue[index].checked;
|
||||||
|
if (locked.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.persistToggles && !editing.value && !props.dirty) {
|
||||||
|
emit('toggle', index, checked);
|
||||||
|
} else {
|
||||||
|
update(
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeydown = (index: number, event: KeyboardEvent): void => {
|
||||||
|
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
event.key === 'ArrowRight' ? 1 : -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="root"
|
||||||
|
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:aria-labelledby="`${id}-title`"
|
||||||
|
:aria-busy="disabled"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3
|
||||||
|
:id="`${id}-title`"
|
||||||
|
class="flex items-center gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<ClipboardCheck
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('criteria.title') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-muted-foreground text-sm" aria-live="polite">{{
|
||||||
|
t('criteria.progress', { completed, total: modelValue.length })
|
||||||
|
}}</span>
|
||||||
|
<Button
|
||||||
|
v-if="persistToggles && allowEditing && !readonly"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
(editing &&
|
||||||
|
modelValue.some((item) => item.text.trim() === ''))
|
||||||
|
"
|
||||||
|
:aria-pressed="editing"
|
||||||
|
@click="editing = !editing"
|
||||||
|
>
|
||||||
|
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('criteria.empty') }}
|
||||||
|
</p>
|
||||||
|
<ol v-else class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in modelValue"
|
||||||
|
:key="item.id ?? `new-${index}`"
|
||||||
|
:data-indent="item.indent"
|
||||||
|
class="border-border min-w-0"
|
||||||
|
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
|
||||||
|
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<label
|
||||||
|
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-60': locked }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="item.checked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="locked"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.complete', {
|
||||||
|
number: index + 1,
|
||||||
|
text: item.text,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-input accent-primary size-5 rounded"
|
||||||
|
@change="toggle(index, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<textarea
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
:value="item.text"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.item', { number: index + 1 })
|
||||||
|
"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
required
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
|
||||||
|
@input="changeText(index, $event)"
|
||||||
|
@keydown="onKeydown(index, $event)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through':
|
||||||
|
item.checked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled || item.indent === 0"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.outdent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentDecrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!canIndentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.indent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentIncrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.remove', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="remove(index)"
|
||||||
|
><Trash2 aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<template v-if="editing && !readonly">
|
||||||
|
<Button
|
||||||
|
data-add-criterion
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="add"
|
||||||
|
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
|
||||||
|
>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{ t('criteria.hint') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p
|
||||||
|
v-if="persistToggles && dirty"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.unsaved') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="saving ?? disabled"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.saving') }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDetailBlockerProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const savedFormData = () => ({
|
||||||
|
acceptance_criteria: props.ticket.acceptance_criteria,
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id,
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
|
description: props.ticket.description,
|
||||||
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(
|
||||||
|
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
||||||
|
savedFormData(),
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const isBlocked = computed(() => Boolean(form.is_blocked));
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!props.canEditTicket || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.update({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
Object.assign(form, savedFormData());
|
||||||
|
form.defaults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-blocker"
|
||||||
|
aria-labelledby="ticket-blocker-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card :class="{ 'border-warning/40': isBlocked }">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.blocker') }}
|
||||||
|
</h2>
|
||||||
|
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.blockerActive')
|
||||||
|
: t('ticketDetail.blockerInactive')
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{{ t('ticketDetail.blockerDescription') }}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent v-if="canEditTicket">
|
||||||
|
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="is_blocked"
|
||||||
|
v-model="form.is_blocked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||||
|
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
||||||
|
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<Label for="is_blocked">
|
||||||
|
{{ t('ticketDetail.blocked') }}
|
||||||
|
</Label>
|
||||||
|
<p
|
||||||
|
id="ticket-blocker-state-description"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t(
|
||||||
|
'ticketDetail.blockerActiveDescription',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'ticketDetail.blockerInactiveDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="is_blocked-error"
|
||||||
|
:message="form.errors.is_blocked"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketFormField
|
||||||
|
v-if="isBlocked"
|
||||||
|
id="blocked_comment"
|
||||||
|
v-model="form.blocked_comment"
|
||||||
|
kind="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:label="t('ticketDetail.blockerComment')"
|
||||||
|
:hint="t('ticketDetail.blockerCommentHelp')"
|
||||||
|
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||||
|
:error="form.errors.blocked_comment"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!form.isDirty || form.processing"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.resetBlocker') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('ticketDetail.savingBlocker')"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.saveBlocker')
|
||||||
|
: t('ticketDetail.unblockTicket')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardContent v-else>
|
||||||
|
<p
|
||||||
|
class="whitespace-pre-wrap text-sm leading-6"
|
||||||
|
:class="{ 'text-muted-foreground': !isBlocked }"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? ticket.blocked_comment
|
||||||
|
: t('ticketDetail.blockerInactiveDescription')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -3,43 +3,54 @@ import { useLocalization } from '@/composables/useLocalization';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||||
import { computed, ref, toRef, unref } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
toRef,
|
||||||
|
unref,
|
||||||
|
useTemplateRef,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import {
|
import {
|
||||||
acceptanceCriteriaToEditorState,
|
|
||||||
buildAcceptanceCriteria,
|
|
||||||
formatChecklistLines,
|
|
||||||
normalizeAcceptanceCriteria,
|
normalizeAcceptanceCriteria,
|
||||||
syncAcceptanceCriteriaDescendantCheckedState,
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
} from '@/lib/acceptance-criteria';
|
} from '@/lib/acceptance-criteria';
|
||||||
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||||
const props = defineProps<TicketDetailContentProps>();
|
const props = defineProps<TicketDetailContentProps>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
||||||
|
async function editParent(): Promise<void> {
|
||||||
|
if (!props.canEditTicket || !editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.open = true;
|
||||||
|
await nextTick();
|
||||||
|
editor.value
|
||||||
|
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
defineExpose({ editParent });
|
||||||
const canEditAuthor = computed(
|
const canEditAuthor = computed(
|
||||||
() => props.canEditTicket && props.canManageProject,
|
() => props.canEditTicket && props.canManageProject,
|
||||||
);
|
);
|
||||||
const canViewAuthor = computed(
|
const canViewAuthor = computed(
|
||||||
() => props.project.show_author || props.canManageProject,
|
() => props.project.show_author || props.canManageProject,
|
||||||
);
|
);
|
||||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
|
||||||
props.ticket.acceptance_criteria,
|
|
||||||
);
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const acceptanceCriteriaDraft = useRemember(
|
||||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
|
||||||
`${draftKey}:checklist`,
|
`${draftKey}:criteria`,
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
);
|
||||||
|
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
|
||||||
const savedAcceptanceCriteria = ref(
|
const savedAcceptanceCriteria = ref(
|
||||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
);
|
);
|
||||||
@@ -47,8 +58,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
|
|||||||
const acceptanceCriteriaToggleError = ref('');
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
const savedFormData = () => ({
|
const savedFormData = () => ({
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value,
|
savedAcceptanceCriteria.value,
|
||||||
),
|
),
|
||||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||||
author_user_id: props.ticket.author_user_id ?? '',
|
author_user_id: props.ticket.author_user_id ?? '',
|
||||||
@@ -77,14 +88,20 @@ const savedFormData = () => ({
|
|||||||
|
|
||||||
const form = useForm(draftKey, savedFormData());
|
const form = useForm(draftKey, savedFormData());
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(
|
watch(
|
||||||
() => savedAcceptanceCriteria.value,
|
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
||||||
|
([isBlocked, blockedComment]) => {
|
||||||
|
form.is_blocked = isBlocked;
|
||||||
|
form.blocked_comment = blockedComment ?? '';
|
||||||
|
form.defaults('is_blocked', isBlocked);
|
||||||
|
form.defaults('blocked_comment', blockedComment ?? '');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
() =>
|
() =>
|
||||||
acceptanceCriteriaChecklist.value !==
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
JSON.stringify(savedAcceptanceCriteria.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDirty = computed(
|
const isDirty = computed(
|
||||||
@@ -103,9 +120,10 @@ const reset = (): void => {
|
|||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
savedAcceptanceCriteria.value,
|
savedAcceptanceCriteria.value,
|
||||||
);
|
);
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
protection.clearFeedback();
|
protection.clearFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,19 +137,6 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildAcceptanceCriteriaForSubmit = () => {
|
|
||||||
const draftItems = buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return draftItems.map((item, index) => ({
|
|
||||||
...item,
|
|
||||||
...(savedAcceptanceCriteria.value[index]?.id !== undefined
|
|
||||||
? { id: savedAcceptanceCriteria.value[index].id }
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSavedTicketPayload = (
|
const buildSavedTicketPayload = (
|
||||||
acceptanceCriteria = savedAcceptanceCriteria.value,
|
acceptanceCriteria = savedAcceptanceCriteria.value,
|
||||||
) => ({
|
) => ({
|
||||||
@@ -171,10 +176,6 @@ const persistAcceptanceCriteriaToggle = async (
|
|||||||
index,
|
index,
|
||||||
checked,
|
checked,
|
||||||
);
|
);
|
||||||
const previousChecklist = formatChecklistLines(
|
|
||||||
savedAcceptanceCriteria.value,
|
|
||||||
);
|
|
||||||
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
|
|
||||||
const csrfToken = document
|
const csrfToken = document
|
||||||
.querySelector('meta[name="csrf-token"]')
|
.querySelector('meta[name="csrf-token"]')
|
||||||
?.getAttribute('content');
|
?.getAttribute('content');
|
||||||
@@ -209,34 +210,18 @@ const persistAcceptanceCriteriaToggle = async (
|
|||||||
|
|
||||||
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||||
|
|
||||||
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value = nextChecklist;
|
nextAcceptanceCriteria,
|
||||||
form.acceptance_criteria = nextAcceptanceCriteria;
|
|
||||||
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
acceptanceCriteriaToggleError.value = t(
|
|
||||||
'ticketDetail.checklistSaveFailed',
|
|
||||||
);
|
);
|
||||||
|
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||||
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||||
|
} catch (_error) {
|
||||||
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||||
} finally {
|
} finally {
|
||||||
isPersistingAcceptanceCriteriaToggle.value = false;
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAcceptanceCriteriaCheckboxChange = async (
|
|
||||||
index: number,
|
|
||||||
event: Event,
|
|
||||||
): Promise<void> => {
|
|
||||||
const target = event.target;
|
|
||||||
|
|
||||||
if (!(target instanceof HTMLInputElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistAcceptanceCriteriaToggle(index, target.checked);
|
|
||||||
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
if (
|
if (
|
||||||
!props.canEditTicket ||
|
!props.canEditTicket ||
|
||||||
@@ -246,7 +231,7 @@ const submit = (): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
||||||
|
|
||||||
protection.submit(
|
protection.submit(
|
||||||
ProjectTicketController.update({
|
ProjectTicketController.update({
|
||||||
@@ -259,7 +244,7 @@ const submit = (): void => {
|
|||||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||||
props.ticket.acceptance_criteria,
|
props.ticket.acceptance_criteria,
|
||||||
);
|
);
|
||||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
savedAcceptanceCriteria.value,
|
savedAcceptanceCriteria.value,
|
||||||
);
|
);
|
||||||
Object.assign(form, savedFormData());
|
Object.assign(form, savedFormData());
|
||||||
@@ -301,85 +286,29 @@ const submit = (): void => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<div id="ticket-checklist" class="scroll-mt-6">
|
||||||
id="ticket-checklist"
|
<AcceptanceCriteriaChecklist
|
||||||
aria-labelledby="ticket-checklist-title"
|
:model-value="savedAcceptanceCriteria"
|
||||||
class="scroll-mt-6"
|
:readonly="!canEditTicket"
|
||||||
>
|
:disabled="
|
||||||
<Card>
|
form.processing ||
|
||||||
<CardHeader
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
><h2
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
id="ticket-checklist-title"
|
"
|
||||||
class="text-lg font-semibold"
|
:saving="
|
||||||
>
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
||||||
{{ t('ticketDetail.checklist') }}
|
"
|
||||||
</h2></CardHeader
|
:allow-editing="false"
|
||||||
>
|
persist-toggles
|
||||||
<CardContent class="space-y-3">
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
<div
|
:error="acceptanceCriteriaToggleError"
|
||||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
@toggle="persistAcceptanceCriteriaToggle"
|
||||||
class="border-border rounded-xl border p-4 text-sm"
|
/>
|
||||||
>
|
</div>
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="item.id ?? index"
|
|
||||||
:style="{
|
|
||||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
|
||||||
}"
|
|
||||||
class="flex min-w-0 items-start gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`acceptance-criteria-${item.id ?? index}`"
|
|
||||||
:checked="item.checked"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="
|
|
||||||
!canEditTicket ||
|
|
||||||
form.processing ||
|
|
||||||
isPersistingAcceptanceCriteriaToggle ||
|
|
||||||
hasUnsavedAcceptanceCriteriaDraft
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
|
|
||||||
@change="
|
|
||||||
onAcceptanceCriteriaCheckboxChange(
|
|
||||||
index,
|
|
||||||
$event,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
|
||||||
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
|
|
||||||
>
|
|
||||||
{{ item.text }}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.noChecklist') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="acceptanceCriteriaToggleError"
|
|
||||||
role="alert"
|
|
||||||
class="text-destructive text-sm"
|
|
||||||
>
|
|
||||||
{{ acceptanceCriteriaToggleError }}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
<details
|
<details
|
||||||
v-if="canEditTicket"
|
v-if="canEditTicket"
|
||||||
id="ticket-edit"
|
id="ticket-edit"
|
||||||
|
ref="editor"
|
||||||
class="bg-card group rounded-xl border shadow-sm"
|
class="bg-card group rounded-xl border shadow-sm"
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
@@ -416,20 +345,13 @@ const submit = (): void => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<TicketFormField
|
<AcceptanceCriteriaChecklist
|
||||||
id="acceptance_criteria_checklist"
|
v-model="acceptanceCriteriaItems"
|
||||||
v-model="acceptanceCriteriaChecklist"
|
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.checklistEditor')"
|
|
||||||
:hint="t('ticketForm.checklistHelp')"
|
|
||||||
:placeholder="
|
|
||||||
t('ticketDetail.checklistPlaceholder')
|
|
||||||
"
|
|
||||||
:error="acceptanceCriteriaError"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
form.processing ||
|
form.processing ||
|
||||||
isPersistingAcceptanceCriteriaToggle
|
isPersistingAcceptanceCriteriaToggle
|
||||||
"
|
"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #metadata>
|
<template #metadata>
|
||||||
@@ -459,44 +381,6 @@ const submit = (): void => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</TicketFormFields>
|
</TicketFormFields>
|
||||||
<fieldset
|
|
||||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
|
||||||
:disabled="
|
|
||||||
form.processing ||
|
|
||||||
isPersistingAcceptanceCriteriaToggle
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<legend class="px-2 text-sm font-semibold">
|
|
||||||
{{ t('ticketForm.blocker') }}
|
|
||||||
</legend>
|
|
||||||
<div class="min-h-control flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
id="is_blocked"
|
|
||||||
v-model="form.is_blocked"
|
|
||||||
type="checkbox"
|
|
||||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
|
||||||
aria-describedby="is_blocked-error"
|
|
||||||
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
<Label for="is_blocked">{{
|
|
||||||
t('ticketDetail.blocked')
|
|
||||||
}}</Label>
|
|
||||||
</div>
|
|
||||||
<InputError
|
|
||||||
id="is_blocked-error"
|
|
||||||
:message="form.errors.is_blocked"
|
|
||||||
/>
|
|
||||||
<TicketFormField
|
|
||||||
id="blocked_comment"
|
|
||||||
v-model="form.blocked_comment"
|
|
||||||
kind="textarea"
|
|
||||||
:rows="3"
|
|
||||||
:label="t('ticketDetail.blockerComment')"
|
|
||||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
|
||||||
:error="form.errors.blocked_comment"
|
|
||||||
:required="form.is_blocked"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset
|
<fieldset
|
||||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
|||||||
@@ -1,273 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<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 { useLocalization } from '@/composables/useLocalization';
|
||||||
import {
|
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||||
Card,
|
|
||||||
CardContent,
|
defineProps<TicketDetailGitProps>();
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
|
|
||||||
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 { 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
|
|||||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||||
{{ t('ticketDetail.git') }}
|
{{ t('ticketDetail.git') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-6">
|
<div class="grid min-w-0 gap-6">
|
||||||
<Card class="h-fit">
|
<TicketGitBranches
|
||||||
<CardHeader
|
:ticket="ticket"
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
:project="project"
|
||||||
>
|
:can-edit-ticket="canEditTicket"
|
||||||
<div class="space-y-1.5">
|
:repository-branches="repositoryBranches"
|
||||||
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
:suggested-branch-name="suggestedBranchName"
|
||||||
<CardDescription>{{
|
/>
|
||||||
t('ticketDetail.branchesDescription')
|
<TicketGitPullRequests
|
||||||
}}</CardDescription>
|
:repository-pull-requests="repositoryPullRequests"
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const sections = [
|
|||||||
'description',
|
'description',
|
||||||
'checklist',
|
'checklist',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'blocker',
|
||||||
'relationships',
|
'relationships',
|
||||||
'history',
|
'history',
|
||||||
'git',
|
'git',
|
||||||
|
|||||||
@@ -8,11 +8,21 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
||||||
defineProps<TicketDetailHistoryProps>();
|
defineProps<TicketDetailHistoryProps>();
|
||||||
const { t, date } = useLocalization();
|
const { t, date } = useLocalization();
|
||||||
const formatDateTime = (value: string): string =>
|
const formatDateTime = (value: string): string =>
|
||||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
||||||
|
created: 'ticketDetail.historyType.created',
|
||||||
|
link_removed: 'ticketDetail.historyType.linkRemoved',
|
||||||
|
link_updated: 'ticketDetail.historyType.linkUpdated',
|
||||||
|
status_changed: 'ticketDetail.historyType.statusChanged',
|
||||||
|
updated: 'ticketDetail.historyType.updated',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
|
|||||||
{{ t('ticketDetail.noHistory') }}
|
{{ t('ticketDetail.noHistory') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-4">
|
<ol v-else class="relative grid gap-0" role="list">
|
||||||
<div
|
<li
|
||||||
v-for="worklogEntry in ticket.worklog_entries"
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
:key="worklogEntry.id"
|
:key="worklogEntry.id"
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div
|
||||||
<Badge variant="outline">
|
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||||
{{ worklogEntry.entry_type }}
|
>
|
||||||
</Badge>
|
<UserAvatar
|
||||||
<span class="text-foreground text-sm font-medium">
|
v-if="worklogEntry.actor"
|
||||||
{{
|
:user="worklogEntry.actor"
|
||||||
worklogEntry.actor?.name ??
|
size="lg"
|
||||||
t('ticketDetail.system')
|
/>
|
||||||
}}
|
<span
|
||||||
|
v-else
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
||||||
|
>
|
||||||
|
S
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-foreground mt-3 text-sm">
|
<article
|
||||||
{{ worklogEntry.summary }}
|
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||||
</p>
|
>
|
||||||
<p class="text-muted-foreground mt-2 text-xs">
|
<div
|
||||||
{{ formatDateTime(worklogEntry.created_at) }}
|
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||||
</p>
|
>
|
||||||
</div>
|
<div
|
||||||
</div>
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-foreground text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
worklogEntry.actor?.name ??
|
||||||
|
t('ticketDetail.system')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
entryTypeLabels[
|
||||||
|
worklogEntry.entry_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
:datetime="worklogEntry.created_at"
|
||||||
|
class="text-muted-foreground shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatDateTime(worklogEntry.created_at)
|
||||||
|
}}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm leading-6">
|
||||||
|
{{ worklogEntry.summary }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,33 +1,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
|
||||||
Card,
|
import { computed, nextTick, useTemplateRef } from 'vue';
|
||||||
CardContent,
|
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
CardDescription,
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
|
|
||||||
import { Form, Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
|
defineEmits<{ editParent: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
|
||||||
const currentParentTicket = computed(
|
const currentParentTicket = computed(
|
||||||
() =>
|
() =>
|
||||||
props.ticket.parent_ticket ??
|
props.ticket.parent_ticket ??
|
||||||
props.parentTicketOptions.find(
|
props.parentTicketOptions.find(
|
||||||
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
|
(parent) => parent.id === props.ticket.parent_ticket_id,
|
||||||
) ??
|
) ??
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const canAddLink = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
props.ticketLinkTypes.length > 0 &&
|
||||||
|
props.ticketLinkOptions.length > 0,
|
||||||
|
);
|
||||||
|
const form = useForm({
|
||||||
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
linked_ticket_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
|
});
|
||||||
|
const selectedType = computed(() =>
|
||||||
|
props.ticketLinkTypes.find(
|
||||||
|
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTicket = computed(() =>
|
||||||
|
props.ticketLinkOptions.find(
|
||||||
|
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function focusLinks(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
linksHeading.value?.focus();
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
if (!canAddLink.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
store({ project: props.project.id, ticket: props.ticket.id }),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.linked_ticket_id = '';
|
||||||
|
form.clearErrors();
|
||||||
|
void focusLinks();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
|
|||||||
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||||
{{ t('ticketDetail.relationships') }}
|
{{ t('ticketDetail.relationships') }}
|
||||||
</h2>
|
</h2>
|
||||||
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
<CardDescription>{{
|
>
|
||||||
t('ticketDetail.childrenDescription')
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
}}</CardDescription>
|
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||||
</CardHeader>
|
t('ticketRelationships.hierarchy')
|
||||||
|
}}
|
||||||
<CardContent>
|
</h3>
|
||||||
<div
|
<Button
|
||||||
v-if="childTicketTree.length === 0"
|
v-if="canEditTicket"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('editParent')"
|
||||||
|
>{{ t('ticketRelationships.editParent') }}</Button
|
||||||
>
|
>
|
||||||
{{ t('ticketDetail.noChildren') }}
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<h4
|
||||||
|
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.parent') }}
|
||||||
|
</h4>
|
||||||
|
<Link
|
||||||
|
v-if="currentParentTicket"
|
||||||
|
:href="
|
||||||
|
showTicket({
|
||||||
|
project: project.id,
|
||||||
|
ticket: currentParentTicket.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-primary font-mono text-xs font-semibold"
|
||||||
|
>{{ currentParentTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="group-hover:text-primary text-sm font-medium"
|
||||||
|
>{{ currentParentTicket.title }}</span
|
||||||
|
>
|
||||||
|
</Link>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noParent') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
{{ t('ticketRelationships.parentHelp') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TicketTreeList
|
|
||||||
v-else
|
|
||||||
:nodes="childTicketTree"
|
|
||||||
:project-id="project.id"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-else-if="currentParentTicket !== null" class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.parentDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Link
|
|
||||||
:href="
|
|
||||||
showTicket({
|
|
||||||
project: project.id,
|
|
||||||
ticket: currentParentTicket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
|
||||||
>
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ currentParentTicket.ticket_key }}
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground mt-1 text-sm">
|
|
||||||
{{ currentParentTicket.title }}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.linkedTicketsDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<Form
|
|
||||||
v-if="
|
|
||||||
canEditTicket &&
|
|
||||||
ticketLinkTypes.length > 0 &&
|
|
||||||
ticketLinkOptions.length > 0
|
|
||||||
"
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketLinkController.store.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="ticket_link_type_id">{{
|
|
||||||
t('ticketDetail.linkType')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_link_type_id"
|
|
||||||
name="ticket_link_type_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]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketLinkType in ticketLinkTypes"
|
|
||||||
:key="ticketLinkType.id"
|
|
||||||
:value="ticketLinkType.id"
|
|
||||||
>
|
|
||||||
{{ ticketLinkType.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.ticket_link_type_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="linked_ticket_id">{{
|
|
||||||
t('ticketDetail.linkedTicket')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="linked_ticket_id"
|
|
||||||
name="linked_ticket_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]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketLinkOption in ticketLinkOptions"
|
|
||||||
:key="ticketLinkOption.id"
|
|
||||||
:value="ticketLinkOption.id"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.linked_ticket_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">{{
|
|
||||||
t('ticketDetail.saveLink')
|
|
||||||
}}</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="ticket.ticket_type?.is_parent"
|
||||||
|
class="grid min-w-0 gap-3 border-t pt-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
{{ t('ticketDetail.children') }}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
childTicketTree.length
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.childrenHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="childTicketTree.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noChildren') }}
|
||||||
|
</p>
|
||||||
|
<TicketTreeList
|
||||||
|
v-else
|
||||||
|
:nodes="childTicketTree"
|
||||||
|
:project-id="project.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<h3
|
||||||
|
ref="linksHeading"
|
||||||
|
tabindex="-1"
|
||||||
|
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<Link2 class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.linkedTickets')
|
||||||
|
}}<Badge variant="secondary">{{
|
||||||
|
linkedTickets.length
|
||||||
|
}}</Badge>
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.linkedTicketsDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<p
|
||||||
v-if="linkedTickets.length === 0"
|
v-if="linkedTickets.length === 0"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
>
|
>
|
||||||
{{ t('ticketDetail.noLinks') }}
|
{{ t('ticketDetail.noLinks') }}
|
||||||
</div>
|
</p>
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
<div v-else class="space-y-3">
|
<TicketRelationshipLink
|
||||||
<div
|
v-for="link in linkedTickets"
|
||||||
v-for="linkedTicket in linkedTickets"
|
:key="link.id"
|
||||||
:key="linkedTicket.id"
|
:link="link"
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
:project-id="project.id"
|
||||||
|
:ticket-id="ticket.id"
|
||||||
|
:ticket-key="ticket.ticket_key"
|
||||||
|
:can-edit="canEditTicket"
|
||||||
|
@removed="focusLinks"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
v-if="canAddLink"
|
||||||
|
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h4 class="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.addLink')
|
||||||
|
}}
|
||||||
|
</h4>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<TicketFormField
|
||||||
<Badge variant="secondary">
|
id="ticket_link_type_id"
|
||||||
{{ linkedTicket.ticket_link_type.label }}
|
v-model="form.ticket_link_type_id"
|
||||||
</Badge>
|
kind="select"
|
||||||
<Link
|
:label="t('ticketDetail.linkType')"
|
||||||
:href="
|
:options="
|
||||||
showTicket({
|
ticketLinkTypes.map((type) => ({
|
||||||
project: project.id,
|
value: type.id,
|
||||||
ticket: linkedTicket.linked_ticket.id,
|
label: type.label,
|
||||||
})
|
}))
|
||||||
"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ linkedTicket.linked_ticket.ticket_key }}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{ linkedTicket.linked_ticket.title }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-if="canEditTicket"
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketLinkController.destroy.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
ticketLink: linkedTicket.id,
|
|
||||||
})
|
|
||||||
"
|
"
|
||||||
class="mt-3 flex justify-end"
|
:error="form.errors.ticket_link_type_id"
|
||||||
v-slot="{ processing: deleting }"
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="linked_ticket_id"
|
||||||
|
v-model="form.linked_ticket_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkedTicket')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t(
|
||||||
|
'ticketRelationships.selectTicket',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...ticketLinkOptions.map((ticket) => ({
|
||||||
|
value: ticket.id,
|
||||||
|
label: `${ticket.ticket_key} — ${ticket.title}`,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.linked_ticket_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="selectedType && selectedTicket"
|
||||||
|
aria-live="polite"
|
||||||
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><span>{{ selectedType.label }}</span
|
||||||
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
>
|
>
|
||||||
<Button
|
</p>
|
||||||
type="submit"
|
<p class="text-muted-foreground text-xs">
|
||||||
variant="destructive"
|
{{ t('ticketRelationships.directionHelp') }}
|
||||||
size="sm"
|
</p>
|
||||||
:disabled="deleting"
|
<Button
|
||||||
>{{ t('ticketDetail.removeLink') }}</Button
|
type="submit"
|
||||||
>
|
:disabled="form.processing || !selectedTicket"
|
||||||
</Form>
|
class="sm:justify-self-start"
|
||||||
</div>
|
>{{
|
||||||
</div>
|
form.processing
|
||||||
|
? t('forms.saving')
|
||||||
|
: t('ticketDetail.saveLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<p
|
||||||
|
v-else-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
ticketLinkTypes.length === 0
|
||||||
|
? 'ticketRelationships.noTypes'
|
||||||
|
: 'ticketRelationships.noTargets',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
link: TicketLinkModel;
|
||||||
|
projectId: number;
|
||||||
|
ticketId: number;
|
||||||
|
ticketKey: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
function remove(): void {
|
||||||
|
if (!props.canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
destroy({
|
||||||
|
project: props.projectId,
|
||||||
|
ticket: props.ticketId,
|
||||||
|
ticketLink: props.link.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => emit('removed'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="grid min-w-0 gap-3 rounded-xl border p-4"
|
||||||
|
:data-ticket-link="link.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-muted-foreground max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
v-if="outgoing"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><ArrowDownLeft
|
||||||
|
v-else
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
outgoing
|
||||||
|
? 'ticketRelationships.outgoing'
|
||||||
|
: 'ticketRelationships.incoming',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
|
ticketKey
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
link.ticket_link_type.label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
show({ project: projectId, ticket: link.linked_ticket.id })
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="text-primary font-mono text-xs font-semibold">{{
|
||||||
|
link.linked_ticket.ticket_key
|
||||||
|
}}</span>
|
||||||
|
<span class="wrap-anywhere text-sm font-medium">{{
|
||||||
|
link.linked_ticket.title
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
|
<form v-if="canEdit" @submit.prevent="remove">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketRelationships.removeLink', {
|
||||||
|
key: link.linked_ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="form.processing"
|
||||||
|
><Unlink class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.removeLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
v-if="canEdit"
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="remove"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,76 @@ import type {
|
|||||||
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
export const canIndentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): boolean => {
|
||||||
|
const item = items[index];
|
||||||
|
const previous = items[index - 1];
|
||||||
|
if (!item || !previous || item.indent > previous.indent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
|
||||||
|
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaSubtreeEnd = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < items.length && items[end].indent > items[index].indent) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const indentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (
|
||||||
|
!items[index] ||
|
||||||
|
(direction === 1
|
||||||
|
? !canIndentAcceptanceCriterion(items, index)
|
||||||
|
: items[index].indent === 0)
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.map((item, position) =>
|
||||||
|
position >= index && position < end
|
||||||
|
? { ...item, indent: item.indent + direction }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove only the selected criterion, promoting its children without losing them. */
|
||||||
|
export const removeAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (!items[index]) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.flatMap((item, position) =>
|
||||||
|
position === index
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
indent:
|
||||||
|
position > index && position < end
|
||||||
|
? item.indent - 1
|
||||||
|
: item.indent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AcceptanceCriteriaEditorState = {
|
export type AcceptanceCriteriaEditorState = {
|
||||||
checklist: string;
|
checklist: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'criteria.title': 'Acceptance criteria',
|
||||||
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
|
'criteria.required': 'Enter a criterion or remove this row.',
|
||||||
|
'criteria.edit': 'Edit checklist',
|
||||||
|
'criteria.preview': 'Preview checklist',
|
||||||
|
'criteria.item': 'Criterion {number}',
|
||||||
|
'criteria.complete': 'Complete criterion {number}: {text}',
|
||||||
|
'criteria.indent': 'Indent criterion {number}',
|
||||||
|
'criteria.outdent': 'Outdent criterion {number}',
|
||||||
|
'criteria.remove': 'Remove criterion {number}',
|
||||||
|
'criteria.add': 'Add criterion',
|
||||||
|
'criteria.hint':
|
||||||
|
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
|
||||||
|
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
|
||||||
|
'criteria.saving': 'Saving checklist…',
|
||||||
|
'criteria.saveError':
|
||||||
|
'Checklist status could not be saved. Please try again.',
|
||||||
|
|
||||||
'board.title': '{name} board',
|
'board.title': '{name} board',
|
||||||
'board.description':
|
'board.description':
|
||||||
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||||
@@ -437,9 +456,24 @@ export const en = {
|
|||||||
'ticketDetail.checklistPlaceholder':
|
'ticketDetail.checklistPlaceholder':
|
||||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
|
'ticketDetail.blocker': 'Blocker',
|
||||||
|
'ticketDetail.blockerDescription':
|
||||||
|
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
|
||||||
|
'ticketDetail.blockerActive': 'Blocked',
|
||||||
|
'ticketDetail.blockerInactive': 'Not blocked',
|
||||||
|
'ticketDetail.blockerActiveDescription':
|
||||||
|
'A blocker reason is required while this ticket is blocked.',
|
||||||
|
'ticketDetail.blockerInactiveDescription':
|
||||||
|
'This ticket currently has no active blocker.',
|
||||||
'ticketDetail.blockerComment': 'Blocker comment',
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
|
'ticketDetail.blockerCommentHelp':
|
||||||
|
'Explain the concrete dependency or decision preventing progress.',
|
||||||
'ticketDetail.blockerPlaceholder':
|
'ticketDetail.blockerPlaceholder':
|
||||||
'Describe what currently blocks progress on this ticket.',
|
'Describe what currently blocks progress on this ticket.',
|
||||||
|
'ticketDetail.saveBlocker': 'Save blocker',
|
||||||
|
'ticketDetail.unblockTicket': 'Unblock ticket',
|
||||||
|
'ticketDetail.resetBlocker': 'Discard blocker changes',
|
||||||
|
'ticketDetail.savingBlocker': 'Saving blocker…',
|
||||||
'ticketDetail.customFields': 'Custom fields',
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
'ticketDetail.customFieldsDescription':
|
'ticketDetail.customFieldsDescription':
|
||||||
'Project-configured fields can be tracked directly on the ticket.',
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
@@ -457,8 +491,20 @@ export const en = {
|
|||||||
'ticketDetail.branchName': 'Branch name',
|
'ticketDetail.branchName': 'Branch name',
|
||||||
'ticketDetail.branchAccess':
|
'ticketDetail.branchAccess':
|
||||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
'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.openGitConnections': 'Open Git connections',
|
||||||
'ticketDetail.createNow': 'Create now',
|
'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':
|
'ticketDetail.inspectBranches':
|
||||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||||
'ticketDetail.pullRequests': 'Pull requests',
|
'ticketDetail.pullRequests': 'Pull requests',
|
||||||
@@ -493,10 +539,32 @@ export const en = {
|
|||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
'No ticket links are configured for this ticket yet.',
|
'No ticket links are configured for this ticket yet.',
|
||||||
'ticketDetail.removeLink': 'Remove',
|
'ticketDetail.removeLink': 'Remove',
|
||||||
|
'ticketRelationships.hierarchy': 'Hierarchy',
|
||||||
|
'ticketRelationships.editParent': 'Change parent',
|
||||||
|
'ticketRelationships.parentHelp':
|
||||||
|
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
|
||||||
|
'ticketRelationships.childrenHelp':
|
||||||
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
|
'ticketRelationships.directionHelp':
|
||||||
|
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
||||||
|
'ticketRelationships.outgoing': 'Outgoing',
|
||||||
|
'ticketRelationships.incoming': 'Incoming',
|
||||||
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
|
'ticketRelationships.noTypes':
|
||||||
|
'No link types are configured for this project.',
|
||||||
|
'ticketRelationships.noTargets':
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
'ticketDetail.historyDescription':
|
'ticketDetail.historyDescription':
|
||||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
'ticketDetail.system': 'System',
|
'ticketDetail.system': 'System',
|
||||||
|
'ticketDetail.historyType.created': 'Ticket created',
|
||||||
|
'ticketDetail.historyType.updated': 'Ticket updated',
|
||||||
|
'ticketDetail.historyType.statusChanged': 'Status changed',
|
||||||
|
'ticketDetail.historyType.linkUpdated': 'Link added',
|
||||||
|
'ticketDetail.historyType.linkRemoved': 'Link removed',
|
||||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
'ticketForm.create': 'Create ticket',
|
'ticketForm.create': 'Create ticket',
|
||||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
|
||||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
||||||
@@ -14,6 +15,7 @@ import { show as showTicket } from '@/routes/projects/tickets';
|
|||||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const ticketContent = useTemplateRef('ticketContent');
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{ title: t('navigation.projects'), href: projectIndex() },
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
{ title: props.project.name, href: show(props.project.id) },
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
@@ -49,6 +51,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||||
>
|
>
|
||||||
<TicketDetailContent
|
<TicketDetailContent
|
||||||
|
ref="ticketContent"
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
:can-edit-ticket="canEditTicket"
|
:can-edit-ticket="canEditTicket"
|
||||||
@@ -62,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
:aria-label="t('ticketDetail.metadata')"
|
:aria-label="t('ticketDetail.metadata')"
|
||||||
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1"
|
class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
|
||||||
>
|
>
|
||||||
<TicketDetailMetadata
|
<TicketDetailMetadata
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
@@ -71,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:ticket-field-definitions="ticketFieldDefinitions"
|
:ticket-field-definitions="ticketFieldDefinitions"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
<TicketDetailBlocker
|
||||||
|
:ticket="ticket"
|
||||||
|
:project="project"
|
||||||
|
:can-edit-ticket="canEditTicket"
|
||||||
|
class="xl:col-start-1"
|
||||||
|
/>
|
||||||
<TicketDetailRelationships
|
<TicketDetailRelationships
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -80,6 +89,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:linked-tickets="linkedTickets"
|
:linked-tickets="linkedTickets"
|
||||||
:ticket-link-types="ticketLinkTypes"
|
:ticket-link-types="ticketLinkTypes"
|
||||||
:ticket-link-options="ticketLinkOptions"
|
:ticket-link-options="ticketLinkOptions"
|
||||||
|
@edit-parent="ticketContent?.editParent()"
|
||||||
class="xl:col-start-1"
|
class="xl:col-start-1"
|
||||||
/>
|
/>
|
||||||
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, toRef, unref } from 'vue';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
|
||||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
@@ -17,10 +17,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
buildAcceptanceCriteria,
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
parseChecklistLines,
|
|
||||||
} from '@/lib/acceptance-criteria';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
@@ -71,19 +69,9 @@ const hasTicketMetadata = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
|
||||||
{ checklist: '' },
|
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
|
||||||
`${draftKey}:checklist`,
|
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm(draftKey, {
|
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
),
|
|
||||||
assignee_user_id: '',
|
assignee_user_id: '',
|
||||||
description: '',
|
description: '',
|
||||||
parent_ticket_id: '',
|
parent_ticket_id: '',
|
||||||
@@ -96,24 +84,17 @@ const form = useForm(draftKey, {
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDirty = computed(
|
const isDirty = computed(() => form.isDirty);
|
||||||
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
const protection = useFormProtection(form);
|
||||||
);
|
|
||||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
|
||||||
const reset = (): void => {
|
const reset = (): void => {
|
||||||
if (form.processing || !protection.confirmLeave()) {
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
acceptanceCriteriaChecklist.value = '';
|
|
||||||
protection.clearFeedback();
|
protection.clearFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
form.errors.acceptance_criteria ??
|
form.errors.acceptance_criteria ??
|
||||||
@@ -128,17 +109,19 @@ const submit = (): void => {
|
|||||||
if (!hasTicketMetadata.value || form.processing) {
|
if (!hasTicketMetadata.value || form.processing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
protection.submit(
|
protection.submit(
|
||||||
ProjectTicketController.store({ project: props.project.id }),
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// The source page is unmounted before this callback, so its storage watcher has stopped.
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`${draftKey}:generation`,
|
||||||
|
String(draftGeneration.value + 1),
|
||||||
|
);
|
||||||
|
draftGeneration.value++;
|
||||||
form.reset();
|
form.reset();
|
||||||
acceptanceCriteriaChecklist.value = '';
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -224,46 +207,11 @@ const submit = (): void => {
|
|||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<TicketFormField
|
<AcceptanceCriteriaChecklist
|
||||||
id="acceptance_criteria_checklist"
|
v-model="form.acceptance_criteria"
|
||||||
v-model="acceptanceCriteriaChecklist"
|
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.checklistEditor')"
|
|
||||||
:hint="t('ticketForm.checklistHelp')"
|
|
||||||
:placeholder="
|
|
||||||
t('ticketDetail.checklistPlaceholder')
|
|
||||||
"
|
|
||||||
:error="acceptanceCriteriaError"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="acceptanceCriteriaPreviewItems.length"
|
|
||||||
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
|
||||||
>
|
|
||||||
<p class="mb-3 font-medium">
|
|
||||||
{{ t('ticketForm.checklistPreview') }}
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="index"
|
|
||||||
:style="{
|
|
||||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
|
||||||
}"
|
|
||||||
class="flex min-w-0 items-start gap-2"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{{
|
|
||||||
item.checked ? '☑' : '☐'
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="wrap-anywhere min-w-0"
|
|
||||||
>{{ item.text }}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</TicketFormFields>
|
</TicketFormFields>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
export type TicketDetailHeaderProps = Pick<
|
export type TicketDetailHeaderProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
'ticket' | 'project' | 'canEditTicket'
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
|
|||||||
| 'ticketTypes'
|
| 'ticketTypes'
|
||||||
| 'users'
|
| 'users'
|
||||||
>;
|
>;
|
||||||
|
export type TicketDetailBlockerProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
export type TicketDetailMetadataProps = Pick<
|
export type TicketDetailMetadataProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
|
|||||||
| 'repositoryPullRequests'
|
| 'repositoryPullRequests'
|
||||||
| 'suggestedBranchName'
|
| '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<
|
export type TicketDetailRelationshipsProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
| 'ticket'
|
| 'ticket'
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AcceptanceCriteriaChecklistTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4176';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const checklist = () =>
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.first()
|
||||||
|
.getByRole('region', { name: 'Acceptance criteria' });
|
||||||
|
const savedChecklist = () => page.locator('#ticket-checklist');
|
||||||
|
const field = (number) =>
|
||||||
|
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
|
||||||
|
const waitState = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).first().waitFor();
|
||||||
|
const save = async () => {
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('success');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
const boxes = savedChecklist().getByRole('checkbox');
|
||||||
|
await setResult('server');
|
||||||
|
await boxes.first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page
|
||||||
|
.getByText('Checklist status could not be saved. Please try again.')
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await boxes.first().isChecked(),
|
||||||
|
false,
|
||||||
|
'Failure must restore saved checkbox',
|
||||||
|
);
|
||||||
|
await setResult('success');
|
||||||
|
let payload;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.method() === 'PATCH') {
|
||||||
|
payload = request.postDataJSON();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await boxes.first().tap();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelectorAll('section[aria-busy="true"]').length === 0,
|
||||||
|
);
|
||||||
|
await savedChecklist().getByText('3 of 4 complete').waitFor();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[11, 12, 13, 14],
|
||||||
|
);
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
await field(2).fill('Edited child');
|
||||||
|
await field(2).press('Alt+ArrowLeft');
|
||||||
|
assert.deepEqual(
|
||||||
|
await checklist()
|
||||||
|
.locator('li')
|
||||||
|
.evaluateAll((items) =>
|
||||||
|
items.map((item) => Number(item.dataset.indent)),
|
||||||
|
),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await button('Remove criterion 1').tap();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(
|
||||||
|
await field(1).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await button('Add criterion').focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await field(4).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await field(4).fill('New criterion');
|
||||||
|
await button('Indent criterion 4').click();
|
||||||
|
await setResult('validation');
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('validation');
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
await setResult('success');
|
||||||
|
await save();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[12, 13, 14, undefined],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.indent),
|
||||||
|
[0, 1, 0, 1],
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(4).inputValue(), 'New criterion');
|
||||||
|
// Blank/whitespace text cannot silently delete a persisted criterion.
|
||||||
|
await field(1).fill(' ');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
|
||||||
|
const beforeInvalidSave = payload;
|
||||||
|
await button('Save changes').click();
|
||||||
|
assert.equal(payload, beforeInvalidSave);
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
|
||||||
|
for (const width of [1280, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await page.evaluate(
|
||||||
|
(theme) =>
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
theme === 'dark',
|
||||||
|
),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await checklist().evaluate(
|
||||||
|
(el) => el.scrollWidth <= el.clientWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await checklist().screenshot({
|
||||||
|
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
|
||||||
|
});
|
||||||
|
await button('Outdent criterion 4').tap();
|
||||||
|
await button('Indent criterion 4').tap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
|
||||||
|
assert.equal(await savedChecklist().getByRole('button').count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await savedChecklist().getByRole('checkbox').first().isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
|
await page.locator('#title').fill('Created with criteria');
|
||||||
|
for (const text of ['First', 'Second']) {
|
||||||
|
await button('Add criterion').click();
|
||||||
|
await page.locator('textarea').last().fill(text);
|
||||||
|
}
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await checklist().getByRole('checkbox').first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'POST' && request.url().endsWith('/tickets'),
|
||||||
|
);
|
||||||
|
await button('Create ticket').click();
|
||||||
|
const created = (await request).postDataJSON();
|
||||||
|
assert.deepEqual(created.acceptance_criteria, [
|
||||||
|
{ text: 'First', checked: true, indent: 0 },
|
||||||
|
{ text: 'Second', checked: true, indent: 1 },
|
||||||
|
]);
|
||||||
|
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
canIndentAcceptanceCriterion: canIndent,
|
||||||
|
indentAcceptanceCriterion: indent,
|
||||||
|
removeAcceptanceCriterion: remove,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState: toggle,
|
||||||
|
normalizeAcceptanceCriteria: normalize,
|
||||||
|
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
|
||||||
|
const items = [
|
||||||
|
{ id: 11, text: 'Parent', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Child', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
|
||||||
|
{ id: 14, text: 'Next', indent: 0, checked: false },
|
||||||
|
];
|
||||||
|
const original = structuredClone(items);
|
||||||
|
assert.equal(canIndent(items, 0), false);
|
||||||
|
assert.equal(canIndent(items, 1), false);
|
||||||
|
assert.equal(canIndent(items, 3), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
indent(items, 1, -1).map((item) => item.indent),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
|
||||||
|
assert.deepEqual(indent(items, 0, -1), items);
|
||||||
|
assert.deepEqual(indent(items, -1, 1), items);
|
||||||
|
assert.deepEqual(remove(items, 0), [
|
||||||
|
{ ...items[1], indent: 0 },
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 1), [
|
||||||
|
items[0],
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 99), items);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 0, true).map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 1, false).map((item) => item.checked),
|
||||||
|
[false, false, false, false],
|
||||||
|
);
|
||||||
|
const deep = Array.from({ length: 9 }, (_, indent) => ({
|
||||||
|
text: 'Deep',
|
||||||
|
checked: false,
|
||||||
|
indent,
|
||||||
|
}));
|
||||||
|
assert.equal(
|
||||||
|
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalize(remove(items, 0)).map((item) => item.id),
|
||||||
|
[12, 13, 14],
|
||||||
|
);
|
||||||
|
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
|
||||||
|
const { default: Checklist } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const render = (props) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(Checklist, props) }).use(
|
||||||
|
createLocalization(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const editable = await render({ modelValue: items });
|
||||||
|
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
|
||||||
|
assert.match(editable, /Indent criterion 4/);
|
||||||
|
assert.match(editable, /1 of 4 complete/);
|
||||||
|
const readonly = await render({ modelValue: items, readonly: true });
|
||||||
|
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
|
||||||
|
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
|
||||||
|
assert.match(
|
||||||
|
await render({ modelValue: [], error: 'Server validation' }),
|
||||||
|
/Server validation/,
|
||||||
|
);
|
||||||
|
console.log('Acceptance criteria behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -320,15 +320,18 @@ try {
|
|||||||
await page.goto(`${base}/projects/7/tickets/create`);
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
await page.locator('#title').fill('New ticket draft');
|
await page.locator('#title').fill('New ticket draft');
|
||||||
await page.locator('#description').fill('Keep this description');
|
await page.locator('#description').fill('Keep this description');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Keep this checklist');
|
.fill('Keep this checklist');
|
||||||
await setResult('validation');
|
await setResult('validation');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('validation');
|
await state('validation');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
@@ -349,8 +352,10 @@ try {
|
|||||||
await page.locator('#title').waitFor();
|
await page.locator('#title').waitFor();
|
||||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
@@ -359,9 +364,10 @@ try {
|
|||||||
await page.goto(`${base}/projects/7/tickets/31`);
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
await page.locator('#ticket-edit > summary').click();
|
await page.locator('#ticket-edit > summary').click();
|
||||||
await page.locator('#title').fill('Updated ticket draft');
|
await page.locator('#title').fill('Updated ticket draft');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Edited checklist');
|
.fill('Edited checklist');
|
||||||
await setResult('server');
|
await setResult('server');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('server');
|
await state('server');
|
||||||
@@ -370,8 +376,10 @@ try {
|
|||||||
'Updated ticket draft',
|
'Updated ticket draft',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Edited checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Edited checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.getByRole('button', { name: 'Try again' }).click();
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import process from 'node:process';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import tailwind from '@tailwindcss/vite';
|
import tailwind from '@tailwindcss/vite';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
@@ -36,7 +37,19 @@ let ticket = {
|
|||||||
ticket_status: status,
|
ticket_status: status,
|
||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
story_points: 0,
|
story_points: 0,
|
||||||
acceptance_criteria: [],
|
acceptance_criteria: process.env.CRITERIA_FIXTURE
|
||||||
|
? [
|
||||||
|
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
text: 'Independent criterion',
|
||||||
|
indent: 0,
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
field_values: [],
|
field_values: [],
|
||||||
worklog_entries: [],
|
worklog_entries: [],
|
||||||
created_at: '2026-09-10T12:00:00Z',
|
created_at: '2026-09-10T12:00:00Z',
|
||||||
@@ -125,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
|
|||||||
ticketStatusOptions: [status],
|
ticketStatusOptions: [status],
|
||||||
users: [],
|
users: [],
|
||||||
parentTicketOptions: [],
|
parentTicketOptions: [],
|
||||||
canEditTicket: true,
|
canEditTicket: !url.searchParams.has('readonly'),
|
||||||
canManageProject: true,
|
canManageProject: true,
|
||||||
childTicketTree: [],
|
childTicketTree: [],
|
||||||
linkedTickets: [],
|
linkedTickets: [],
|
||||||
|
|||||||
@@ -101,26 +101,130 @@ try {
|
|||||||
}
|
}
|
||||||
await page.setViewportSize({ width: 390, height: 1000 });
|
await page.setViewportSize({ width: 390, height: 1000 });
|
||||||
await visit();
|
await visit();
|
||||||
|
const blocker = content().locator('#ticket-blocker');
|
||||||
|
const blockerToggle = blocker.getByRole('checkbox', {
|
||||||
|
name: 'Mark ticket as blocked',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const blockerComment = blocker.getByLabel('Blocker comment', {
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(await blockerToggle.isChecked(), true);
|
||||||
|
assert.equal(
|
||||||
|
await blocker.getByText('Alex Morgan', { exact: true }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.locator('#ticket-history')
|
||||||
|
.getByText('Alex Morgan', { exact: true })
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.locator('#ticket-history')
|
||||||
|
.getByText('Status changed', { exact: true })
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await blockerComment.fill('');
|
||||||
|
assert.equal(
|
||||||
|
await blockerComment.evaluate(
|
||||||
|
(element) => element.validity.valueMissing,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await blockerComment.fill('Waiting for a production decision.');
|
||||||
|
await blocker.getByRole('button', { name: 'Save blocker' }).click();
|
||||||
|
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await blockerComment.inputValue(),
|
||||||
|
'Waiting for a production decision.',
|
||||||
|
);
|
||||||
|
for (const label of ['Assignee', 'Reviewer']) {
|
||||||
|
const field = content()
|
||||||
|
.locator('#ticket-metadata dl > div')
|
||||||
|
.filter({ has: page.getByText(label, { exact: true }) });
|
||||||
|
assert.match(await field.locator('dd').innerText(), /Alex Morgan/);
|
||||||
|
}
|
||||||
|
await blockerToggle.uncheck();
|
||||||
|
await blocker.getByRole('button', { name: 'Unblock ticket' }).click();
|
||||||
|
await blocker
|
||||||
|
.getByText('This ticket currently has no active blocker.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await blocker.getByLabel('Blocker comment').count(), 0);
|
||||||
|
await content().getByRole('button', { name: 'Change parent' }).click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.activeElement?.id === 'parent_ticket_id',
|
||||||
|
);
|
||||||
|
const saveTicket = content().getByRole('button', {
|
||||||
|
name: 'Save changes',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await content()
|
||||||
|
.getByRole('button', { name: 'Discard changes', exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
true,
|
||||||
|
'Saving the blocker must also update the ticket form defaults.',
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByLabel('Title', { exact: true })
|
||||||
|
.fill('Updated title after unblocking');
|
||||||
|
const ticketUpdate = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'PATCH' &&
|
||||||
|
new URL(request.url()).pathname === '/projects/7/tickets/42',
|
||||||
|
);
|
||||||
|
await saveTicket.click();
|
||||||
|
const savedTicket = (await ticketUpdate).postDataJSON();
|
||||||
|
assert.equal(savedTicket.is_blocked, false);
|
||||||
|
assert.equal(savedTicket.blocked_comment, '');
|
||||||
|
assert.equal(Number(savedTicket.parent_ticket_id), 40);
|
||||||
|
assert.deepEqual(savedTicket.acceptance_criteria, [
|
||||||
|
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
text: 'Invalidate when records change',
|
||||||
|
checked: false,
|
||||||
|
indent: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Updated title after unblocking' })
|
||||||
|
.waitFor();
|
||||||
|
await visit();
|
||||||
await tabTo(edit());
|
await tabTo(edit());
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
|
||||||
await page
|
await page
|
||||||
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
|
.locator('#ticket-checklist')
|
||||||
.check();
|
.getByRole('checkbox', {
|
||||||
|
name: 'Complete criterion 1: Cache requests safely',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector('#acceptance-criteria-11').checked,
|
() =>
|
||||||
|
document.querySelectorAll(
|
||||||
|
'#ticket-checklist input[type=checkbox]',
|
||||||
|
)[1].checked,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.getByLabel('Title', { exact: true }).inputValue(),
|
await page.getByLabel('Title', { exact: true }).inputValue(),
|
||||||
'Unsaved title draft',
|
'Unsaved title draft',
|
||||||
);
|
);
|
||||||
await page
|
await page
|
||||||
.getByLabel('Acceptance criteria checklist', { exact: true })
|
.getByLabel('Criterion 1', { exact: true })
|
||||||
.fill('[ ] A draft checklist item');
|
.fill('A draft checklist item');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page
|
await page
|
||||||
|
.locator('#ticket-checklist')
|
||||||
.getByRole('checkbox', {
|
.getByRole('checkbox', {
|
||||||
name: 'Cache requests safely',
|
name: 'Complete criterion 1: Cache requests safely',
|
||||||
exact: true,
|
exact: true,
|
||||||
})
|
})
|
||||||
.isDisabled(),
|
.isDisabled(),
|
||||||
@@ -155,10 +259,8 @@ try {
|
|||||||
'Unsaved title draft',
|
'Unsaved title draft',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page
|
await page.getByLabel('Criterion 1', { exact: true }).inputValue(),
|
||||||
.getByLabel('Acceptance criteria checklist', { exact: true })
|
'A draft checklist item',
|
||||||
.inputValue(),
|
|
||||||
'[ ] A draft checklist item',
|
|
||||||
);
|
);
|
||||||
await visit('?reject');
|
await visit('?reject');
|
||||||
await edit().click();
|
await edit().click();
|
||||||
@@ -196,16 +298,21 @@ try {
|
|||||||
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||||
await visit('?rejectToggle');
|
await visit('?rejectToggle');
|
||||||
await page
|
await page
|
||||||
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
|
.locator('#ticket-checklist')
|
||||||
.check();
|
.getByRole('checkbox', {
|
||||||
|
name: 'Complete criterion 1: Cache requests safely',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
await page
|
await page
|
||||||
.getByRole('alert')
|
.getByRole('alert')
|
||||||
.filter({ hasText: 'Checklist status could not be saved' })
|
.filter({ hasText: 'Checklist status could not be saved' })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page
|
await page
|
||||||
|
.locator('#ticket-checklist')
|
||||||
.getByRole('checkbox', {
|
.getByRole('checkbox', {
|
||||||
name: 'Cache requests safely',
|
name: 'Complete criterion 1: Cache requests safely',
|
||||||
exact: true,
|
exact: true,
|
||||||
})
|
})
|
||||||
.isChecked(),
|
.isChecked(),
|
||||||
@@ -235,20 +342,68 @@ try {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
await page.getByRole('button', { name: 'Create now', exact: true }).click();
|
||||||
await dialog.waitFor({ state: 'hidden' });
|
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');
|
||||||
|
await dialog.waitFor({ state: 'hidden' });
|
||||||
|
await visit();
|
||||||
const linkSaved = page.waitForResponse(
|
const linkSaved = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/tickets/42/links') &&
|
response.url().includes('/tickets/42/links') &&
|
||||||
response.request().method() === 'POST',
|
response.request().method() === 'POST',
|
||||||
);
|
);
|
||||||
|
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
|
||||||
await page.getByRole('button', { name: 'Save link', exact: true }).click();
|
await page.getByRole('button', { name: 'Save link', exact: true }).click();
|
||||||
assert.equal((await linkSaved).status(), 303);
|
assert.equal((await linkSaved).status(), 303);
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||||
|
.waitFor();
|
||||||
const linkRemoved = page.waitForResponse(
|
const linkRemoved = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
|
response.url().includes('/tickets/42/links/3') &&
|
||||||
response.request().method() === 'POST',
|
response.request().method() === 'DELETE',
|
||||||
);
|
);
|
||||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||||
|
.click();
|
||||||
assert.equal((await linkRemoved).status(), 303);
|
assert.equal((await linkRemoved).status(), 303);
|
||||||
await visit('?readonly&hideAuthor&hidePoints');
|
await visit('?readonly&hideAuthor&hidePoints');
|
||||||
assert.equal(await content().locator('form').count(), 0);
|
assert.equal(await content().locator('form').count(), 0);
|
||||||
@@ -262,7 +417,12 @@ try {
|
|||||||
.count(),
|
.count(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
|
await tabTo(
|
||||||
|
content().getByRole('link', {
|
||||||
|
name: 'ENG-41 Related infrastructure',
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForURL('**/tickets/41');
|
await page.waitForURL('**/tickets/41');
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const repository = {
|
|||||||
provider_label: 'Gitea',
|
provider_label: 'Gitea',
|
||||||
base_url: 'https://git.example.test',
|
base_url: 'https://git.example.test',
|
||||||
repository_full_name: 'Engineering/platform',
|
repository_full_name: 'Engineering/platform',
|
||||||
|
repository_url: 'https://git.example.test/Engineering/platform',
|
||||||
has_personal_access: true,
|
has_personal_access: true,
|
||||||
};
|
};
|
||||||
const parent = {
|
const parent = {
|
||||||
@@ -108,9 +109,18 @@ const ticket = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
worklog_entries: [
|
worklog_entries: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
actor: null,
|
||||||
|
actor_id: null,
|
||||||
|
entry_type: 'status_changed',
|
||||||
|
summary: 'Moved from Backlog to Delivery / In progress.',
|
||||||
|
created_at: '2026-09-11T13:00:00Z',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
actor: user,
|
actor: user,
|
||||||
|
actor_id: user.id,
|
||||||
entry_type: 'created',
|
entry_type: 'created',
|
||||||
summary: 'Ticket created in the project backlog.',
|
summary: 'Ticket created in the project backlog.',
|
||||||
created_at: '2026-09-11T12:00:00Z',
|
created_at: '2026-09-11T12:00:00Z',
|
||||||
@@ -182,30 +192,35 @@ function makePage(url) {
|
|||||||
encryptHistory: false,
|
encryptHistory: false,
|
||||||
props: {
|
props: {
|
||||||
name: 'KanbanGit',
|
name: 'KanbanGit',
|
||||||
errors: params.has('rejected')
|
errors: params.has('linkRejected')
|
||||||
? {
|
? {
|
||||||
title: 'This title is not allowed.',
|
ticket_link_type_id: 'Invalid relationship type.',
|
||||||
...(params.has('allErrors')
|
linked_ticket_id: 'Choose another ticket.',
|
||||||
? {
|
|
||||||
description: 'Invalid description.',
|
|
||||||
ticket_type_id: 'Invalid type.',
|
|
||||||
ticket_priority_id: 'Invalid priority.',
|
|
||||||
ticket_status_id: 'Invalid status.',
|
|
||||||
assignee_user_id: 'Invalid assignee.',
|
|
||||||
author_user_id: 'Invalid author.',
|
|
||||||
parent_ticket_id: 'Invalid parent.',
|
|
||||||
story_points: 'Invalid points.',
|
|
||||||
'acceptance_criteria.0.text':
|
|
||||||
'Invalid checklist.',
|
|
||||||
blocked_comment: 'Invalid blocker.',
|
|
||||||
'field_values.0.value_number':
|
|
||||||
'Invalid number.',
|
|
||||||
'field_values.1.value_user_id':
|
|
||||||
'Invalid reviewer.',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
}
|
||||||
: {},
|
: params.has('rejected')
|
||||||
|
? {
|
||||||
|
title: 'This title is not allowed.',
|
||||||
|
...(params.has('allErrors')
|
||||||
|
? {
|
||||||
|
description: 'Invalid description.',
|
||||||
|
ticket_type_id: 'Invalid type.',
|
||||||
|
ticket_priority_id: 'Invalid priority.',
|
||||||
|
ticket_status_id: 'Invalid status.',
|
||||||
|
assignee_user_id: 'Invalid assignee.',
|
||||||
|
author_user_id: 'Invalid author.',
|
||||||
|
parent_ticket_id: 'Invalid parent.',
|
||||||
|
story_points: 'Invalid points.',
|
||||||
|
'acceptance_criteria.0.text':
|
||||||
|
'Invalid checklist.',
|
||||||
|
blocked_comment: 'Invalid blocker.',
|
||||||
|
'field_values.0.value_number':
|
||||||
|
'Invalid number.',
|
||||||
|
'field_values.1.value_user_id':
|
||||||
|
'Invalid reviewer.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
auth: {
|
auth: {
|
||||||
user,
|
user,
|
||||||
@@ -250,25 +265,45 @@ function makePage(url) {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
...linked,
|
...linked,
|
||||||
ticket_type: { is_parent: false },
|
ticket_type_name: 'Epic',
|
||||||
children: [],
|
children: [
|
||||||
|
{
|
||||||
|
id: 43,
|
||||||
|
ticket_key: 'ENG-43',
|
||||||
|
title: 'Nested child with a long title that must wrap on mobile',
|
||||||
|
ticket_type_name: 'Task',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
children_count: 0,
|
children_count: 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
linkedTickets: params.has('empty')
|
linkedTickets:
|
||||||
|
params.has('empty') || params.has('noLinks')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
source_ticket_id: params.has('incoming')
|
||||||
|
? 41
|
||||||
|
: 42,
|
||||||
|
target_ticket_id: params.has('incoming')
|
||||||
|
? 42
|
||||||
|
: 41,
|
||||||
|
ticket_link_type: {
|
||||||
|
id: 1,
|
||||||
|
label: params.has('incoming')
|
||||||
|
? 'Blocked by'
|
||||||
|
: 'Blocks',
|
||||||
|
},
|
||||||
|
linked_ticket: linked,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
|
||||||
|
ticketLinkTypes: params.has('noTypes')
|
||||||
? []
|
? []
|
||||||
: [
|
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
ticket_link_type: { id: 1, label: 'Blocks' },
|
|
||||||
linked_ticket: linked,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
ticketLinkOptions: [linked],
|
|
||||||
ticketLinkTypes: [
|
|
||||||
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
|
||||||
],
|
|
||||||
repositoryBranches: params.has('empty')
|
repositoryBranches: params.has('empty')
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
@@ -317,9 +352,12 @@ if (serve) {
|
|||||||
for await (const chunk of request) {
|
for await (const chunk of request) {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
}
|
}
|
||||||
const data = JSON.parse(body);
|
const data = body ? JSON.parse(body) : {};
|
||||||
const previous = new URL(request.headers.referer);
|
const previous = new URL(request.headers.referer);
|
||||||
const params = previous.searchParams;
|
const params = previous.searchParams;
|
||||||
|
if (request.url.includes('/branches') && params.has('slowBranch')) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
if (params.has('serverError')) {
|
if (params.has('serverError')) {
|
||||||
response.writeHead(503);
|
response.writeHead(503);
|
||||||
response.end('Simulated failure');
|
response.end('Simulated failure');
|
||||||
@@ -350,8 +388,17 @@ if (serve) {
|
|||||||
} else if (request.url.includes('/links')) {
|
} else if (request.url.includes('/links')) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
request.url.endsWith('/links/3?_method=DELETE') ||
|
request.url.endsWith('/links/3?_method=DELETE') ||
|
||||||
|
request.method === 'DELETE' ||
|
||||||
Number(data.linked_ticket_id) === 41,
|
Number(data.linked_ticket_id) === 41,
|
||||||
);
|
);
|
||||||
|
if (params.has('rejectLink')) {
|
||||||
|
params.set('linkRejected', '1');
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
params.set('noLinks', '1');
|
||||||
|
} else {
|
||||||
|
params.delete('noLinks');
|
||||||
|
params.delete('linkRejected');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
(request.method === 'POST' || 'field_values' in data) &&
|
(request.method === 'POST' || 'field_values' in data) &&
|
||||||
@@ -361,7 +408,19 @@ if (serve) {
|
|||||||
if (params.has('reject')) {
|
if (params.has('reject')) {
|
||||||
params.set('rejected', '1');
|
params.set('rejected', '1');
|
||||||
} else {
|
} else {
|
||||||
savedForm = data;
|
savedForm = {
|
||||||
|
...data,
|
||||||
|
blocked_comment: data.is_blocked
|
||||||
|
? data.blocked_comment
|
||||||
|
: null,
|
||||||
|
field_values: data.field_values.map((fieldValue) => ({
|
||||||
|
...fieldValue,
|
||||||
|
user_value:
|
||||||
|
Number(fieldValue.value_user_id) === user.id
|
||||||
|
? user
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
params.set('saved', data.title);
|
params.set('saved', data.title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,6 +501,9 @@ if (serve) {
|
|||||||
'feature/ENG-42-cache',
|
'feature/ENG-42-cache',
|
||||||
'Cache implementation',
|
'Cache implementation',
|
||||||
'Ticket created in the project backlog.',
|
'Ticket created in the project backlog.',
|
||||||
|
'Ticket created',
|
||||||
|
'Status changed',
|
||||||
|
'System',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes(text),
|
html.includes(text),
|
||||||
@@ -452,6 +514,7 @@ if (serve) {
|
|||||||
'description',
|
'description',
|
||||||
'checklist',
|
'checklist',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'blocker',
|
||||||
'relationships',
|
'relationships',
|
||||||
'history',
|
'history',
|
||||||
'git',
|
'git',
|
||||||
@@ -478,7 +541,6 @@ if (serve) {
|
|||||||
'story_points',
|
'story_points',
|
||||||
'parent_ticket_id',
|
'parent_ticket_id',
|
||||||
'description',
|
'description',
|
||||||
'acceptance_criteria_checklist',
|
|
||||||
'is_blocked',
|
'is_blocked',
|
||||||
'blocked_comment',
|
'blocked_comment',
|
||||||
'field-1',
|
'field-1',
|
||||||
@@ -489,8 +551,27 @@ if (serve) {
|
|||||||
`Missing editor: ${field}`,
|
`Missing editor: ${field}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.match(html, /aria-label="Criterion 1"/);
|
||||||
|
assert.match(html, /Add criterion/);
|
||||||
assert.match(html, /Create branch/);
|
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, /Save link/);
|
||||||
|
assert.match(html, /Outgoing/);
|
||||||
|
const incoming = await render('?incoming');
|
||||||
|
assert.match(incoming, /Incoming/);
|
||||||
|
assert.match(incoming, /Blocked by/);
|
||||||
|
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||||
|
assert.match(
|
||||||
|
await render('?noTargets'),
|
||||||
|
/No other tickets are available/,
|
||||||
|
);
|
||||||
assert.match(html, /Remove/);
|
assert.match(html, /Remove/);
|
||||||
const readOnly = await render('?readonly');
|
const readOnly = await render('?readonly');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
@@ -505,23 +586,28 @@ if (serve) {
|
|||||||
const empty = await render('?empty');
|
const empty = await render('?empty');
|
||||||
for (const text of [
|
for (const text of [
|
||||||
'No description has been added yet.',
|
'No description has been added yet.',
|
||||||
'No acceptance criteria have been added yet.',
|
'No acceptance criteria yet.',
|
||||||
'No status',
|
'No status',
|
||||||
'Unassigned',
|
'Unassigned',
|
||||||
'No author',
|
'No author',
|
||||||
'Not set',
|
'Not set',
|
||||||
'No matching branches',
|
'No linked repositories',
|
||||||
'No matching pull requests',
|
|
||||||
'No worklog entries exist',
|
'No worklog entries exist',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||||
}
|
}
|
||||||
assert.match(await render('?parent'), /Child tickets/);
|
const hierarchy = await render('?parent');
|
||||||
assert.match(await render('?noAccess'), /Open Git connections/);
|
assert.match(hierarchy, /Child tickets/);
|
||||||
assert.match(
|
assert.match(hierarchy, /Platform delivery/);
|
||||||
await render('?gitError'),
|
assert.match(hierarchy, /Nested child/);
|
||||||
/Repository temporarily unavailable/,
|
assert.match(hierarchy, /Change parent/);
|
||||||
);
|
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);
|
const create = await render('?source=board', true);
|
||||||
assert.match(create, /Create ticket/);
|
assert.match(create, /Create ticket/);
|
||||||
assert.match(create, /Delivery \/ In progress/);
|
assert.match(create, /Delivery \/ In progress/);
|
||||||
@@ -540,6 +626,10 @@ if (serve) {
|
|||||||
assert.match(unavailable, /Ticket creation needs at least one type/);
|
assert.match(unavailable, /Ticket creation needs at least one type/);
|
||||||
for (const creating of [false, true]) {
|
for (const creating of [false, true]) {
|
||||||
const invalid = await render('?rejected&allErrors', creating);
|
const invalid = await render('?rejected&allErrors', creating);
|
||||||
|
assert.match(
|
||||||
|
invalid,
|
||||||
|
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
|
||||||
|
);
|
||||||
for (const id of [
|
for (const id of [
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
@@ -548,7 +638,6 @@ if (serve) {
|
|||||||
'assignee_user_id',
|
'assignee_user_id',
|
||||||
'parent_ticket_id',
|
'parent_ticket_id',
|
||||||
'story_points',
|
'story_points',
|
||||||
'acceptance_criteria_checklist',
|
|
||||||
]) {
|
]) {
|
||||||
assert.ok(invalid.includes(`id="${id}-error"`));
|
assert.ok(invalid.includes(`id="${id}-error"`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ page.setDefaultTimeout(10000);
|
|||||||
const base = 'http://127.0.0.1:4185/projects/7/tickets';
|
const base = 'http://127.0.0.1:4185/projects/7/tickets';
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', (error) => errors.push(error.message));
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
const field = (label) => page.getByLabel(label, { exact: true });
|
const field = (label) => form().getByLabel(label, { exact: true });
|
||||||
const form = () => page.locator('form').first();
|
const form = () => page.locator('form').first();
|
||||||
const state = (value) =>
|
const state = (value) =>
|
||||||
form().locator(`[data-form-state="${value}"]`).waitFor();
|
form().locator(`[data-form-state="${value}"]`).waitFor();
|
||||||
@@ -29,9 +29,19 @@ async function fill(create) {
|
|||||||
await field('Assignee').selectOption('2');
|
await field('Assignee').selectOption('2');
|
||||||
await field('Parent ticket').selectOption('40');
|
await field('Parent ticket').selectOption('40');
|
||||||
await field('Estimated story points').fill('0');
|
await field('Estimated story points').fill('0');
|
||||||
await field('Acceptance criteria checklist').fill(
|
if (create) {
|
||||||
'[ ] Main criterion\n [x] Nested criterion',
|
await button('Add criterion').click();
|
||||||
);
|
await button('Add criterion').click();
|
||||||
|
await button('Indent criterion 2').click();
|
||||||
|
}
|
||||||
|
await field('Criterion 1').fill('Main criterion');
|
||||||
|
await field('Criterion 2').fill('Nested criterion');
|
||||||
|
await form()
|
||||||
|
.getByRole('checkbox', {
|
||||||
|
name: 'Complete criterion 2: Nested criterion',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.check();
|
||||||
if (create) {
|
if (create) {
|
||||||
await field('Status').selectOption('4');
|
await field('Status').selectOption('4');
|
||||||
} else {
|
} else {
|
||||||
@@ -154,7 +164,7 @@ try {
|
|||||||
'Assignee',
|
'Assignee',
|
||||||
'Parent ticket',
|
'Parent ticket',
|
||||||
'Estimated story points',
|
'Estimated story points',
|
||||||
'Acceptance criteria checklist',
|
'Criterion 1',
|
||||||
...(create
|
...(create
|
||||||
? ['Status']
|
? ['Status']
|
||||||
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
|
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
|
||||||
@@ -231,12 +241,12 @@ try {
|
|||||||
.waitFor();
|
.waitFor();
|
||||||
if (create) {
|
if (create) {
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Create ticket', exact: true })
|
||||||
|
.waitFor();
|
||||||
await field('Title').waitFor();
|
await field('Title').waitFor();
|
||||||
assert.equal(await field('Title').inputValue(), '');
|
assert.equal(await field('Title').inputValue(), '');
|
||||||
assert.equal(
|
assert.equal(await field('Criterion 1').count(), 0);
|
||||||
await field('Acceptance criteria checklist').inputValue(),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await form()
|
await form()
|
||||||
.getByText('All changes saved', { exact: true })
|
.getByText('All changes saved', { exact: true })
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
|
||||||
|
const section = () => page.locator('#ticket-relationships');
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
async function visit(query = '') {
|
||||||
|
await page.goto(base + query);
|
||||||
|
await section().waitFor();
|
||||||
|
}
|
||||||
|
const control = (label) => section().getByLabel(label, { exact: true });
|
||||||
|
const save = () =>
|
||||||
|
section().getByRole('button', { name: 'Save link', exact: true });
|
||||||
|
const remove = () =>
|
||||||
|
section().getByRole('button', {
|
||||||
|
name: 'Remove link to ENG-41',
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: 'reduce',
|
||||||
|
});
|
||||||
|
await visit('?parent&incoming');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((el) => el.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
for (const text of [
|
||||||
|
'Platform delivery',
|
||||||
|
'Child tickets',
|
||||||
|
'Nested child with a long title that must wrap on mobile',
|
||||||
|
'Incoming',
|
||||||
|
'Blocked by',
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByText(text, { exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const collapse = section().getByRole('button', {
|
||||||
|
name: 'Collapse children of ENG-41',
|
||||||
|
});
|
||||||
|
await collapse.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByText(
|
||||||
|
'Nested child with a long title that must wrap on mobile',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
|
await control('Link type').focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await control('Linked ticket').evaluate(
|
||||||
|
(el) =>
|
||||||
|
el === document.activeElement &&
|
||||||
|
el.matches(':focus-visible'),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await section()
|
||||||
|
.locator('form')
|
||||||
|
.last()
|
||||||
|
.getByText('ENG-42', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Overflow ${width}/${theme}`,
|
||||||
|
);
|
||||||
|
await section().screenshot({
|
||||||
|
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
|
||||||
|
});
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('#parent_ticket_id')
|
||||||
|
.evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#ticket-edit').evaluate((el) => el.open),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?noLinks');
|
||||||
|
assert.equal(await save().isDisabled(), true);
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
let request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||||
|
);
|
||||||
|
await save().click();
|
||||||
|
assert.deepEqual((await request).postDataJSON(), {
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
|
);
|
||||||
|
request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
|
||||||
|
);
|
||||||
|
await remove().click();
|
||||||
|
await request;
|
||||||
|
await section()
|
||||||
|
.getByText('No ticket links are configured for this ticket yet.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await visit('?rejectLink');
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await save().click();
|
||||||
|
await section()
|
||||||
|
.getByText('Choose another ticket.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
|
for (const label of ['Link type', 'Linked ticket']) {
|
||||||
|
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||||
|
}
|
||||||
|
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||||
|
await save().click();
|
||||||
|
await section().locator('[data-form-state="network"]').waitFor();
|
||||||
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
|
await page.unrouteAll();
|
||||||
|
|
||||||
|
await visit('?serverError');
|
||||||
|
await remove().click();
|
||||||
|
await section().locator('[data-form-state="server"]').waitFor();
|
||||||
|
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
|
||||||
|
let pending;
|
||||||
|
await page.route('**/tickets/42/links/3', (route) => {
|
||||||
|
pending = route;
|
||||||
|
});
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await section().locator('[data-form-state="loading"]').waitFor();
|
||||||
|
assert.equal(await remove().isDisabled(), true);
|
||||||
|
await pending.abort();
|
||||||
|
await section().locator('[data-form-state="network"]').waitFor();
|
||||||
|
await page.unrouteAll();
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
|
||||||
|
request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save changes', exact: true })
|
||||||
|
.click();
|
||||||
|
assert.equal((await request).postDataJSON().parent_ticket_id, '');
|
||||||
|
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||||
|
|
||||||
|
await visit('?readonly&parent&incoming');
|
||||||
|
assert.equal(await section().locator('form').count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.getByRole('button', { name: 'Change parent', exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await section().getByText('Blocked by', { exact: true }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const [query, message] of [
|
||||||
|
['?noTypes', 'No link types are configured for this project.'],
|
||||||
|
[
|
||||||
|
'?noTargets',
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await visit(query);
|
||||||
|
assert.equal(
|
||||||
|
await section().getByText(message, { exact: true }).isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(await save().count(), 0);
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user