Compare commits

..
10 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
Twyco ba7e174e82 Merge pull request 'Feature/kan 31 ticket create edit forms' (#126) from feature/KAN-31-ticket-create-edit-forms into main
CI/CD / Publish immutable image (push) Successful in 4m13s
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 8m40s
Reviewed-on: #126
2026-09-12 11:34:10 +00:00
Eric d646f8b537 feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-12 13:33:53 +02:00
25 changed files with 2584 additions and 1079 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>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type { ProjectTicketCreatePageProps } from '@/types';
const props = defineProps<
Pick<
ProjectTicketCreatePageProps,
| 'project'
| 'ticketTypes'
| 'ticketPriorities'
| 'users'
| 'parentTicketOptions'
> & {
errors: Record<string, string>;
disabled?: boolean;
autofocus?: boolean;
ticketStatusOptions?: ProjectTicketCreatePageProps['ticketStatusOptions'];
}
>();
const title = defineModel<string>('title', { required: true });
const description = defineModel<string>('description', { required: true });
const type = defineModel<string | number>('type', { required: true });
const priority = defineModel<string | number>('priority', { required: true });
const assignee = defineModel<string | number>('assignee', { required: true });
const parent = defineModel<string | number>('parent', { required: true });
const points = defineModel<string | number | null>('points', {
required: true,
});
const status = defineModel<string | number>('status');
const { t } = useLocalization();
const typeOptions = computed(() =>
props.ticketTypes.map((item) => ({ value: item.id, label: item.name })),
);
const priorityOptions = computed(() =>
props.ticketPriorities.map((item) => ({
value: item.id,
label: item.name,
})),
);
const userOptions = computed(() => [
{ value: '', label: t('tickets.unassigned') },
...props.users.map((item) => ({ value: item.id, label: item.name })),
]);
const parentOptions = computed(() => [
{ value: '', label: t('ticketDetail.noParent') },
...props.parentTicketOptions.map((item) => ({
value: item.id,
label: `${item.ticket_key} - ${item.title}`,
})),
]);
const statusOptions = computed(
() =>
props.ticketStatusOptions?.map((item) => ({
value: item.id,
label: ticketStatusLabel(item, t('ticketDetail.notSet')),
})) ?? [],
);
</script>
<template>
<div class="grid min-w-0 gap-6">
<TicketFormField
id="title"
v-model="title"
:label="t('ticketDetail.title')"
:error="errors.title"
:disabled="disabled"
:autofocus="autofocus"
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
:placeholder="t('ticketForm.descriptionPlaceholder')"
/>
<slot name="content" />
<fieldset
class="bg-muted/20 grid min-w-0 gap-5 rounded-xl border p-4 sm:p-5"
:disabled="disabled"
>
<legend class="px-2 text-sm font-semibold">
{{ t('ticketDetail.metadata') }}
</legend>
<div class="grid min-w-0 gap-5 sm:grid-cols-2">
<TicketFormField
id="ticket_type_id"
v-model="type"
kind="select"
:label="t('ticketDetail.type')"
:options="typeOptions"
:error="errors.ticket_type_id"
:disabled="disabled"
required
/>
<TicketFormField
id="ticket_priority_id"
v-model="priority"
kind="select"
:label="t('ticketDetail.priority')"
:options="priorityOptions"
:error="errors.ticket_priority_id"
:disabled="disabled"
required
/>
<TicketFormField
v-if="ticketStatusOptions && status !== undefined"
id="ticket_status_id"
v-model="status"
kind="select"
:label="t('ticketDetail.status')"
:options="statusOptions"
:error="errors.ticket_status_id"
:disabled="disabled"
required
/>
<TicketFormField
id="assignee_user_id"
v-model="assignee"
kind="select"
:label="t('ticketDetail.assignee')"
:options="userOptions"
:error="errors.assignee_user_id"
:disabled="disabled"
/>
<TicketFormField
id="parent_ticket_id"
v-model="parent"
kind="select"
:label="t('ticketDetail.parent')"
:options="parentOptions"
:error="errors.parent_ticket_id"
:disabled="disabled"
/>
<TicketFormField
v-if="project.show_story_points"
id="story_points"
v-model="points"
kind="number"
:label="t('ticketDetail.storyPoints')"
:error="errors.story_points"
:disabled="disabled"
:min="0"
step="1"
/>
<slot name="metadata" />
</div>
</fieldset>
</div>
</template>
@@ -3,51 +3,56 @@ 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 { Input } from '@/components/ui/input'; import TicketFormField from '@/components/tickets/TicketFormField.vue';
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),
); );
const isPersistingAcceptanceCriteriaToggle = ref(false); const isPersistingAcceptanceCriteriaToggle = ref(false);
const acceptanceCriteriaToggleError = ref(''); const acceptanceCriteriaToggleError = ref('');
const form = useForm(draftKey, { 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 ?? '',
@@ -74,21 +79,36 @@ const form = useForm(draftKey, {
story_points: props.ticket.story_points, story_points: props.ticket.story_points,
}); });
const acceptanceCriteriaPreviewItems = computed( const form = useForm(draftKey, savedFormData());
() => savedAcceptanceCriteria.value,
);
const hasUnsavedAcceptanceCriteriaDraft = computed( const hasUnsavedAcceptanceCriteriaDraft = computed(
() => () =>
acceptanceCriteriaChecklist.value !== JSON.stringify(acceptanceCriteriaItems.value) !==
formatChecklistLines(savedAcceptanceCriteria.value), JSON.stringify(savedAcceptanceCriteria.value),
); );
const protection = useFormProtection(form, { const isDirty = computed(
isDirty: () => () =>
props.canEditTicket && props.canEditTicket &&
(form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value), (form.isDirty || hasUnsavedAcceptanceCriteriaDraft.value),
}); );
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
const reset = (): void => {
if (
form.processing ||
isPersistingAcceptanceCriteriaToggle.value ||
!protection.confirmLeave()
) {
return;
}
form.reset();
form.clearErrors();
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
savedAcceptanceCriteria.value,
);
acceptanceCriteriaToggleError.value = '';
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -100,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,
) => ({ ) => ({
@@ -140,6 +147,7 @@ const persistAcceptanceCriteriaToggle = async (
): Promise<void> => { ): Promise<void> => {
if ( if (
!props.canEditTicket || !props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value || isPersistingAcceptanceCriteriaToggle.value ||
hasUnsavedAcceptanceCriteriaDraft.value hasUnsavedAcceptanceCriteriaDraft.value
) { ) {
@@ -151,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');
@@ -189,38 +193,28 @@ const persistAcceptanceCriteriaToggle = async (
savedAcceptanceCriteria.value = nextAcceptanceCriteria; savedAcceptanceCriteria.value = nextAcceptanceCriteria;
if (acceptanceCriteriaChecklist.value === previousChecklist) { acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
acceptanceCriteriaChecklist.value = nextChecklist; 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 (!props.canEditTicket) { if (
!props.canEditTicket ||
form.processing ||
isPersistingAcceptanceCriteriaToggle.value
) {
return; return;
} }
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit(); form.acceptance_criteria = acceptanceCriteriaItems.value;
protection.submit( protection.submit(
ProjectTicketController.update({ ProjectTicketController.update({
@@ -233,9 +227,11 @@ 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());
form.defaults();
}, },
}, },
); );
@@ -273,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
@@ -359,339 +299,217 @@ const submit = (): void => {
> >
{{ t('ticketDetail.edit') }} {{ t('ticketDetail.edit') }}
</summary> </summary>
<div class="border-t p-6"> <div class="border-t p-4 sm:p-6">
<p class="text-muted-foreground mb-6 text-sm"> <p class="text-muted-foreground mb-6 text-sm">
{{ t('ticketDetail.editDescription') }} {{ t('ticketDetail.editDescription') }}
</p> </p>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid min-w-0 gap-6" @submit.prevent="submit">
<FormFeedback <FormFeedback
:state="protection.state.value" :state="protection.state.value"
@retry="submit" @retry="submit"
/> />
<div class="grid min-w-0 gap-2"> <TicketFormFields
<Label for="title">{{ t('ticketDetail.title') }}</Label> v-model:title="form.title"
<Input v-model:description="form.description"
id="title" v-model:type="form.ticket_type_id"
v-model="form.title" v-model:priority="form.ticket_priority_id"
:invalid="Boolean(form.errors.title)" v-model:assignee="form.assignee_user_id"
aria-describedby="title-error" v-model:parent="form.parent_ticket_id"
:disabled="!canEditTicket || form.processing" v-model:points="form.story_points"
/> :project="project"
<InputError :ticket-types="ticketTypes"
id="title-error" :ticket-priorities="ticketPriorities"
:message="form.errors.title" :users="users"
/> :parent-ticket-options="parentTicketOptions"
</div> :errors="form.errors"
:disabled="
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> form.processing ||
<div class="grid min-w-0 gap-2"> isPersistingAcceptanceCriteriaToggle
<Label for="ticket_type_id">{{ "
t('ticketDetail.type') >
}}</Label> <template #content>
<select <AcceptanceCriteriaChecklist
id="ticket_type_id" v-model="acceptanceCriteriaItems"
v-model="form.ticket_type_id" :disabled="
:disabled="!canEditTicket || form.processing" form.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" isPersistingAcceptanceCriteriaToggle
> "
<option :error="acceptanceCriteriaError"
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError :message="form.errors.ticket_type_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="ticket_priority_id">{{
t('ticketDetail.priority')
}}</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="!canEditTicket || form.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="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_priority_id"
/> />
</div> </template>
</div> <template #metadata>
<TicketFormField
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> v-if="canViewAuthor"
<div class="grid min-w-0 gap-2">
<Label for="assignee_user_id">{{
t('ticketDetail.assignee')
}}</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="!canEditTicket || form.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 value="">
{{ t('tickets.unassigned') }}
</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div v-if="canViewAuthor" class="grid min-w-0 gap-2">
<Label for="author_user_id">{{
t('ticketDetail.author')
}}</Label>
<select
id="author_user_id" id="author_user_id"
v-model="form.author_user_id" v-model="form.author_user_id"
:disabled="!canEditAuthor || form.processing" kind="select"
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" :label="t('ticketDetail.author')"
> :hint="
<option value=""> !canEditAuthor
{{ t('ticketDetail.noAuthor') }} ? t('ticketForm.authorRestricted')
</option> : undefined
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.author_user_id" />
</div>
</div>
<div
class="grid min-w-0 gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">{{
t('ticketDetail.storyPoints')
}}</Label>
<Input
id="story_points"
name="story_points"
:disabled="!canEditTicket || form.processing"
v-model="form.story_points"
type="number"
step="1"
/>
<InputError :message="form.errors.story_points" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="parent_ticket_id">{{
t('ticketDetail.parent')
}}</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="!canEditTicket || form.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 value="">
{{ t('ticketDetail.noParent') }}
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError :message="form.errors.parent_ticket_id" />
</div>
<div class="grid min-w-0 gap-2">
<Label for="description">{{
t('ticketDetail.description')
}}</Label>
<textarea
id="description"
v-model="form.description"
rows="5"
:disabled="!canEditTicket || form.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"
/>
<InputError :message="form.errors.description" />
</div>
<div class="grid gap-4">
<div class="grid min-w-0 gap-2">
<Label for="acceptance_criteria_checklist">{{
t('ticketDetail.checklistEditor')
}}</Label>
<textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
rows="5"
:disabled="!canEditTicket || form.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"
:placeholder="
t('ticketDetail.checklistPlaceholder')
" "
:options="[
{
value: '',
label: t('ticketDetail.noAuthor'),
},
...users.map((user) => ({
value: user.id,
label: user.name,
})),
]"
:error="form.errors.author_user_id"
:disabled="!canEditAuthor || form.processing"
/> />
<p class="text-muted-foreground text-xs"> </template>
{{ t('ticketDetail.checklistHelp') }} </TicketFormFields>
</p> <fieldset
<p class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
v-if="hasUnsavedAcceptanceCriteriaDraft" :disabled="
class="text-muted-foreground text-xs" form.processing ||
> isPersistingAcceptanceCriteriaToggle
{{ t('ticketDetail.saveChecklistFirst') }} "
</p> >
<InputError :message="acceptanceCriteriaError" /> <legend class="px-2 text-sm font-semibold">
<InputError {{ t('ticketForm.blocker') }}
:message="acceptanceCriteriaToggleError" </legend>
/> <div class="min-h-control flex items-center gap-3">
</div>
</div>
<div class="border-border grid gap-3 rounded-xl border p-4">
<div class="flex items-center gap-3">
<input <input
id="is_blocked" id="is_blocked"
v-model="form.is_blocked" v-model="form.is_blocked"
type="checkbox" type="checkbox"
:disabled="!canEditTicket || form.processing" :aria-invalid="Boolean(form.errors.is_blocked)"
class="border-input text-primary focus:ring-ring size-4 rounded" 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">{{ <Label for="is_blocked">{{
t('ticketDetail.blocked') t('ticketDetail.blocked')
}}</Label> }}</Label>
</div> </div>
<InputError
<div class="grid min-w-0 gap-2"> id="is_blocked-error"
<Label for="blocked_comment">{{ :message="form.errors.is_blocked"
t('ticketDetail.blockerComment') />
}}</Label> <TicketFormField
<textarea id="blocked_comment"
id="blocked_comment" v-model="form.blocked_comment"
v-model="form.blocked_comment" kind="textarea"
rows="3" :rows="3"
:disabled="!canEditTicket || form.processing" :label="t('ticketDetail.blockerComment')"
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" :placeholder="t('ticketDetail.blockerPlaceholder')"
:placeholder=" :error="form.errors.blocked_comment"
t('ticketDetail.blockerPlaceholder') :required="form.is_blocked"
" />
/> </fieldset>
<InputError <fieldset
:message="form.errors.blocked_comment" class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
/> :disabled="
</div> form.processing ||
</div> isPersistingAcceptanceCriteriaToggle
"
<div class="grid gap-4"> >
<div class="space-y-1"> <legend class="px-2 text-sm font-semibold">
<h3 class="text-sm font-semibold"> {{ t('ticketDetail.customFields') }}
{{ t('ticketDetail.customFields') }} </legend>
</h3> <p class="text-muted-foreground text-sm">
<p class="text-muted-foreground text-sm"> {{ t('ticketDetail.customFieldsDescription') }}
{{ t('ticketDetail.customFieldsDescription') }} </p>
</p> <InputError :message="form.errors.field_values" />
</div> <p
v-if="!ticketFieldDefinitions.length"
<div class="text-muted-foreground text-sm"
v-if="ticketFieldDefinitions.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
> >
{{ t('ticketDetail.noCustomFields') }} {{ t('ticketDetail.noCustomFields') }}
</div> </p>
<div v-else class="grid min-w-0 gap-5 sm:grid-cols-2">
<div v-else class="grid gap-4 md:grid-cols-2"> <template
<div
v-for="( v-for="(
ticketFieldDefinition, index definition, index
) in ticketFieldDefinitions" ) in ticketFieldDefinitions"
:key="ticketFieldDefinition.id" :key="definition.id"
class="grid min-w-0 gap-2"
> >
<Label <TicketFormField
:for="`field-${ticketFieldDefinition.id}`" v-if="definition.field_type === 'user'"
> :id="`field-${definition.id}`"
{{ ticketFieldDefinition.label }}
</Label>
<select
v-if="
ticketFieldDefinition.field_type ===
'user'
"
:id="`field-${ticketFieldDefinition.id}`"
v-model=" v-model="
form.field_values[index].value_user_id form.field_values[index].value_user_id
" "
:disabled=" kind="select"
!canEditTicket || form.processing :label="definition.label"
" :options="[
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" {
> value: '',
<option value=""> label: t('tickets.unassigned'),
{{ t('tickets.unassigned') }} },
</option> ...users.map((user) => ({
<option value: user.id,
v-for="user in users" label: user.name,
:key="user.id" })),
:value="user.id" ]"
> :error="
{{ user.name }}
</option>
</select>
<Input
v-else
:id="`field-${ticketFieldDefinition.id}`"
v-model="
form.field_values[index].value_number
"
type="number"
step="0.1"
:disabled="
!canEditTicket || form.processing
"
/>
<InputError
:message="
form.errors[ form.errors[
`field_values.${index}.value_user_id` `field_values.${index}.value_user_id`
] ?? ] ??
form.errors[ form.errors[
`field_values.${index}.value_number` `field_values.${index}.ticket_field_definition_id`
] ]
" "
/> />
</div> <TicketFormField
v-else
:id="`field-${definition.id}`"
v-model="
form.field_values[index].value_number
"
kind="number"
step="0.1"
:label="definition.label"
:error="
form.errors[
`field_values.${index}.value_number`
] ??
form.errors[
`field_values.${index}.ticket_field_definition_id`
]
"
/>
</template>
</div> </div>
</div> </fieldset>
<div
<div v-if="canEditTicket" class="flex items-center gap-3"> class="flex flex-wrap items-center gap-3 border-t pt-5"
<Button type="submit" :disabled="form.processing">{{ >
t('ticketDetail.saveChanges') <Button
}}</Button> type="submit"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle
"
>{{
form.processing
? t('ticketForm.saving')
: t('ticketDetail.saveChanges')
}}</Button
>
<Button
type="button"
variant="outline"
:disabled="
form.processing ||
isPersistingAcceptanceCriteriaToggle ||
!isDirty
"
@click="reset"
>{{ t('ticketForm.reset') }}</Button
>
<p role="status" class="text-muted-foreground text-sm">
{{
isDirty
? t('ticketForm.unsaved')
: t('ticketForm.saved')
}}
</p>
</div> </div>
</form> </form>
</div> </div>
@@ -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;
}; };
+60
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,9 +512,50 @@ 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.sourceBranch': 'Source branch: {name}', 'ticketDetail.sourceBranch': 'Source branch: {name}',
'ticketForm.create': 'Create ticket',
'ticketForm.pageTitle': 'Create {project} ticket',
'ticketForm.createDescription': 'Add a ticket to {project}.',
'ticketForm.backToBoard': 'Back to board',
'ticketForm.backToBacklog': 'Back to backlog',
'ticketForm.details': 'Ticket details',
'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
'ticketForm.titlePlaceholder': 'Implement repository sync',
'ticketForm.descriptionPlaceholder':
'Describe the expected behavior and relevant context.',
'ticketForm.checklistHelp':
'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
'ticketForm.checklistPreview': 'Checklist preview',
'ticketForm.metadataRequired':
'Ticket creation needs at least one type, one priority, and one status.',
'ticketForm.openSettings': 'Open ticket settings',
'ticketForm.creating': 'Creating ticket…',
'ticketForm.saving': 'Saving changes…',
'ticketForm.reset': 'Discard changes',
'ticketForm.unsaved': 'Unsaved changes',
'ticketForm.ready': 'Ready for a new ticket',
'ticketForm.saved': 'All changes saved',
'ticketForm.authorRestricted': 'Only project admins can change the author.',
'ticketForm.blocker': 'Blocker',
} as const; } as const;
+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" />
+120 -320
View File
@@ -1,9 +1,10 @@
<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 Heading from '@/components/Heading.vue'; import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
import InputError from '@/components/InputError.vue'; import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue'; import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection'; import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -15,32 +16,30 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
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';
import { metadata as metadataSettings } from '@/routes/projects/settings'; import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>(); const props = defineProps<ProjectTicketCreatePageProps>();
const { t } = useLocalization();
const returnHref = const returnHref =
props.source === 'board' props.source === 'board'
? boardIndex(props.project.id) ? boardIndex(props.project.id)
: backlogIndex(props.project.id); : backlogIndex(props.project.id);
const returnLabel = const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog'; props.source === 'board'
? t('ticketForm.backToBoard')
: t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Projects', title: t('navigation.projects'),
href: index(), href: index(),
}, },
{ {
@@ -48,36 +47,31 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id), href: show(props.project.id),
}, },
{ {
title: props.source === 'board' ? 'Board' : 'Backlog', title:
props.source === 'board'
? t('navigation.board')
: t('navigation.backlog'),
href: returnHref, href: returnHref,
}, },
{ {
title: 'Create ticket', title: t('ticketForm.create'),
href: ProjectTicketController.create({ href: ProjectTicketController.create({
project: props.project.id, project: props.project.id,
}), }),
}, },
]; ];
const hasTicketMetadata = const hasTicketMetadata = computed(
props.ticketTypes.length > 0 && () =>
props.ticketPriorities.length > 0 && props.ticketTypes.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0,
);
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: '',
@@ -90,13 +84,16 @@ const form = useForm(draftKey, {
title: '', title: '',
}); });
const protection = useFormProtection(form, { const isDirty = computed(() => form.isDirty);
isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '', const protection = useFormProtection(form);
}); const reset = (): void => {
if (form.processing || !protection.confirmLeave()) {
const acceptanceCriteriaPreviewItems = computed(() => return;
parseChecklistLines(acceptanceCriteriaChecklist.value), }
); form.reset();
form.clearErrors();
protection.clearFeedback();
};
const acceptanceCriteriaError = computed(() => { const acceptanceCriteriaError = computed(() => {
return ( return (
@@ -109,39 +106,35 @@ const acceptanceCriteriaError = computed(() => {
}); });
const submit = (): void => { const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria( if (!hasTicketMetadata.value || form.processing) {
acceptanceCriteriaChecklist.value, return;
); }
protection.submit( protection.submit(
ProjectTicketController.store({ project: props.project.id }), ProjectTicketController.store({ project: props.project.id }),
{ {
preserveScroll: true, preserveScroll: true,
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();
},
}, },
); );
}; };
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" /> <Head :title="t('ticketForm.pageTitle', { project: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div
class="mx-auto flex w-full min-w-0 max-w-5xl flex-1 flex-col gap-6 p-4 sm:p-6 lg:p-8"
>
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
@@ -149,10 +142,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Badge variant="outline" class="px-3 py-1"> <Badge variant="outline" class="px-3 py-1">
{{ project.key }} {{ project.key }}
</Badge> </Badge>
<Heading <h1 class="text-headline font-semibold tracking-tight">
title="Create ticket" {{ t('ticketForm.create') }}
:description="`Add a ticket to ${project.name}.`" </h1>
/> <p class="text-muted-foreground text-sm">
{{
t('ticketForm.createDescription', {
project: project.name,
})
}}
</p>
</div> </div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
@@ -160,7 +159,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Link :href="returnHref">{{ returnLabel }}</Link> <Link :href="returnHref">{{ returnLabel }}</Link>
</Button> </Button>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link> <Link :href="show(project.id)">{{
t('ticketDetail.backToProject')
}}</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -174,9 +175,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-if="hasTicketMetadata"> <Card v-if="hasTicketMetadata">
<CardHeader> <CardHeader>
<CardTitle>Ticket details</CardTitle> <CardTitle>{{ t('ticketForm.details') }}</CardTitle>
<CardDescription> <CardDescription>
The ticket key is assigned after creation. {{ t('ticketForm.keyAssigned') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -186,258 +187,58 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:state="protection.state.value" :state="protection.state.value"
@retry="submit" @retry="submit"
/> />
<div class="grid gap-2"> <TicketFormFields
<Label for="title">Title</Label> v-model:title="form.title"
<Input v-model:description="form.description"
id="title" v-model:type="form.ticket_type_id"
v-model="form.title" v-model:priority="form.ticket_priority_id"
:invalid="Boolean(form.errors.title)" v-model:status="form.ticket_status_id"
aria-describedby="title-error" v-model:assignee="form.assignee_user_id"
required v-model:parent="form.parent_ticket_id"
autofocus v-model:points="form.story_points"
:disabled="form.processing" :project="project"
placeholder="Implement repository sync" :ticket-types="ticketTypes"
/> :ticket-priorities="ticketPriorities"
<InputError :ticket-status-options="ticketStatusOptions"
id="title-error" :users="users"
:message="form.errors.title" :parent-ticket-options="parentTicketOptions"
/> :errors="form.errors"
</div> :disabled="form.processing"
autofocus
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"
> >
<div class="grid gap-6"> <template #content>
<div class="grid gap-2"> <AcceptanceCriteriaChecklist
<Label for="description">Description</Label> v-model="form.acceptance_criteria"
<textarea :disabled="form.processing"
id="description" :error="acceptanceCriteriaError"
v-model="form.description" />
rows="4" </template>
:disabled="form.processing" </TicketFormFields>
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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" <div
placeholder="Describe the expected behavior and relevant context." class="flex flex-wrap items-center gap-3 border-t pt-5"
/> >
<InputError <Button type="submit" :disabled="form.processing">{{
:message="form.errors.description" form.processing
/> ? t('ticketForm.creating')
</div> : t('ticketForm.create')
}}</Button>
<div class="grid gap-4"> <Button
<div class="grid gap-2"> type="button"
<Label variant="outline"
for="acceptance_criteria_checklist" :disabled="form.processing || !isDirty"
> @click="reset"
Acceptance criteria checklist >{{ t('ticketForm.reset') }}</Button
</Label> >
<textarea <p
id="acceptance_criteria_checklist" role="status"
v-model=" class="text-muted-foreground text-sm"
acceptanceCriteriaChecklist >
" {{
rows="6" isDirty
:disabled="form.processing" ? t('ticketForm.unsaved')
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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" : t('ticketForm.ready')
placeholder="[ ] User can sign in&#10; [x] Validation is shown for invalid credentials" }}
/> </p>
<p
class="text-muted-foreground text-xs"
>
Use <code>[ ]</code> /
<code>[x]</code> for checklist
items. Indent with two spaces for
nested steps.
</p>
<InputError
:message="acceptanceCriteriaError"
/>
</div>
<div
v-if="
acceptanceCriteriaPreviewItems.length >
0
"
class="border-border rounded-xl border p-4 text-sm"
>
<p class="mb-3 font-medium">
Checklist preview
</p>
<ul class="space-y-2">
<li
v-for="(
item, index
) in acceptanceCriteriaPreviewItems"
:key="index"
:style="{
marginLeft: `${item.indent * 1.25}rem`,
}"
class="flex items-start gap-2"
>
<span class="mt-0.5">{{
item.checked ? '☑' : '☐'
}}</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="grid h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
v-model="form.ticket_type_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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="ticketType in ticketTypes"
:key="ticketType.id"
:value="String(ticketType.id)"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="form.errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
v-model="form.ticket_priority_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="String(ticketPriority.id)"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
v-model="form.ticket_status_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="String(ticketStatus.id)"
>
{{
ticketStatusLabel(ticketStatus)
}}
</option>
</select>
<InputError
:message="form.errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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 value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</option>
</select>
<InputError
:message="form.errors.assignee_user_id"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="form.processing"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 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 value="">
No parent ticket
</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="
String(parentTicketOption.id)
"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="form.processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -446,13 +247,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<Card v-else> <Card v-else>
<CardContent class="flex flex-col gap-4 p-6 text-sm"> <CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Ticket creation needs at least one type, one priority, {{ t('ticketForm.metadataRequired') }}
and one status.
</p> </p>
<div> <div>
<Button as-child variant="outline"> <Button as-child variant="outline">
<Link :href="metadataSettings(project.id)"> <Link :href="metadataSettings(project.id)">
Open ticket settings {{ t('ticketForm.openSettings') }}
</Link> </Link>
</Button> </Button>
</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());
}
}
@@ -1511,6 +1511,74 @@ class ProjectTicketControllerTest extends TestCase
]); ]);
} }
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): void
{
$viewer = User::factory()->create();
$project = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.create', $project))
->assertForbidden();
$this->post(route('projects.tickets.store', $project), [
'title' => 'Unauthorized ticket',
'ticket_type_id' => $this->nonParentType($project)->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
])->assertForbidden();
$this->assertDatabaseCount('tickets', 0);
}
public function test_ticket_forms_reject_metadata_from_another_project(): void
{
$editor = User::factory()->create();
$project = Project::factory()->create();
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($project);
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
$parent = Ticket::factory()->create([
'project_id' => $otherProject->id,
'ticket_key' => 'OTHER-1',
'ticket_type_id' => $this->parentType($otherProject)->id,
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
]);
$invalidMetadata = [
'parent_ticket_id' => $parent->id,
'ticket_type_id' => $this->nonParentType($otherProject)->id,
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
];
$this->actingAs($editor)
->post(route('projects.tickets.store', $project), [
...$invalidMetadata,
'title' => 'Rejected creation',
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
])
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
$this->assertSame(0, $project->tickets()->count());
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'ticket_key' => 'LOCAL-1',
'ticket_type_id' => $this->nonParentType($project)->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
'title' => 'Original title',
]);
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
...$invalidMetadata,
'title' => 'Rejected update',
]))->assertSessionHasErrors(array_keys($invalidMetadata));
$this->assertSame('Original title', $ticket->fresh()->title);
$this->assertNull($ticket->fresh()->parent_ticket_id);
}
private function nonParentType(Project $project): TicketType private function nonParentType(Project $project): TicketType
{ {
return $project->ticketTypes() return $project->ticketTypes()
@@ -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
+6 -3
View File
@@ -2,14 +2,17 @@ import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3'; import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css'; import '../../resources/css/app.css';
import Ticket from '../../resources/js/pages/projects/Ticket.vue'; import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance'; import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization'; import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({ createInertiaApp({
resolve: (name) => resolve: (name) =>
name === 'Ticket' name === 'TicketCreate'
? Ticket ? TicketCreate
: { render: () => h('h1', 'Destination reached') }, : name === 'Ticket'
? Ticket
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) => setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(plugin) .use(plugin)
+161 -30
View File
@@ -123,10 +123,13 @@ const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' }, { id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' }, { id: 2, label: 'Reviewer', field_type: 'user' },
]; ];
let savedForm = {};
function makePage(url) { function makePage(url) {
const parsed = new URL(url, 'http://localhost'); const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams; const params = parsed.searchParams;
const detail = parsed.pathname.startsWith('/projects/7/tickets/'); const creating = parsed.pathname.endsWith('/create');
const detail =
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket); const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) { if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, { Object.assign(data, linked, {
@@ -136,7 +139,7 @@ function makePage(url) {
}); });
} }
if (params.has('saved')) { if (params.has('saved')) {
data.title = params.get('saved'); Object.assign(data, savedForm, { title: params.get('saved') });
} }
if (params.has('long')) { if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10); data.title = 'LongUnbrokenTicketTitle'.repeat(10);
@@ -168,16 +171,46 @@ function makePage(url) {
? 'Repository temporarily unavailable.' ? 'Repository temporarily unavailable.'
: null; : null;
return { return {
component: detail ? 'Ticket' : 'Destination', component: creating
? 'TicketCreate'
: detail
? 'Ticket'
: 'Destination',
url, url,
version: null, version: null,
clearHistory: false, clearHistory: false,
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.',
linked_ticket_id: 'Choose another ticket.',
}
: 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,
@@ -204,7 +237,17 @@ function makePage(url) {
canManageProject: params.has('admin'), canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null, status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author], users: [user, ticket.author],
ticketTypes: [data.ticket_type], ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
source: params.get('source') === 'board' ? 'board' : 'backlog',
defaultTicketStatusId: 3,
ticketStatusOptions: [
{
id: 3,
name: 'In progress',
board_column: { name: 'Delivery' },
},
{ id: 4, name: 'Backlog', board_column: null },
],
ticketPriorities: [data.ticket_priority], ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions, ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent], parentTicketOptions: [parent],
@@ -212,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')
? [] ? []
: [ : [
@@ -279,9 +342,14 @@ 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')) {
response.writeHead(503);
response.end('Simulated failure');
return;
}
if (!request.headers['x-inertia']) { if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH'); assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42'); assert.equal(request.url, '/projects/7/tickets/42');
@@ -307,22 +375,32 @@ 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(
'field_values' in data && (request.method === 'POST' || 'field_values' in data) &&
'acceptance_criteria' in data && 'acceptance_criteria' in data &&
'parent_ticket_id' in data, 'parent_ticket_id' in data,
); );
if (params.has('reject')) { if (params.has('reject')) {
params.set('rejected', '1'); params.set('rejected', '1');
} else { } else {
savedForm = data;
params.set('saved', data.title); params.set('saved', data.title);
} }
} }
response.writeHead(303, { response.writeHead(303, {
Location: `/projects/7/tickets/42?${params}`, Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
}); });
response.end(); response.end();
return; return;
@@ -364,10 +442,15 @@ if (serve) {
const { default: Ticket } = await server.ssrLoadModule( const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue', '/resources/js/pages/projects/Ticket.vue',
); );
const render = async (query = '') => { const { default: TicketCreate } = await server.ssrLoadModule(
'/resources/js/pages/projects/TicketCreate.vue',
);
const render = async (query = '', creating = false) => {
const result = await createInertiaApp({ const result = await createInertiaApp({
page: makePage(`/projects/7/tickets/42${query}`), page: makePage(
resolve: () => Ticket, `/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
),
resolve: () => (creating ? TicketCreate : Ticket),
render: renderToString, render: renderToString,
setup: ({ App, props, plugin }) => setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) }) createSSRApp({ render: () => h(App, props) })
@@ -429,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',
@@ -440,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(
@@ -456,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',
@@ -467,12 +560,50 @@ 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'),
/Repository temporarily unavailable/, /Repository temporarily unavailable/,
); );
const create = await render('?source=board', true);
assert.match(create, /Create ticket/);
assert.match(create, /Delivery \/ In progress/);
assert.match(create, /Back to board/);
assert.match(create, /id="ticket_status_id"/);
assert.doesNotMatch(
create,
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
);
assert.doesNotMatch(
await render('?hidePoints', true),
/id="story_points"/,
);
const unavailable = await render('?noMetadata', true);
assert.doesNotMatch(unavailable, /<form/);
assert.match(unavailable, /Ticket creation needs at least one type/);
for (const creating of [false, true]) {
const invalid = await render('?rejected&allErrors', creating);
assert.match(
invalid,
/aria-describedby="[^"]+-hint [^"]+-error"|No acceptance criteria yet/,
);
for (const id of [
'title',
'description',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'parent_ticket_id',
'story_points',
]) {
assert.ok(invalid.includes(`id="${id}-error"`));
}
}
console.log('Ticket detail behavior checks passed.'); console.log('Ticket detail behavior checks passed.');
} finally { } finally {
await server.close(); await server.close();
+279
View File
@@ -0,0 +1,279 @@
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({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const base = 'http://127.0.0.1:4185/projects/7/tickets';
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const field = (label) => form().getByLabel(label, { exact: true });
const form = () => page.locator('form').first();
const state = (value) =>
form().locator(`[data-form-state="${value}"]`).waitFor();
const button = (label) =>
form().getByRole('button', { name: label, exact: true });
async function visit(create, query = '') {
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
await page.getByRole('heading', { level: 1 }).waitFor();
if (!create) {
await page.locator('#ticket-edit > summary').click();
}
}
async function fill(create) {
await field('Title').fill('A complete ticket');
await field('Description').fill('Description with context.');
await field('Assignee').selectOption('2');
await field('Parent ticket').selectOption('40');
await field('Estimated story points').fill('0');
if (create) {
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) {
await field('Status').selectOption('4');
} else {
await field('Author').selectOption('1');
await field('Blocker comment').fill('Waiting for review');
await field('Impact score').fill('2.5');
await field('Reviewer').selectOption('2');
}
}
async function submit(create) {
const request = page.waitForRequest(
(r) =>
r.method() === (create ? 'POST' : 'PATCH') &&
r.url().split('?')[0] === (create ? base : `${base}/42`),
);
await button(create ? 'Create ticket' : 'Save changes').click();
return (await request).postDataJSON();
}
try {
for (const create of [true, false]) {
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(create, '?admin');
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth,
),
true,
`Overflow ${create}/${width}/${theme}`,
);
await field('Title').focus();
await page.keyboard.press('Tab');
assert.equal(
await field('Description').evaluate(
(el) =>
el === document.activeElement &&
el.matches(':focus-visible'),
),
true,
);
await fill(create);
await page.screenshot({
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
fullPage: true,
});
page.once('dialog', (dialog) => dialog.dismiss());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await button('Discard changes').isDisabled(),
true,
);
assert.equal(
await field('Title').inputValue(),
create
? ''
: 'Implement a resilient caching layer for the project API',
);
}
}
await visit(create, '?admin&reject&allErrors');
await fill(create);
const data = await submit(create);
assert.equal(data.title, 'A complete ticket');
assert.equal(data.description, 'Description with context.');
assert.equal(Number(data.ticket_type_id), 1);
assert.equal(Number(data.ticket_priority_id), 2);
assert.equal(Number(data.assignee_user_id), 2);
assert.equal(Number(data.parent_ticket_id), 40);
assert.equal(Number(data.story_points), 0);
assert.deepEqual(
data.acceptance_criteria.map(({ text, checked, indent }) => ({
text,
checked,
indent,
})),
[
{ text: 'Main criterion', checked: false, indent: 0 },
{ text: 'Nested criterion', checked: true, indent: 1 },
],
);
if (create) {
assert.equal(Number(data.ticket_status_id), 4);
assert.equal('author_user_id' in data, false);
} else {
assert.equal(Number(data.author_user_id), 1);
assert.equal(data.is_blocked, true);
assert.equal(data.blocked_comment, 'Waiting for review');
assert.equal(Number(data.field_values[0].value_number), 2.5);
assert.equal(Number(data.field_values[1].value_user_id), 2);
assert.deepEqual(
data.acceptance_criteria.map((item) => item.id),
[10, 11],
);
assert.equal('ticket_status_id' in data, false);
}
await state('validation');
const fields = [
'Title',
'Description',
'Type',
'Priority',
'Assignee',
'Parent ticket',
'Estimated story points',
'Criterion 1',
...(create
? ['Status']
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
];
for (const label of fields) {
const control = field(label);
assert.equal(
await control.getAttribute('aria-invalid'),
'true',
label,
);
const errorId = (await control.getAttribute('aria-describedby'))
.split(' ')
.at(-1);
assert.ok(
(await page.locator(`[id="${errorId}"]`).innerText()).length,
`Missing associated error: ${label}`,
);
}
assert.equal(await field('Title').inputValue(), 'A complete ticket');
await page.route(create ? `${base}` : `${base}/42`, (route) =>
route.abort('failed'),
);
await button(create ? 'Create ticket' : 'Save changes').click();
await state('network');
assert.equal(
await field('Description').inputValue(),
'Description with context.',
);
await page.unrouteAll();
await visit(create, '?admin&serverError');
await fill(create);
await submit(create);
await state('server');
assert.equal(await field('Title').inputValue(), 'A complete ticket');
let pending;
let requests = 0;
await page.route(create ? `${base}` : `${base}/42`, (route) => {
requests++;
pending = route;
});
await button('Try again').click();
await state('loading');
assert.equal(await field('Title').isDisabled(), true);
assert.equal(
await button(
create ? 'Creating ticket…' : 'Saving changes…',
).isDisabled(),
true,
);
await form().evaluate((el) =>
el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }),
),
);
assert.equal(requests, 1);
await pending.abort();
await state('network');
await page.unrouteAll();
await visit(create, '?admin');
await fill(create);
page.once('dialog', (dialog) => dialog.dismiss());
await page
.locator('#main-content')
.getByRole('link', {
name: create ? 'Back to backlog' : 'Open backlog',
exact: true,
})
.click();
assert.ok(page.url().includes('/tickets/'));
const successData = await submit(create);
await page
.getByRole('heading', { name: successData.title, exact: true })
.waitFor();
if (create) {
await page.goBack();
await page
.getByRole('heading', { name: 'Create ticket', exact: true })
.waitFor();
await field('Title').waitFor();
assert.equal(await field('Title').inputValue(), '');
assert.equal(await field('Criterion 1').count(), 0);
} else {
await form()
.getByText('All changes saved', { exact: true })
.waitFor();
await field('Title').fill('Another draft');
page.once('dialog', (dialog) => dialog.accept());
await button('Discard changes').click();
assert.equal(
await field('Title').inputValue(),
'A complete ticket',
);
assert.equal(await field('Impact score').inputValue(), '2.5');
}
}
await visit(false, '?hideAuthor&hidePoints');
assert.equal(await field('Author').count(), 0);
assert.equal(await field('Estimated story points').count(), 0);
await visit(false);
assert.equal(await field('Author').isDisabled(), true);
await visit(true, '?hidePoints');
assert.equal(await field('Estimated story points').count(), 0);
await visit(true, '?noMetadata');
assert.equal(await page.locator('form').count(), 0);
assert.deepEqual(errors, []);
console.log(
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
);
} finally {
await browser.close();
}
@@ -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();
}