Merge pull request 'KAN-41: Erlaubte Statusübergänge bearbeiten' (#136) from feature/KAN-41-allowed-status-transitions into main
Reviewed-on: #136
This commit was merged in pull request #136.
This commit is contained in:
@@ -1,91 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { Form } from '@inertiajs/vue3';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { ArrowRightLeft } from 'lucide-vue-next';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { ticketStatusLabel } from '@/lib/tickets';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
defineProps<{ projectId: number; statuses: TicketStatusModel[] }>();
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
const { t } = useLocalization();
|
||||
const sourceId = ref(props.statuses[0]?.id ?? null);
|
||||
const source = computed(() =>
|
||||
props.statuses.find((status) => status.id === sourceId.value),
|
||||
);
|
||||
const targets = computed(() =>
|
||||
props.statuses.filter((status) => status.id !== sourceId.value),
|
||||
);
|
||||
const savedTargets = computed(
|
||||
() =>
|
||||
source.value?.allowed_target_statuses
|
||||
?.map((status) => status.id)
|
||||
.sort((a, b) => a - b) ?? [],
|
||||
);
|
||||
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
const protection = useFormProtection(form);
|
||||
const saved = ref(false);
|
||||
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
||||
|
||||
function reset(): void {
|
||||
saved.value = false;
|
||||
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.statuses,
|
||||
() => {
|
||||
if (!source.value) {
|
||||
sourceId.value = props.statuses[0]?.id ?? null;
|
||||
reset();
|
||||
} else if (!form.isDirty && !form.processing) {
|
||||
reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function selectSource(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
if (form.processing || !protection.confirmLeave()) {
|
||||
select.value = String(sourceId.value);
|
||||
return;
|
||||
}
|
||||
sourceId.value = Number(select.value);
|
||||
reset();
|
||||
}
|
||||
|
||||
function toggleTarget(id: number, event: Event): void {
|
||||
saved.value = false;
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
form.allowed_ticket_status_ids = checked
|
||||
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
||||
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
||||
form.clearErrors();
|
||||
protection.clearFeedback();
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
if (!source.value || !form.isDirty || form.processing) {
|
||||
return;
|
||||
}
|
||||
protection.submit(
|
||||
updateStatusTransitions([props.projectId, source.value.id]),
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
saved.value = true;
|
||||
form.defaults({
|
||||
allowed_ticket_status_ids: [...savedTargets.value],
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details class="bg-card min-w-0 rounded-xl border">
|
||||
<summary
|
||||
class="focus-visible:ring-ring/40 cursor-pointer rounded-xl p-5 font-semibold outline-none focus-visible:ring-2 sm:p-6"
|
||||
>
|
||||
{{ t('workflow.transitions') }}
|
||||
</summary>
|
||||
<div class="grid min-w-0 gap-4 border-t p-5 sm:p-6">
|
||||
<section
|
||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||
aria-labelledby="workflow-transitions"
|
||||
>
|
||||
<header class="grid gap-3 border-b p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ArrowRightLeft
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="workflow-transitions" class="font-semibold">
|
||||
{{ t('workflow.transitions') }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.transitionsDescription') }}
|
||||
</p>
|
||||
<Form
|
||||
v-for="source in statuses"
|
||||
:key="source.id"
|
||||
v-bind="updateStatusTransitions.form([projectId, source.id])"
|
||||
preserve-scroll
|
||||
set-defaults-on-success
|
||||
class="grid min-w-0 gap-3 rounded-lg border p-4"
|
||||
v-slot="{ errors, processing, recentlySuccessful }"
|
||||
>
|
||||
<fieldset :disabled="processing" class="grid min-w-0 gap-3">
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{ ticketStatusLabel(source, '') }}
|
||||
</legend>
|
||||
</header>
|
||||
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
||||
<p v-if="!source" class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.noStatuses') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<Label for="workflow-source">{{
|
||||
t('workflow.sourceStatus')
|
||||
}}</Label>
|
||||
<select
|
||||
id="workflow-source"
|
||||
:value="sourceId"
|
||||
:disabled="form.processing"
|
||||
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
@change="selectSource"
|
||||
>
|
||||
<option
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
:value="status.id"
|
||||
>
|
||||
{{ ticketStatusLabel(status, '') }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.directionHelp') }}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-3"
|
||||
aria-describedby="workflow-target-help workflow-transition-errors"
|
||||
:aria-invalid="hasErrors"
|
||||
>
|
||||
<legend class="mb-3 break-words font-medium">
|
||||
{{
|
||||
t('workflow.targetsFrom', { name: source.name })
|
||||
}}
|
||||
</legend>
|
||||
<p
|
||||
id="workflow-target-help"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.targetHelp') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="targets.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noTargets') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in targets"
|
||||
:key="target.id"
|
||||
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
||||
:class="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'border-primary/40 bg-accent/40'
|
||||
: 'border-input'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
"
|
||||
class="accent-primary size-4 shrink-0"
|
||||
@change="toggleTarget(target.id, $event)"
|
||||
/>
|
||||
<span class="grid min-w-0 gap-1">
|
||||
<span
|
||||
class="break-words text-sm font-medium"
|
||||
>{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>{{
|
||||
t(
|
||||
form.allowed_ticket_status_ids.includes(
|
||||
target.id,
|
||||
)
|
||||
? 'workflow.allowed'
|
||||
: 'workflow.notAllowed',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p
|
||||
v-if="statuses.length <= 1"
|
||||
v-if="
|
||||
targets.length > 0 &&
|
||||
form.allowed_ticket_status_ids.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noTargets') }}
|
||||
{{ t('workflow.noMovesHelp') }}
|
||||
</p>
|
||||
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="target in statuses.filter(
|
||||
(item) => item.id !== source.id,
|
||||
)"
|
||||
:key="target.id"
|
||||
class="border-input flex min-h-11 min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="target.id"
|
||||
:checked="
|
||||
source.allowed_target_statuses?.some(
|
||||
(item) => item.id === target.id,
|
||||
) ?? false
|
||||
"
|
||||
class="border-input accent-primary focus-visible:ring-ring/40 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||
/>
|
||||
<span class="min-w-0 break-words">{{
|
||||
ticketStatusLabel(target, '')
|
||||
}}</span>
|
||||
</label>
|
||||
<div id="workflow-transition-errors" role="alert">
|
||||
<InputError
|
||||
v-for="(message, key) in form.errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<InputError
|
||||
v-for="(message, key) in errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:loading="processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
<FormFeedback
|
||||
:state="
|
||||
!saved && protection.state.value === 'success'
|
||||
? 'idle'
|
||||
: protection.state.value
|
||||
"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
||||
>
|
||||
<span
|
||||
v-if="recentlySuccessful"
|
||||
role="status"
|
||||
class="text-primary text-sm"
|
||||
>{{ t('feedback.saved') }}</span
|
||||
>
|
||||
</div>
|
||||
</Form>
|
||||
<Badge
|
||||
:variant="form.isDirty ? 'warning' : 'secondary'"
|
||||
role="status"
|
||||
>{{
|
||||
t(
|
||||
form.isDirty
|
||||
? 'workflow.unsavedMoves'
|
||||
: 'workflow.savedMoves',
|
||||
{ count: savedTargets.length },
|
||||
)
|
||||
}}</Badge
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!form.isDirty || form.processing"
|
||||
@click="reset"
|
||||
>{{ t('workflow.resetMoves') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="!form.isDirty"
|
||||
:loading="form.processing"
|
||||
>{{ t('workflow.saveMoves') }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -46,6 +46,22 @@ export const en = {
|
||||
'workflow.transitions': 'Status moves',
|
||||
'workflow.transitionsDescription':
|
||||
'Choose exactly where each status is allowed to move next.',
|
||||
'workflow.sourceStatus': 'From status',
|
||||
'workflow.directionHelp':
|
||||
'Rules apply in one direction. Select another source to configure the return move.',
|
||||
'workflow.targetsFrom': 'Allowed destinations from {name}',
|
||||
'workflow.targetHelp':
|
||||
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
|
||||
'workflow.allowed': 'Allowed',
|
||||
'workflow.notAllowed': 'Not allowed',
|
||||
'workflow.noMovesHelp':
|
||||
'With no destinations selected, tickets cannot leave this status after you save.',
|
||||
'workflow.noStatuses':
|
||||
'Add a status to configure its allowed destinations.',
|
||||
'workflow.unsavedMoves': 'Unsaved changes',
|
||||
'workflow.savedMoves': 'Saved destinations: {count}',
|
||||
'workflow.resetMoves': 'Reset changes',
|
||||
'workflow.saveMoves': 'Save moves',
|
||||
'workflow.noTargets': 'No other statuses exist yet.',
|
||||
'workflow.confirmDelete': 'Delete',
|
||||
'security.title': 'Security settings',
|
||||
|
||||
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
|
||||
*/
|
||||
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create(['key' => 'CORE']);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
|
||||
'name' => 'Progress',
|
||||
'position' => 1,
|
||||
]);
|
||||
$reviewStatus = TicketStatus::factory()->for($project)->create([
|
||||
'name' => 'Review',
|
||||
'position' => 2,
|
||||
]);
|
||||
$doneStatus = TicketStatus::factory()->for($project)->create([
|
||||
'name' => 'Done',
|
||||
'position' => 3,
|
||||
]);
|
||||
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
|
||||
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
|
||||
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||
'allowed_ticket_status_ids' => [$reviewStatus->id],
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.current_status.id', $sourceStatus->id)
|
||||
->assertJsonCount(1, 'data.target_statuses')
|
||||
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
|
||||
|
||||
$this->get(route('projects.board.index', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Board')
|
||||
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
|
||||
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
|
||||
);
|
||||
|
||||
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||
'allowed_ticket_status_ids' => [],
|
||||
])
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||
|
||||
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.target_statuses', []);
|
||||
|
||||
$this->get(route('projects.board.index', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Board')
|
||||
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
||||
*/
|
||||
|
||||
@@ -254,23 +254,6 @@ try {
|
||||
.getByText('Building', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await button('Edit column In progress').count(), 0);
|
||||
await page.locator('summary').filter({ hasText: 'Status moves' }).click();
|
||||
const transition = page.locator('details form').first();
|
||||
const checkbox = transition.getByRole('checkbox').first();
|
||||
await checkbox.check();
|
||||
const move = page.waitForRequest((request) => request.method() === 'POST');
|
||||
await transition.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
assert.equal(
|
||||
new URL((await move).url()).pathname,
|
||||
'/projects/1/settings/board/statuses/1/transitions',
|
||||
);
|
||||
assert.equal(
|
||||
new URL((await move).url()).searchParams.get('_method'),
|
||||
'PATCH',
|
||||
);
|
||||
assert.deepEqual((await move).postDataJSON(), {
|
||||
allowed_ticket_status_ids: [await checkbox.getAttribute('value')],
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
||||
|
||||
@@ -35,6 +35,7 @@ function initial() {
|
||||
const ready = makeStatus(2, 'Ready', 10);
|
||||
const active = makeStatus(3, 'Building', 20);
|
||||
const review = makeStatus(4, 'Review', 20, 2);
|
||||
backlog.allowed_target_statuses = [ready];
|
||||
const columns = [
|
||||
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
|
||||
{
|
||||
@@ -134,13 +135,17 @@ if (serve) {
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
errors =
|
||||
request.method === 'DELETE'
|
||||
? {
|
||||
ticket_status_id:
|
||||
'This group needs at least one status.',
|
||||
}
|
||||
: { name: 'Choose a different name.' };
|
||||
errors = url.pathname.endsWith('/transitions')
|
||||
? {
|
||||
'allowed_ticket_status_ids.0':
|
||||
'Choose a destination from this project.',
|
||||
}
|
||||
: request.method === 'DELETE'
|
||||
? {
|
||||
ticket_status_id:
|
||||
'This group needs at least one status.',
|
||||
}
|
||||
: { name: 'Choose a different name.' };
|
||||
} else if (result === 'position') {
|
||||
errors = { position: 'The position must be at least 1.' };
|
||||
} else {
|
||||
@@ -188,7 +193,16 @@ if (serve) {
|
||||
column.position = index + 1;
|
||||
});
|
||||
} else if (url.pathname.endsWith('/transitions')) {
|
||||
// The browser assertion checks the preserved transition endpoint and payload.
|
||||
const sourceId = Number(segments.at(-2));
|
||||
const statuses = [
|
||||
...data.backlogTicketStatuses,
|
||||
...columns.flatMap((column) => column.ticket_statuses),
|
||||
];
|
||||
statuses.find(
|
||||
(status) => status.id === sourceId,
|
||||
).allowed_target_statuses = (
|
||||
submitted.allowed_ticket_status_ids ?? []
|
||||
).map((id) => ({ id: Number(id) }));
|
||||
} else if (request.method === 'POST') {
|
||||
const group =
|
||||
submitted.board_column_id === null
|
||||
@@ -294,6 +308,16 @@ if (serve) {
|
||||
html.indexOf('data-column-id="10"') <
|
||||
html.indexOf('data-column-id="20"'),
|
||||
);
|
||||
assert.match(html, /From status/);
|
||||
assert.match(html, /Saved destinations: 1/);
|
||||
assert.match(
|
||||
await render({ ticketStatuses: [] }),
|
||||
/Add a status to configure/,
|
||||
);
|
||||
assert.match(
|
||||
await render({ ticketStatuses: [makeStatus(1, 'Backlog', null)] }),
|
||||
/No other statuses exist yet/,
|
||||
);
|
||||
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||
for (const name of ['Backlog', 'Ready']) {
|
||||
assert.match(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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:4188';
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const editor = page.getByRole('region', { name: 'Status moves', exact: true });
|
||||
const source = () => editor.getByLabel('From status', { exact: true });
|
||||
const check = (name) =>
|
||||
editor.getByRole('checkbox', { name: new RegExp(name) });
|
||||
const button = (name) => editor.getByRole('button', { name, exact: true });
|
||||
const state = (name) => editor.locator('[data-form-state="' + name + '"]');
|
||||
const result = (value) =>
|
||||
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
|
||||
const visit = async (query = '?reset') => {
|
||||
await result('success');
|
||||
await page.goto(base + '/projects/1/settings/board' + query);
|
||||
await source().waitFor();
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const save = async (id, targets) => {
|
||||
const sent = page.waitForRequest((request) => request.method() === 'PATCH');
|
||||
await button('Save moves').click();
|
||||
const request = await sent;
|
||||
assert.equal(
|
||||
new URL(request.url()).pathname,
|
||||
'/projects/1/settings/board/statuses/' + id + '/transitions',
|
||||
);
|
||||
assert.deepEqual(request.postDataJSON(), {
|
||||
allowed_ticket_status_ids: targets,
|
||||
});
|
||||
};
|
||||
try {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await visit();
|
||||
assert.equal(await check('Ready').isChecked(), true);
|
||||
assert.equal(await check('Building').isChecked(), false);
|
||||
assert.equal(await check('Backlog').count(), 0);
|
||||
assert.equal(await button('Save moves').isDisabled(), true);
|
||||
await check('Building').focus();
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await check('Building').isChecked(), true);
|
||||
await editor
|
||||
.getByText('Unsaved changes', { exact: true })
|
||||
.waitFor();
|
||||
await noOverflow();
|
||||
await button('Reset changes').click();
|
||||
assert.equal(await check('Building').isChecked(), false);
|
||||
assert.equal(await check('Ready').isChecked(), true);
|
||||
if ([1440, 390].includes(width)) {
|
||||
await editor.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/KAN-41-' + width + '-' + theme + '.png',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit();
|
||||
await check('Ready').uncheck();
|
||||
await check('Review').check();
|
||||
await result('validation');
|
||||
await save(1, [4]);
|
||||
await state('validation').waitFor();
|
||||
await editor
|
||||
.getByText('Choose a destination from this project.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
assert.equal(await check('Ready').isChecked(), false);
|
||||
assert.equal(
|
||||
await editor.getByRole('group').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await result('server');
|
||||
await save(1, [4]);
|
||||
await state('server').waitFor();
|
||||
await result('success');
|
||||
await button('Try again').click();
|
||||
await state('success').waitFor();
|
||||
assert.equal(await button('Save moves').isDisabled(), true);
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
await page.reload();
|
||||
await source().waitFor();
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
assert.equal(await check('Ready').isChecked(), false);
|
||||
await source().selectOption('4');
|
||||
assert.equal(await check('Backlog').isChecked(), false);
|
||||
assert.equal(await check('Review').count(), 0);
|
||||
await check('Backlog').check();
|
||||
page.once('dialog', (prompt) => prompt.dismiss());
|
||||
await source().selectOption('1');
|
||||
assert.equal(await source().inputValue(), '4');
|
||||
assert.equal(await check('Backlog').isChecked(), true);
|
||||
page.once('dialog', (prompt) => prompt.accept());
|
||||
await source().selectOption('1');
|
||||
assert.equal(await source().inputValue(), '1');
|
||||
assert.equal(await check('Review').isChecked(), true);
|
||||
await check('Review').uncheck();
|
||||
await editor.getByText(/With no destinations selected/).waitFor();
|
||||
await save(1, []);
|
||||
await state('success').waitFor();
|
||||
await page.reload();
|
||||
await source().waitFor();
|
||||
assert.equal(await editor.locator('input:checked').count(), 0);
|
||||
await editor.getByText('Saved destinations: 0', { exact: true }).waitFor();
|
||||
// A disconnected request retains the draft and supports a single retry.
|
||||
await check('Building').check();
|
||||
await page.route('**/statuses/1/transitions', (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await save(1, [3]);
|
||||
await state('network').waitFor();
|
||||
assert.equal(await check('Building').isChecked(), true);
|
||||
await page.unroute('**/statuses/1/transitions');
|
||||
await button('Try again').click();
|
||||
await state('success').waitFor();
|
||||
// Pending saves disable inputs, source changes and duplicate submits.
|
||||
let release;
|
||||
const blocked = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/statuses/1/transitions', async (route) => {
|
||||
await blocked;
|
||||
await route.continue();
|
||||
});
|
||||
await check('Review').check();
|
||||
await save(1, [3, 4]);
|
||||
await state('loading').waitFor();
|
||||
assert.equal(await source().isDisabled(), true);
|
||||
assert.equal(await check('Building').isDisabled(), true);
|
||||
assert.equal(
|
||||
await editor.locator('button[type=submit]').isDisabled(),
|
||||
true,
|
||||
);
|
||||
release();
|
||||
await state('success').waitFor();
|
||||
await page.unroute('**/statuses/1/transitions');
|
||||
await visit('?reset&empty');
|
||||
await editor
|
||||
.getByText('No other statuses exist yet.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await editor.getByRole('checkbox').count(), 0);
|
||||
await noOverflow();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Transition browser checks passed: directed rules, persisted selection, clearing all targets, reset, source-change protection, validation/network/server errors, retry, pending saves, keyboard, responsive widths and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user