Compare commits

...
8 Commits
Author SHA1 Message Date
Twyco fed0b657c0 Merge pull request 'Feature/kan 33 ticketbeziehungen und hierarchie' (#128) from feature/KAN-33-ticketbeziehungen-und-hierarchie into main
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 20m52s
CI/CD / Publish immutable image (push) Successful in 3m39s
Reviewed-on: #128
2026-09-14 09:45:54 +00:00
Twyco 1fe0c0b2d9 feat(KAN-33): redesign ticket relationships and hierarchy
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 5s
2026-09-14 09:45:45 +00:00
Twyco 463cb7fe36 Merge pull request 'feat(KAN-32): add hierarchical acceptance criteria checklist' (#127) from feature/KAN-32-akzeptanzkriterien-checkliste into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #127
2026-09-14 09:45:39 +00:00
Twyco 3bf5a0c508 feat(KAN-32): add hierarchical acceptance criteria checklist
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Canceled after 31s
2026-09-14 09:45:28 +00:00
Twyco 32de1f425a docker test env fix
CI/CD / preconditions (push) Successful in 1m2s
CI/CD / test_and_check_app (push) Successful in 11m39s
CI/CD / Publish immutable image (push) Successful in 3m47s
2026-09-14 10:52:07 +02:00
Twyco 1af98cd56e docker env example fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Failing after 1m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:48:29 +02:00
Twyco aadf159ac5 docker test fix
CI/CD / preconditions (push) Successful in 1m1s
CI/CD / test_and_check_app (push) Failing after 3m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-14 10:36:56 +02:00
Twyco 5c47453d4d rollback test app action
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Failing after 8m23s
CI/CD / Publish immutable image (push) Skipped
2026-09-12 14:23:18 +02:00
21 changed files with 1609 additions and 559 deletions
-3
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
MARIADB_DATABASE=kanbangit
MARIADB_USER=kanbangit
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=
+12 -66
View File
@@ -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
View File
@@ -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>
@@ -3,9 +3,10 @@ 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 } 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';
@@ -13,33 +14,36 @@ import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import { Label } from '@/components/ui/label'; 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 +51,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 +81,10 @@ const savedFormData = () => ({
const form = useForm(draftKey, savedFormData()); const form = useForm(draftKey, savedFormData());
const acceptanceCriteriaPreviewItems = computed(
() => savedAcceptanceCriteria.value,
);
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 +103,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 +120,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 +159,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 +193,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 +214,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 +227,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 +269,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 +328,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>
@@ -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,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>
+70
View File
@@ -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;
}; };
+36
View File
@@ -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.',
@@ -493,6 +512,23 @@ 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.',
+4 -1
View File
@@ -1,6 +1,6 @@
<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 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 +14,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 +50,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"
@@ -80,6 +82,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" />
+19 -71
View File
@@ -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
@@ -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();
}
+103
View File
@@ -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();
}
+18 -10
View File
@@ -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();
+15 -2
View File
@@ -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: [],
+39 -18
View File
@@ -105,22 +105,30 @@ try {
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 +163,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 +202,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(),
@@ -240,15 +251,20 @@ try {
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 +278,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
+92 -41
View File
@@ -182,30 +182,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 +255,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,7 +342,7 @@ 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 (params.has('serverError')) { if (params.has('serverError')) {
@@ -350,8 +375,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) &&
@@ -478,7 +512,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 +522,19 @@ 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, /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,7 +549,7 @@ 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',
@@ -516,7 +560,11 @@ if (serve) {
]) { ]) {
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(hierarchy, /Child tickets/);
assert.match(hierarchy, /Platform delivery/);
assert.match(hierarchy, /Nested child/);
assert.match(hierarchy, /Change parent/);
assert.match(await render('?noAccess'), /Open Git connections/); assert.match(await render('?noAccess'), /Open Git connections/);
assert.match( assert.match(
await render('?gitError'), await render('?gitError'),
@@ -540,6 +588,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 +600,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"`));
} }
+19 -9
View File
@@ -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();
}