Compare commits
13
Commits
a028eacec2
..
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf |
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
destroyPriority,
|
||||
destroyType,
|
||||
storePriority,
|
||||
storeType,
|
||||
updatePriority,
|
||||
updateType,
|
||||
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
target: MetadataEditorTarget | null;
|
||||
types: TicketTypeModel[];
|
||||
priorities: TicketPriorityModel[];
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
name: '',
|
||||
position: 1,
|
||||
is_parent: false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deletionError = ref<string | null>(null);
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const entries = computed(() =>
|
||||
props.target?.kind === 'type' ? props.types : props.priorities,
|
||||
);
|
||||
const replacements = computed(() =>
|
||||
props.types.filter((type) => type.id !== props.target?.entry?.id),
|
||||
);
|
||||
const needsReplacement = computed(
|
||||
() =>
|
||||
deleting.value &&
|
||||
props.target?.kind === 'type' &&
|
||||
(props.target.entry?.tickets_count ?? 0) > 0,
|
||||
);
|
||||
const title = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.action === 'create') {
|
||||
return t(
|
||||
target.kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'edit'
|
||||
? 'metadata.editEntry'
|
||||
: 'metadata.deleteEntry',
|
||||
{ name: target.entry.name },
|
||||
);
|
||||
});
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name: target.entry?.name ?? '',
|
||||
position: target.entry?.position ?? 1,
|
||||
is_parent: target.entry?.is_parent ?? false,
|
||||
replacement_ticket_type_id: '',
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
deletionError.value = null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (
|
||||
!target ||
|
||||
form.processing ||
|
||||
(deleting.value && entries.value.length <= 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'type'
|
||||
? target.action === 'create'
|
||||
? storeType(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateType([props.projectId, target.entry.id])
|
||||
: destroyType([props.projectId, target.entry.id])
|
||||
: target.action === 'create'
|
||||
? storePriority(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updatePriority([props.projectId, target.entry.id])
|
||||
: destroyPriority([props.projectId, target.entry.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? target.kind === 'type'
|
||||
? {
|
||||
replacement_ticket_type_id:
|
||||
data.replacement_ticket_type_id || null,
|
||||
}
|
||||
: {}
|
||||
: target.action === 'edit'
|
||||
? { name: data.name, position: data.position }
|
||||
: target.kind === 'type'
|
||||
? { name: data.name, is_parent: data.is_parent }
|
||||
: { name: data.name },
|
||||
);
|
||||
deletionError.value = null;
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: (page) => {
|
||||
if (page.props.error) {
|
||||
deletionError.value =
|
||||
typeof page.props.status === 'string'
|
||||
? page.props.status
|
||||
: t('common.error');
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
t(
|
||||
deleting
|
||||
? target?.kind === 'type'
|
||||
? 'metadata.deleteTypeHelp'
|
||||
: 'metadata.deletePriorityHelp'
|
||||
: 'metadata.editHelp',
|
||||
)
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback
|
||||
:state="deletionError ? 'idle' : protection.state.value"
|
||||
@retry="submit"
|
||||
/>
|
||||
<p
|
||||
v-if="deletionError"
|
||||
role="alert"
|
||||
class="text-destructive text-sm"
|
||||
>
|
||||
{{ deletionError }}
|
||||
</p>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<template v-if="!deleting">
|
||||
<div class="grid gap-2">
|
||||
<Label for="metadata-name">{{
|
||||
t('metadata.name')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
aria-describedby="metadata-name-error"
|
||||
/>
|
||||
<InputError
|
||||
id="metadata-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="target?.action === 'edit'"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="metadata-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="metadata-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="entries.length"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="metadata-position-help metadata-position-error"
|
||||
/>
|
||||
<p
|
||||
id="metadata-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('metadata.positionHelp', {
|
||||
count: entries.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="target?.kind === 'type'" class="grid gap-2">
|
||||
<Label
|
||||
v-if="target.action === 'create'"
|
||||
for="metadata-parent"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id="metadata-parent"
|
||||
v-model="form.is_parent"
|
||||
name="is_parent"
|
||||
:invalid="!!form.errors.is_parent"
|
||||
aria-describedby="metadata-parent-help metadata-parent-error"
|
||||
/>{{ t('metadata.allowChildren') }}
|
||||
</Label>
|
||||
<p v-else class="text-sm font-medium">
|
||||
{{
|
||||
t(
|
||||
form.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
id="metadata-parent-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentFixed') }}
|
||||
</p>
|
||||
<InputError
|
||||
id="metadata-parent-error"
|
||||
:message="form.errors.is_parent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="needsReplacement" class="grid gap-2">
|
||||
<p class="text-sm">
|
||||
{{
|
||||
t('metadata.replacementHelp', {
|
||||
count: target?.entry?.tickets_count ?? 0,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<Label for="metadata-replacement">{{
|
||||
t('metadata.replacement')
|
||||
}}</Label>
|
||||
<select
|
||||
id="metadata-replacement"
|
||||
v-model="form.replacement_ticket_type_id"
|
||||
name="replacement_ticket_type_id"
|
||||
required
|
||||
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||
:aria-invalid="
|
||||
!!form.errors.replacement_ticket_type_id
|
||||
"
|
||||
aria-describedby="metadata-replacement-help metadata-replacement-error"
|
||||
>
|
||||
<option value="">
|
||||
{{ t('metadata.chooseType') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="entry in replacements"
|
||||
:key="entry.id"
|
||||
:value="entry.id"
|
||||
>
|
||||
{{ entry.name
|
||||
}}{{
|
||||
entry.is_parent
|
||||
? ` (${t('metadata.parentType')})`
|
||||
: ''
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<p
|
||||
id="metadata-replacement-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('metadata.parentReplacement') }}
|
||||
</p>
|
||||
</div>
|
||||
<InputError
|
||||
id="metadata-replacement-error"
|
||||
:message="form.errors.replacement_ticket_type_id"
|
||||
/>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
:disabled="deleting && entries.length <= 1"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type {
|
||||
MetadataEditorTarget,
|
||||
MetadataEntry,
|
||||
MetadataKind,
|
||||
} from '@/types/project-metadata';
|
||||
|
||||
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
|
||||
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||
:aria-labelledby="`metadata-${kind}`"
|
||||
>
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 :id="`metadata-${kind}`" class="font-semibold">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.types'
|
||||
: 'metadata.priorities',
|
||||
)
|
||||
}}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{ entries.length }}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto"
|
||||
@click="$emit('edit', { kind, action: 'create' })"
|
||||
>
|
||||
<Plus class="size-4" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.addType'
|
||||
: 'metadata.addPriority',
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.typesDescription'
|
||||
: 'metadata.prioritiesDescription',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ol
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
|
||||
:data-metadata-entry="`${kind}-${entry.id}`"
|
||||
>
|
||||
<span
|
||||
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||
:aria-label="
|
||||
t('workflow.positionValue', {
|
||||
position: entry.position,
|
||||
})
|
||||
"
|
||||
>{{ entry.position }}</span
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 basis-28 gap-1">
|
||||
<p class="break-words font-medium">{{ entry.name }}</p>
|
||||
<div
|
||||
v-if="kind === 'type'"
|
||||
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge variant="outline">{{
|
||||
t(
|
||||
entry.is_parent
|
||||
? 'metadata.parentType'
|
||||
: 'metadata.standardType',
|
||||
)
|
||||
}}</Badge>
|
||||
<span>{{
|
||||
t('metadata.assignedTickets', {
|
||||
count: entry.tickets_count ?? 0,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('metadata.editEntry', { name: entry.name })
|
||||
"
|
||||
@click="$emit('edit', { kind, action: 'edit', entry })"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="entries.length <= 1"
|
||||
:aria-label="
|
||||
t('metadata.deleteEntry', { name: entry.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
entries.length <= 1
|
||||
? `metadata-${kind}-protection`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', { kind, action: 'delete', entry })
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p
|
||||
v-if="entries.length <= 1"
|
||||
:id="`metadata-${kind}-protection`"
|
||||
class="text-caption text-muted-foreground flex items-start gap-2"
|
||||
>
|
||||
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
|
||||
t(
|
||||
kind === 'type'
|
||||
? 'metadata.lastType'
|
||||
: 'metadata.lastPriority',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { watch } from 'vue';
|
||||
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
|
||||
const props = defineProps<{ project: ProjectModel }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
const protection = useFormProtection(form);
|
||||
watch(
|
||||
() => [props.project.show_author, props.project.show_story_points],
|
||||
() => {
|
||||
form.defaults({
|
||||
show_author: props.project.show_author,
|
||||
show_story_points: props.project.show_story_points,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
},
|
||||
);
|
||||
function submit(): void {
|
||||
protection.submit(update(props.project.id), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.defaults(),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('metadata.displayDescription') }}
|
||||
</p>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<fieldset :disabled="form.processing" class="grid gap-4">
|
||||
<div
|
||||
v-for="option in ['show_author', 'show_story_points'] as const"
|
||||
:key="option"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
:for="option"
|
||||
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<Checkbox
|
||||
:id="option"
|
||||
v-model="form[option]"
|
||||
:name="option"
|
||||
:invalid="!!form.errors[option]"
|
||||
:aria-describedby="`${option}-help ${option}-error`"
|
||||
/>
|
||||
<span class="grid gap-1">
|
||||
<span>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.author'
|
||||
: 'metadata.storyPoints',
|
||||
)
|
||||
}}</span>
|
||||
<span
|
||||
:id="`${option}-help`"
|
||||
class="text-muted-foreground text-sm font-normal"
|
||||
>{{
|
||||
t(
|
||||
option === 'show_author'
|
||||
? 'metadata.authorHelp'
|
||||
: 'metadata.storyPointsHelp',
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</Label>
|
||||
<InputError
|
||||
:id="`${option}-error`"
|
||||
:message="form.errors[option]"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full sm:w-fit"
|
||||
:loading="form.processing"
|
||||
>{{ t('common.save') }}</Button
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import {
|
||||
destroy,
|
||||
destroyStatus,
|
||||
store,
|
||||
storeStatus,
|
||||
update,
|
||||
updateStatus,
|
||||
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number;
|
||||
columnCount: number;
|
||||
target: WorkflowEditorTarget | null;
|
||||
}>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const form = useForm({ name: '', position: 1 });
|
||||
const protection = useFormProtection(form, {
|
||||
isDirty: () => props.target !== null && form.isDirty,
|
||||
});
|
||||
const deleting = computed(() => props.target?.action === 'delete');
|
||||
const title = computed(() => {
|
||||
if (props.target?.kind === 'column') {
|
||||
return t(
|
||||
props.target.action === 'create'
|
||||
? 'workflow.addColumn'
|
||||
: props.target.action === 'edit'
|
||||
? 'workflow.editColumn'
|
||||
: 'workflow.deleteColumn',
|
||||
);
|
||||
}
|
||||
return t(
|
||||
props.target?.action === 'create'
|
||||
? 'workflow.addStatus'
|
||||
: props.target?.action === 'edit'
|
||||
? 'workflow.editStatus'
|
||||
: 'workflow.deleteStatus',
|
||||
);
|
||||
});
|
||||
const description = computed(() => {
|
||||
const target = props.target;
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
if (target.kind === 'column') {
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteColumnDescription'
|
||||
: target.action === 'create'
|
||||
? 'workflow.addColumnDescription'
|
||||
: 'workflow.editColumnDescription',
|
||||
{ name: target.column?.name ?? '' },
|
||||
);
|
||||
}
|
||||
return t(
|
||||
target.action === 'delete'
|
||||
? 'workflow.deleteStatusDescription'
|
||||
: 'workflow.statusContext',
|
||||
{
|
||||
name: target.status?.name ?? '',
|
||||
context: target.column?.name ?? t('workflow.backlog'),
|
||||
replacement: target.replacement?.name ?? '',
|
||||
},
|
||||
);
|
||||
});
|
||||
const otherErrors = computed(() =>
|
||||
Object.entries(form.errors)
|
||||
.filter(([key]) => !['name', 'position'].includes(key))
|
||||
.map(([, message]) => message),
|
||||
);
|
||||
watch(
|
||||
() => props.target,
|
||||
(target) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
form.defaults({
|
||||
name:
|
||||
target.kind === 'column'
|
||||
? (target.column?.name ?? '')
|
||||
: (target.status?.name ?? ''),
|
||||
position: target.column?.position ?? 1,
|
||||
});
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
},
|
||||
);
|
||||
function close(open: boolean): void {
|
||||
if (open || form.processing || !protection.confirmLeave()) {
|
||||
return;
|
||||
}
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
emit('close');
|
||||
}
|
||||
function submit(): void {
|
||||
const target = props.target;
|
||||
if (
|
||||
!target ||
|
||||
form.processing ||
|
||||
(target.kind === 'status' &&
|
||||
target.action === 'delete' &&
|
||||
!target.replacement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route =
|
||||
target.kind === 'column'
|
||||
? target.action === 'create'
|
||||
? store(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? update([props.projectId, target.column.id])
|
||||
: destroy([props.projectId, target.column.id])
|
||||
: target.action === 'create'
|
||||
? storeStatus(props.projectId)
|
||||
: target.action === 'edit'
|
||||
? updateStatus([props.projectId, target.status.id])
|
||||
: destroyStatus([props.projectId, target.status.id]);
|
||||
form.transform((data) =>
|
||||
target.action === 'delete'
|
||||
? {}
|
||||
: target.kind === 'column' && target.action === 'edit'
|
||||
? data
|
||||
: target.kind === 'status' && target.action === 'create'
|
||||
? {
|
||||
name: data.name,
|
||||
board_column_id: target.column?.id ?? null,
|
||||
}
|
||||
: { name: data.name },
|
||||
);
|
||||
protection.submit(route, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
form.resetAndClearErrors();
|
||||
emit('close');
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="target !== null" @update:open="close">
|
||||
<DialogContent
|
||||
:show-close-button="!form.processing"
|
||||
@escape-key-down="form.processing && $event.preventDefault()"
|
||||
@interact-outside="form.processing && $event.preventDefault()"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription class="break-words">{{
|
||||
description
|
||||
}}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
class="gap-column grid min-w-0"
|
||||
:aria-busy="form.processing"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||
<InputError
|
||||
v-for="message in otherErrors"
|
||||
:key="message"
|
||||
:message="message"
|
||||
/>
|
||||
<fieldset
|
||||
v-if="!deleting"
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="workflow-name">{{
|
||||
t(
|
||||
target?.kind === 'column'
|
||||
? 'workflow.columnName'
|
||||
: 'workflow.statusName',
|
||||
)
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
v-model="form.name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
:aria-invalid="!!form.errors.name"
|
||||
:aria-describedby="
|
||||
form.errors.name
|
||||
? 'workflow-name-error'
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<InputError
|
||||
id="workflow-name-error"
|
||||
:message="form.errors.name"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
target?.kind === 'column' &&
|
||||
target.action === 'edit'
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label for="workflow-position">{{
|
||||
t('workflow.position')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="workflow-position"
|
||||
v-model="form.position"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="columnCount"
|
||||
step="1"
|
||||
required
|
||||
:aria-invalid="!!form.errors.position"
|
||||
aria-describedby="workflow-position-help workflow-position-error"
|
||||
/>
|
||||
<p
|
||||
id="workflow-position-help"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('workflow.positionHelp', {
|
||||
count: columnCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<InputError
|
||||
id="workflow-position-error"
|
||||
:message="form.errors.position"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="form.processing"
|
||||
@click="close(false)"
|
||||
>{{ t('common.cancel') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
:variant="deleting ? 'destructive' : 'default'"
|
||||
:loading="form.processing"
|
||||
>{{
|
||||
deleting
|
||||
? t('workflow.confirmDelete')
|
||||
: target?.action === 'create'
|
||||
? title
|
||||
: t('common.save')
|
||||
}}</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
defineProps<{
|
||||
column: BoardColumnModel | null;
|
||||
statuses: TicketStatusModel[];
|
||||
}>();
|
||||
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-3">
|
||||
<ul
|
||||
class="divide-y"
|
||||
:aria-label="
|
||||
t('workflow.statusesIn', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
>
|
||||
<li
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
|
||||
>{{ status.name }}</span
|
||||
>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('workflow.renameStatus', { name: status.name })
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'edit',
|
||||
column,
|
||||
status,
|
||||
})
|
||||
"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:disabled="statuses.length <= 1"
|
||||
:aria-label="
|
||||
t('workflow.removeStatus', { name: status.name })
|
||||
"
|
||||
:aria-describedby="
|
||||
statuses.length <= 1
|
||||
? `status-protection-${column?.id ?? 'backlog'}`
|
||||
: undefined
|
||||
"
|
||||
@click="
|
||||
$emit('edit', {
|
||||
kind: 'status',
|
||||
action: 'delete',
|
||||
column,
|
||||
status,
|
||||
replacement: statuses.find(
|
||||
(item) => item.id !== status.id,
|
||||
),
|
||||
})
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-if="statuses.length <= 1"
|
||||
:id="`status-protection-${column?.id ?? 'backlog'}`"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{ t('workflow.lastStatus') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-fit"
|
||||
:aria-label="
|
||||
t('workflow.addStatusTo', {
|
||||
context: column?.name ?? t('workflow.backlog'),
|
||||
})
|
||||
"
|
||||
@click="$emit('edit', { kind: 'status', action: 'create', column })"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('workflow.addStatus')
|
||||
}}</Button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
|
||||
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>
|
||||
<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>
|
||||
</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="
|
||||
targets.length > 0 &&
|
||||
form.allowed_ticket_status_ids.length === 0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('workflow.noMovesHelp') }}
|
||||
</p>
|
||||
<div id="workflow-transition-errors" role="alert">
|
||||
<InputError
|
||||
v-for="(message, key) in form.errors"
|
||||
:key="key"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,4 +1,69 @@
|
||||
export const en = {
|
||||
'workflow.title': 'Board workflow',
|
||||
'workflow.pageTitle': '{project} · Board workflow',
|
||||
'workflow.description':
|
||||
'Organize your board into columns and give each stage the statuses your team needs.',
|
||||
'workflow.updated': 'Workflow updated.',
|
||||
'workflow.backlog': 'Backlog',
|
||||
'workflow.offBoard': 'Off the board',
|
||||
'workflow.backlogDescription':
|
||||
'Your intake queue. Tickets with these statuses stay outside the board columns.',
|
||||
'workflow.columns': 'Board columns',
|
||||
'workflow.columnsDescription':
|
||||
'Columns appear from left to right in this order. Edit a column to change its name or position.',
|
||||
'workflow.emptyColumns': 'No board columns yet',
|
||||
'workflow.emptyColumnsDescription':
|
||||
'Add your first column to start organizing tickets on the board.',
|
||||
'workflow.addColumn': 'Add column',
|
||||
'workflow.editColumn': 'Edit column',
|
||||
'workflow.deleteColumn': 'Delete column?',
|
||||
'workflow.addColumnDescription':
|
||||
'The column is added at the end of the board with a first status of the same name.',
|
||||
'workflow.editColumnDescription':
|
||||
'Rename this column or move it to a different position. Its statuses keep their names.',
|
||||
'workflow.deleteColumnDescription':
|
||||
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
|
||||
'workflow.addStatus': 'Add status',
|
||||
'workflow.editStatus': 'Rename status',
|
||||
'workflow.deleteStatus': 'Delete status?',
|
||||
'workflow.statusContext': 'This status belongs to {context}.',
|
||||
'workflow.deleteStatusDescription':
|
||||
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
|
||||
'workflow.columnName': 'Column name',
|
||||
'workflow.statusName': 'Status name',
|
||||
'workflow.position': 'Position',
|
||||
'workflow.positionValue': 'Position {position}',
|
||||
'workflow.positionHelp':
|
||||
'Choose a position from 1 to {count}. Other columns shift automatically.',
|
||||
'workflow.statusesIn': 'Statuses in {context}',
|
||||
'workflow.renameStatus': 'Rename status {name}',
|
||||
'workflow.removeStatus': 'Delete status {name}',
|
||||
'workflow.changeColumn': 'Edit column {name}',
|
||||
'workflow.removeColumn': 'Delete column {name}',
|
||||
'workflow.addStatusTo': 'Add status to {context}',
|
||||
'workflow.lastStatus':
|
||||
'Keep at least one status in this group. The last status cannot be deleted.',
|
||||
'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',
|
||||
'security.passwordTitle': 'Update password',
|
||||
'security.passwordDescription':
|
||||
@@ -848,4 +913,51 @@ export const en = {
|
||||
'gitConnections.added': 'Git connection added.',
|
||||
'gitConnections.updated': 'Git connection updated.',
|
||||
'gitConnections.removed': 'Git connection removed.',
|
||||
'metadata.title': 'Ticket settings',
|
||||
'metadata.pageTitle': '{project} ticket settings',
|
||||
'metadata.description':
|
||||
'Manage ticket types, priorities, and the information shown on your tickets.',
|
||||
'metadata.updated': 'Ticket settings updated.',
|
||||
'metadata.types': 'Ticket types',
|
||||
'metadata.priorities': 'Priorities',
|
||||
'metadata.addType': 'Add type',
|
||||
'metadata.addPriority': 'Add priority',
|
||||
'metadata.typesDescription':
|
||||
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
|
||||
'metadata.prioritiesDescription':
|
||||
'Define and order project-specific priorities. The last priority must be kept.',
|
||||
'metadata.parentType': 'Parent type',
|
||||
'metadata.standardType': 'Standard type',
|
||||
'metadata.assignedTickets': 'Assigned tickets: {count}',
|
||||
'metadata.editEntry': 'Edit {name}',
|
||||
'metadata.deleteEntry': 'Delete {name}',
|
||||
'metadata.lastType':
|
||||
'The last ticket type cannot be deleted. Add another type first.',
|
||||
'metadata.lastPriority':
|
||||
'The last priority cannot be deleted. Add another priority first.',
|
||||
'metadata.display': 'Display options',
|
||||
'metadata.displayDescription':
|
||||
'Choose which optional information is available on this project’s tickets.',
|
||||
'metadata.author': 'Show ticket author',
|
||||
'metadata.authorHelp': 'Display the author on ticket details.',
|
||||
'metadata.storyPoints': 'Enable story points',
|
||||
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
|
||||
'metadata.name': 'Name',
|
||||
'metadata.positionHelp':
|
||||
'Choose a position from 1 to {count}. Other entries shift automatically.',
|
||||
'metadata.allowChildren': 'Allow child tickets',
|
||||
'metadata.parentFixed':
|
||||
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
|
||||
'metadata.editHelp':
|
||||
'Choose a project-specific name. Changes apply to tickets using this entry.',
|
||||
'metadata.deleteTypeHelp':
|
||||
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
|
||||
'metadata.deletePriorityHelp':
|
||||
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
|
||||
'metadata.replacementHelp':
|
||||
'Move {count} assigned tickets to another type before deleting this one.',
|
||||
'metadata.replacement': 'Replacement ticket type',
|
||||
'metadata.chooseType': 'Choose a type',
|
||||
'metadata.parentReplacement':
|
||||
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
|
||||
} as const;
|
||||
|
||||
@@ -1,674 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
|
||||
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
|
||||
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
board as boardSettings,
|
||||
general as generalSettings,
|
||||
metadata as metadataSettings,
|
||||
notifications as notificationSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { board } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||
|
||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Board settings',
|
||||
href: boardSettings(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
const transitionTargetOptions = (
|
||||
sourceStatus: TicketStatusModel,
|
||||
): TicketStatusModel[] => {
|
||||
return props.ticketStatuses.filter(
|
||||
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
|
||||
);
|
||||
};
|
||||
|
||||
const statusAllowsTarget = (
|
||||
sourceStatus: TicketStatusModel,
|
||||
targetStatus: TicketStatusModel,
|
||||
): boolean => {
|
||||
return (
|
||||
sourceStatus.allowed_target_statuses?.some(
|
||||
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
|
||||
) ?? false
|
||||
);
|
||||
};
|
||||
const { t } = useLocalization();
|
||||
const target = ref<WorkflowEditorTarget | null>(null);
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.projects'), href: index() },
|
||||
{ title: props.project.name, href: show(props.project.id) },
|
||||
{ title: t('workflow.title'), href: board(props.project.id) },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} board settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} board settings`"
|
||||
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="$page.props.auth.permissions.globalAdmin"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="generalSettings(project.id)">
|
||||
General settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Ticket settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="notificationSettings(project.id)">
|
||||
Notification settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<Head :title="t('workflow.pageTitle', { project: project.name })" />
|
||||
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||
<header class="grid gap-3">
|
||||
<h2 class="text-title">{{ t('workflow.title') }}</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ t('workflow.description') }}
|
||||
</p>
|
||||
</header>
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
state="success"
|
||||
:title="t('workflow.updated')"
|
||||
/>
|
||||
<TemplateInheritancePanel
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
:areas="[
|
||||
'board_columns',
|
||||
'ticket_statuses',
|
||||
'status_transitions',
|
||||
]"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow model</CardTitle>
|
||||
<CardDescription>
|
||||
Backlog is always the intake queue; board columns group
|
||||
the statuses that appear on the board.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-wrap gap-3">
|
||||
<Badge class="px-3 py-1">Backlog</Badge>
|
||||
<Badge
|
||||
v-for="ticketStatus in backlogTicketStatuses"
|
||||
:key="ticketStatus.id"
|
||||
variant="outline"
|
||||
class="px-3 py-1"
|
||||
>
|
||||
{{ ticketStatus.name }}
|
||||
</Badge>
|
||||
<Card
|
||||
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||
>
|
||||
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Inbox
|
||||
class="text-primary size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 class="font-semibold">
|
||||
{{ t('workflow.backlog') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{
|
||||
t('workflow.offBoard')
|
||||
}}</Badge>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Tickets move between statuses; backlog statuses are not
|
||||
included in a board column.
|
||||
{{ t('workflow.backlogDescription') }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Status moves</CardTitle>
|
||||
<CardDescription>
|
||||
Choose exactly where each status is allowed to move
|
||||
next.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="divide-border min-w-full divide-y text-sm"
|
||||
>
|
||||
<thead
|
||||
class="bg-muted/40 text-muted-foreground text-left"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Source status
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
Allowed move targets
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">Save</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-border divide-y">
|
||||
<tr
|
||||
v-for="sourceStatus in ticketStatuses"
|
||||
:key="sourceStatus.id"
|
||||
class="bg-background align-top"
|
||||
>
|
||||
<td class="px-4 py-4">
|
||||
<Badge variant="outline">
|
||||
{{
|
||||
ticketStatusLabel(sourceStatus)
|
||||
}}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Form
|
||||
:id="`status-transitions-${sourceStatus.id}`"
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatusTransitions.form(
|
||||
[
|
||||
project.id,
|
||||
sourceStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="grid gap-3"
|
||||
v-slot="{ errors }"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
transitionTargetOptions(
|
||||
sourceStatus,
|
||||
).length === 0
|
||||
"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
No other statuses exist yet.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<label
|
||||
v-for="targetStatus in transitionTargetOptions(
|
||||
sourceStatus,
|
||||
)"
|
||||
:key="targetStatus.id"
|
||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allowed_ticket_status_ids[]"
|
||||
:value="targetStatus.id"
|
||||
:checked="
|
||||
statusAllowsTarget(
|
||||
sourceStatus,
|
||||
targetStatus,
|
||||
)
|
||||
"
|
||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
||||
/>
|
||||
<span>{{
|
||||
ticketStatusLabel(
|
||||
targetStatus,
|
||||
)
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
<InputError
|
||||
:message="
|
||||
errors.allowed_ticket_status_ids
|
||||
"
|
||||
/>
|
||||
</Form>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:form="`status-transitions-${sourceStatus.id}`"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<CardContent class="p-5 sm:p-6"
|
||||
><WorkflowStatusList
|
||||
:column="null"
|
||||
:statuses="backlogTicketStatuses"
|
||||
@edit="target = $event"
|
||||
/></CardContent>
|
||||
</Card>
|
||||
<section
|
||||
class="grid min-w-0 gap-4"
|
||||
aria-labelledby="workflow-columns"
|
||||
>
|
||||
<header
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Columns3
|
||||
class="text-primary size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h3 id="workflow-columns" class="font-semibold">
|
||||
{{ t('workflow.columns') }}
|
||||
</h3>
|
||||
<Badge variant="secondary">{{
|
||||
boardColumns.length
|
||||
}}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backlog statuses</CardTitle>
|
||||
<CardDescription>
|
||||
New backlog tickets choose one of these statuses.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
<div
|
||||
v-for="ticketStatus in backlogTicketStatuses"
|
||||
:key="ticketStatus.id"
|
||||
class="border-border rounded-lg border p-4"
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
@click="target = { kind: 'column', action: 'create' }"
|
||||
><Plus class="size-4" aria-hidden="true" />{{
|
||||
t('workflow.addColumn')
|
||||
}}</Button
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatus.form(
|
||||
[project.id, ticketStatus.id],
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
</header>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
{{ t('workflow.columnsDescription') }}
|
||||
</p>
|
||||
<FeedbackState
|
||||
v-if="boardColumns.length === 0"
|
||||
state="empty"
|
||||
:title="t('workflow.emptyColumns')"
|
||||
:description="t('workflow.emptyColumnsDescription')"
|
||||
/>
|
||||
<ol
|
||||
v-else
|
||||
class="grid min-w-0 gap-4"
|
||||
:aria-label="t('workflow.columns')"
|
||||
>
|
||||
<li
|
||||
v-for="column in boardColumns"
|
||||
:key="column.id"
|
||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||
:data-column-id="column.id"
|
||||
>
|
||||
<header
|
||||
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`backlog-status-${ticketStatus.id}`"
|
||||
>
|
||||
Status name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`backlog-status-${ticketStatus.id}`"
|
||||
name="name"
|
||||
:default-value="ticketStatus.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroyStatus.form(
|
||||
[project.id, ticketStatus.id],
|
||||
)
|
||||
<span
|
||||
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||
:aria-label="
|
||||
t('workflow.positionValue', {
|
||||
position: column.position,
|
||||
})
|
||||
"
|
||||
v-slot="{ errors, processing }"
|
||||
>{{ column.position }}</span
|
||||
>
|
||||
<InputError
|
||||
:message="errors.ticket_status_id"
|
||||
class="mb-2"
|
||||
/>
|
||||
<h4
|
||||
class="min-w-0 flex-1 basis-24 break-words font-semibold"
|
||||
>
|
||||
{{ column.name }}
|
||||
</h4>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="
|
||||
processing ||
|
||||
backlogTicketStatuses.length <= 1
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('workflow.changeColumn', {
|
||||
name: column.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.storeStatus.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="backlog-status-name">
|
||||
New backlog status
|
||||
</Label>
|
||||
<Input
|
||||
id="backlog-status-name"
|
||||
name="name"
|
||||
placeholder="Ready"
|
||||
required
|
||||
@click="
|
||||
target = {
|
||||
kind: 'column',
|
||||
action: 'edit',
|
||||
column,
|
||||
}
|
||||
"
|
||||
><Pencil class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-destructive"
|
||||
:aria-label="
|
||||
t('workflow.removeColumn', {
|
||||
name: column.name,
|
||||
})
|
||||
"
|
||||
@click="
|
||||
target = {
|
||||
kind: 'column',
|
||||
action: 'delete',
|
||||
column,
|
||||
}
|
||||
"
|
||||
><Trash2 class="size-4" aria-hidden="true"
|
||||
/></Button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-4 sm:p-5">
|
||||
<WorkflowStatusList
|
||||
:column="column"
|
||||
:statuses="column.ticket_statuses ?? []"
|
||||
@edit="target = $event"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add status
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add column</CardTitle>
|
||||
<CardDescription>
|
||||
New columns extend the project-specific workflow and
|
||||
are inserted at the end unless you specify a
|
||||
position later.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.store.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="space-y-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Column name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="In Review"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
Add column
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Board columns</CardTitle>
|
||||
<CardDescription>
|
||||
Rename columns or change the numeric position to
|
||||
reorder the board from left to right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="boardColumns.length === 0"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No board columns exist yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="column in boardColumns"
|
||||
:key="column.id"
|
||||
class="border-border rounded-xl border p-4"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.update.form([
|
||||
project.id,
|
||||
column.id,
|
||||
])
|
||||
"
|
||||
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label :for="`column-name-${column.id}`">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-name-${column.id}`"
|
||||
name="name"
|
||||
:default-value="column.name"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`column-position-${column.id}`"
|
||||
>
|
||||
Position
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-position-${column.id}`"
|
||||
name="position"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="boardColumns.length"
|
||||
:default-value="column.position"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.position" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroy.form(
|
||||
[project.id, column.id],
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div class="border-border mt-4 border-t pt-4">
|
||||
<div class="mb-3">
|
||||
<p class="text-sm font-medium">
|
||||
Ticket statuses
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Moving into this column requires a
|
||||
specific status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="ticketStatus in column.ticket_statuses ??
|
||||
[]"
|
||||
:key="ticketStatus.id"
|
||||
class="border-border rounded-lg border p-3"
|
||||
>
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.updateStatus.form(
|
||||
[
|
||||
project.id,
|
||||
ticketStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`column-status-${ticketStatus.id}`"
|
||||
>
|
||||
Status name
|
||||
</Label>
|
||||
<Input
|
||||
:id="`column-status-${ticketStatus.id}`"
|
||||
name="name"
|
||||
:default-value="
|
||||
ticketStatus.name
|
||||
"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
:message="errors.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="processing"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.destroyStatus.form(
|
||||
[
|
||||
project.id,
|
||||
ticketStatus.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<InputError
|
||||
:message="
|
||||
errors.ticket_status_id
|
||||
"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="
|
||||
processing ||
|
||||
(column.ticket_statuses
|
||||
?.length ?? 0) <= 1
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectBoardSettingsController.storeStatus.form(
|
||||
project.id,
|
||||
)
|
||||
"
|
||||
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="board_column_id"
|
||||
:value="column.id"
|
||||
/>
|
||||
<div class="grid gap-2">
|
||||
<Label
|
||||
:for="`new-column-status-${column.id}`"
|
||||
>
|
||||
New status
|
||||
</Label>
|
||||
<Input
|
||||
:id="`new-column-status-${column.id}`"
|
||||
name="name"
|
||||
placeholder="Ready for QA"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="processing"
|
||||
>
|
||||
Add status
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<WorkflowTransitionMatrix
|
||||
:project-id="project.id"
|
||||
:statuses="ticketStatuses"
|
||||
/>
|
||||
<WorkflowEditorDialog
|
||||
:project-id="project.id"
|
||||
:column-count="boardColumns.length"
|
||||
:target="target"
|
||||
@close="target = null"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||
|
||||
export type MetadataKind = 'type' | 'priority';
|
||||
export type MetadataEntry = TicketPriorityModel &
|
||||
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
|
||||
export type MetadataEditorTarget = { kind: MetadataKind } & (
|
||||
| { action: 'create'; entry?: never }
|
||||
| { action: 'edit' | 'delete'; entry: MetadataEntry }
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||
|
||||
export type WorkflowEditorTarget =
|
||||
| { kind: 'column'; action: 'create'; column?: never }
|
||||
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
|
||||
| {
|
||||
kind: 'status';
|
||||
action: 'create';
|
||||
column: BoardColumnModel | null;
|
||||
status?: never;
|
||||
replacement?: never;
|
||||
}
|
||||
| {
|
||||
kind: 'status';
|
||||
action: 'edit' | 'delete';
|
||||
column: BoardColumnModel | null;
|
||||
status: TicketStatusModel;
|
||||
replacement?: TicketStatusModel;
|
||||
};
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
||||
'id' => $backlogStatus->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
|
||||
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
|
||||
->sequence(['position' => 1], ['position' => 2])->create();
|
||||
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
|
||||
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
|
||||
->assertRedirect(route('projects.settings.board', $project))
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->assertModelMissing($column);
|
||||
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
|
||||
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
|
||||
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
|
||||
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
|
||||
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
|
||||
}
|
||||
|
||||
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||
$status = TicketStatus::factory()->for($project)->for($column)->create();
|
||||
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
|
||||
|
||||
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
|
||||
->assertSessionHasErrors('ticket_status_id');
|
||||
$this->assertModelExists($status);
|
||||
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
||||
|
||||
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
|
||||
->assertSessionHasErrors('position');
|
||||
$this->assertSame(1, $column->fresh()->position);
|
||||
$this->assertSame($column->name, $column->fresh()->name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectBoardSettingsUiTest extends TestCase
|
||||
{
|
||||
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
||||
'id' => $ticketLinkType->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_last_priority_is_protected_from_deletion(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$priority = TicketPriority::factory()->for($project)->create();
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
|
||||
->assertRedirect(route('projects.settings.metadata', $project))
|
||||
->assertSessionHas('error', true)
|
||||
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
|
||||
|
||||
$this->assertModelExists($priority);
|
||||
}
|
||||
|
||||
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$types = $project->ticketTypes()->get();
|
||||
$first = $types->first();
|
||||
$last = $types->last();
|
||||
Ticket::factory()->for($project)->create([
|
||||
'ticket_type_id' => $last->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
|
||||
'name' => 'Customer request', 'position' => 1,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$this->get(route('projects.settings.metadata', $project))
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->where('ticketTypes.0.id', $last->id)
|
||||
->where('ticketTypes.0.name', 'Customer request')
|
||||
->where('ticketTypes.0.position', 1)
|
||||
->where('ticketTypes.0.tickets_count', 1)
|
||||
->where('ticketTypes.1.id', $first->id)
|
||||
->where('ticketTypes.1.position', 2)
|
||||
->where('ticketTypes.1.tickets_count', 0));
|
||||
}
|
||||
|
||||
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$type = $project->ticketTypes()->firstOrFail();
|
||||
$foreignType = TicketType::factory()->create();
|
||||
$ticket = Ticket::factory()->for($project)->create([
|
||||
'ticket_type_id' => $type->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.settings.metadata', $project))
|
||||
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
|
||||
'replacement_ticket_type_id' => $foreignType->id,
|
||||
])
|
||||
->assertSessionHasErrors('replacement_ticket_type_id');
|
||||
|
||||
$this->assertModelExists($type);
|
||||
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectTicketMetadataSettingsUiTest extends TestCase
|
||||
{
|
||||
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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({
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const requests = [];
|
||||
page.on('request', (request) => {
|
||||
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||
requests.push(request);
|
||||
}
|
||||
});
|
||||
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||
const dialog = () => page.getByRole('dialog');
|
||||
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 page
|
||||
.getByRole('heading', { name: 'Board workflow', exact: true })
|
||||
.waitFor();
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
const submit = async (label, method, pathname, expected) => {
|
||||
const sent = page.waitForRequest((request) => request.method() === method);
|
||||
await dialog().getByRole('button', { name: label, exact: true }).click();
|
||||
const request = await sent;
|
||||
assert.equal(new URL(request.url()).pathname, pathname);
|
||||
assert.deepEqual(request.postDataJSON(), expected);
|
||||
};
|
||||
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||
const formState = (state) =>
|
||||
dialog()
|
||||
.locator('[data-form-state="' + state + '"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
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();
|
||||
await noOverflow();
|
||||
assert.equal(
|
||||
await button('Delete status Backlog').isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await button('Delete status Ready').isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await button('Delete status Review').isDisabled(),
|
||||
false,
|
||||
);
|
||||
const trigger = button('Edit column In progress');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog().waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Column name', { exact: true })
|
||||
.inputValue(),
|
||||
'In progress',
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await dialog().evaluate((element) =>
|
||||
element.contains(document.activeElement),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await noOverflow();
|
||||
await page.keyboard.press('Escape');
|
||||
await closed();
|
||||
assert.equal(
|
||||
await trigger.evaluate(
|
||||
(element) => document.activeElement === element,
|
||||
),
|
||||
true,
|
||||
);
|
||||
if ([390, 1440].includes(width)) {
|
||||
await page.screenshot({
|
||||
path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png',
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
assert.equal(requests.length, 0);
|
||||
}
|
||||
}
|
||||
await visit('?reset&empty');
|
||||
await page.getByText('No board columns yet', { exact: true }).waitFor();
|
||||
await visit();
|
||||
await button('Add column').click();
|
||||
await page
|
||||
.getByLabel('Column name', { exact: true })
|
||||
.fill('Quality assurance');
|
||||
await result('validation');
|
||||
await submit('Add column', 'POST', '/projects/1/settings/board/columns', {
|
||||
name: 'Quality assurance',
|
||||
});
|
||||
await formState('validation');
|
||||
assert.equal(
|
||||
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||
'Quality assurance',
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Column name', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await result('server');
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Add column', exact: true })
|
||||
.click();
|
||||
await formState('server');
|
||||
await result('success');
|
||||
await dialog()
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.click();
|
||||
await closed();
|
||||
await button('Edit column Quality assurance').waitFor();
|
||||
const newColumnId = await page
|
||||
.locator('[data-column-id]')
|
||||
.last()
|
||||
.getAttribute('data-column-id');
|
||||
await button('Edit column Quality assurance').click();
|
||||
await page.getByLabel('Column name', { exact: true }).fill('QA');
|
||||
await page.getByLabel('Position', { exact: true }).fill('1');
|
||||
await result('position');
|
||||
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await formState('validation');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByLabel('Position', { exact: true })
|
||||
.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
await result('success');
|
||||
await submit(
|
||||
'Save',
|
||||
'PATCH',
|
||||
'/projects/1/settings/board/columns/' + newColumnId,
|
||||
{ name: 'QA', position: 1 },
|
||||
);
|
||||
await closed();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-column-id]')
|
||||
.first()
|
||||
.getAttribute('data-column-id'),
|
||||
newColumnId,
|
||||
);
|
||||
await button('Edit column QA').click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Position', { exact: true }).inputValue(),
|
||||
'1',
|
||||
);
|
||||
assert.equal(
|
||||
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||
'QA',
|
||||
);
|
||||
await page.getByLabel('Column name', { exact: true }).fill('Unsaved');
|
||||
page.once('dialog', (prompt) => prompt.dismiss());
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
assert.equal(await dialog().isVisible(), true);
|
||||
page.once('dialog', (prompt) => prompt.accept());
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await closed();
|
||||
await button('Add status to Backlog').click();
|
||||
assert.equal(
|
||||
await page.getByLabel('Status name', { exact: true }).inputValue(),
|
||||
'',
|
||||
);
|
||||
await page.getByLabel('Status name', { exact: true }).fill('Triage');
|
||||
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||
name: 'Triage',
|
||||
board_column_id: null,
|
||||
});
|
||||
await closed();
|
||||
await button('Rename status Triage').waitFor();
|
||||
await button('Add status to Todo').click();
|
||||
await page.getByLabel('Status name', { exact: true }).fill('Refinement');
|
||||
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||
name: 'Refinement',
|
||||
board_column_id: 10,
|
||||
});
|
||||
await closed();
|
||||
await button('Rename status Review').click();
|
||||
await page.getByLabel('Status name', { exact: true }).fill('Peer review');
|
||||
await page.route('**/statuses/4', (route) => route.abort('failed'));
|
||||
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await formState('network');
|
||||
await page.unroute('**/statuses/4');
|
||||
await submit(
|
||||
'Try again',
|
||||
'PATCH',
|
||||
'/projects/1/settings/board/statuses/4',
|
||||
{ name: 'Peer review' },
|
||||
);
|
||||
await closed();
|
||||
const before = requests.length;
|
||||
await button('Delete status Peer review').click();
|
||||
await dialog()
|
||||
.getByText(/assigned to “Building”/)
|
||||
.waitFor();
|
||||
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await closed();
|
||||
assert.equal(requests.length, before);
|
||||
await button('Delete status Peer review').click();
|
||||
await result('validation');
|
||||
await submit(
|
||||
'Delete',
|
||||
'DELETE',
|
||||
'/projects/1/settings/board/statuses/4',
|
||||
{},
|
||||
);
|
||||
await formState('validation');
|
||||
await dialog()
|
||||
.getByText('This group needs at least one status.', { exact: true })
|
||||
.waitFor();
|
||||
await result('success');
|
||||
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await closed();
|
||||
assert.equal(await button('Delete status Building').isDisabled(), true);
|
||||
await button('Delete column In progress').click();
|
||||
await dialog()
|
||||
.getByText(/statuses move to the backlog/)
|
||||
.waitFor();
|
||||
await submit(
|
||||
'Delete',
|
||||
'DELETE',
|
||||
'/projects/1/settings/board/columns/20',
|
||||
{},
|
||||
);
|
||||
await closed();
|
||||
await page
|
||||
.getByRole('list', { name: 'Statuses in Backlog', exact: true })
|
||||
.getByText('Building', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await button('Edit column In progress').count(), 0);
|
||||
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.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Board from '../../resources/js/pages/projects/settings/Board.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Board'
|
||||
? Board
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/project-board-settings-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
const makeStatus = (id, name, column, position = 1) => ({
|
||||
id,
|
||||
name,
|
||||
board_column_id: column,
|
||||
position,
|
||||
allowed_target_statuses: [],
|
||||
});
|
||||
function initial() {
|
||||
const backlog = makeStatus(1, 'Backlog', null);
|
||||
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] },
|
||||
{
|
||||
id: 20,
|
||||
name: 'In progress',
|
||||
position: 2,
|
||||
ticket_statuses: [active, review],
|
||||
},
|
||||
];
|
||||
return {
|
||||
project: { id: 1, key: 'CORE', name: 'Core platform' },
|
||||
status: null,
|
||||
templateState: null,
|
||||
backlogTicketStatuses: [backlog],
|
||||
boardColumns: columns,
|
||||
ticketStatuses: [
|
||||
backlog,
|
||||
...columns.flatMap((column) =>
|
||||
column.ticket_statuses.map((status) => ({
|
||||
...status,
|
||||
board_column: { id: column.id, name: column.name },
|
||||
})),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
function makePage(
|
||||
extra = {},
|
||||
url = '/projects/1/settings/board',
|
||||
component = 'Board',
|
||||
) {
|
||||
return {
|
||||
component,
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#10B981',
|
||||
avatar_is_generated: true,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
id: 1,
|
||||
name: 'Core platform',
|
||||
key: 'CORE',
|
||||
canManage: true,
|
||||
},
|
||||
},
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
let nextId = 100;
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/projects')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const mutation = request.method !== 'GET';
|
||||
if (!mutation && url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
let errors = {};
|
||||
if (mutation) {
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/workflow_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
if (['server', 'forbidden'].includes(result)) {
|
||||
response.statusCode = result === 'server' ? 503 : 403;
|
||||
response.end('Simulated request failure');
|
||||
return;
|
||||
}
|
||||
if (result === 'validation') {
|
||||
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 {
|
||||
const segments = url.pathname.split('/');
|
||||
const id = Number(segments.at(-1));
|
||||
const columns = data.boardColumns;
|
||||
if (url.pathname.includes('/columns')) {
|
||||
if (request.method === 'POST') {
|
||||
const column = {
|
||||
id: nextId++,
|
||||
name: submitted.name,
|
||||
position: columns.length + 1,
|
||||
ticket_statuses: [],
|
||||
};
|
||||
column.ticket_statuses.push(
|
||||
makeStatus(nextId++, submitted.name, column.id),
|
||||
);
|
||||
columns.push(column);
|
||||
} else if (request.method === 'PATCH') {
|
||||
const column = columns.find((item) => item.id === id);
|
||||
column.name = submitted.name;
|
||||
columns.splice(columns.indexOf(column), 1);
|
||||
columns.splice(
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
Number(submitted.position) - 1,
|
||||
columns.length,
|
||||
),
|
||||
),
|
||||
0,
|
||||
column,
|
||||
);
|
||||
} else {
|
||||
const column = columns.find((item) => item.id === id);
|
||||
for (const status of column.ticket_statuses) {
|
||||
status.board_column_id = null;
|
||||
status.position =
|
||||
data.backlogTicketStatuses.length + 1;
|
||||
data.backlogTicketStatuses.push(status);
|
||||
}
|
||||
columns.splice(columns.indexOf(column), 1);
|
||||
}
|
||||
columns.forEach((column, index) => {
|
||||
column.position = index + 1;
|
||||
});
|
||||
} else if (url.pathname.endsWith('/transitions')) {
|
||||
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
|
||||
? data.backlogTicketStatuses
|
||||
: columns.find(
|
||||
(item) =>
|
||||
item.id === submitted.board_column_id,
|
||||
).ticket_statuses;
|
||||
group.push(
|
||||
makeStatus(
|
||||
nextId++,
|
||||
submitted.name,
|
||||
submitted.board_column_id,
|
||||
group.length + 1,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const group = [
|
||||
data.backlogTicketStatuses,
|
||||
...columns.map((column) => column.ticket_statuses),
|
||||
].find((items) => items.some((item) => item.id === id));
|
||||
const status = group.find((item) => item.id === id);
|
||||
if (request.method === 'PATCH') {
|
||||
status.name = submitted.name;
|
||||
} else {
|
||||
group.splice(group.indexOf(status), 1);
|
||||
}
|
||||
}
|
||||
data.ticketStatuses = [
|
||||
...data.backlogTicketStatuses,
|
||||
...columns.flatMap((column) =>
|
||||
column.ticket_statuses.map((status) => ({
|
||||
...status,
|
||||
board_column: { id: column.id, name: column.name },
|
||||
})),
|
||||
),
|
||||
];
|
||||
data.status = 'Updated';
|
||||
}
|
||||
}
|
||||
if (url.searchParams.has('empty')) {
|
||||
data.boardColumns = [];
|
||||
data.ticketStatuses = data.backlogTicketStatuses;
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-board-settings-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Project board settings fixture ready.');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { en } = await server.ssrLoadModule(
|
||||
'/resources/js/locales/en.ts',
|
||||
);
|
||||
const component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/settings/Board.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /Board workflow/);
|
||||
assert.match(html, /Off the board/);
|
||||
assert.match(html, /aria-current="page"/);
|
||||
assert.ok(
|
||||
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(
|
||||
buttons.find((button) =>
|
||||
button.includes('aria-label="Delete status ' + name + '"'),
|
||||
),
|
||||
/aria-disabled="true"/,
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
buttons.find((button) =>
|
||||
button.includes('aria-label="Delete status Review"'),
|
||||
),
|
||||
/aria-disabled="true"/,
|
||||
);
|
||||
assert.match(
|
||||
await render({ boardColumns: [] }),
|
||||
/No board columns yet/,
|
||||
);
|
||||
assert.match(
|
||||
await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{ de: { ...en, 'workflow.title': 'Board-Ablauf' } },
|
||||
),
|
||||
),
|
||||
/Board-Ablauf/,
|
||||
);
|
||||
console.log('Project board settings rendering checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Metadata from '../../resources/js/pages/projects/settings/Metadata.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
name === 'Metadata'
|
||||
? Metadata
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/project-metadata-settings-fixture',
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4189, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
function initial() {
|
||||
return {
|
||||
project: {
|
||||
id: 1,
|
||||
key: 'CORE',
|
||||
name: 'Core platform',
|
||||
show_author: true,
|
||||
show_story_points: false,
|
||||
},
|
||||
status: null,
|
||||
error: false,
|
||||
templateState: null,
|
||||
ticketTypes: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Task',
|
||||
position: 1,
|
||||
is_parent: false,
|
||||
tickets_count: 2,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Release',
|
||||
position: 2,
|
||||
is_parent: true,
|
||||
tickets_count: 1,
|
||||
},
|
||||
],
|
||||
ticketPriorities: [
|
||||
{ id: 3, name: 'Normal', position: 1 },
|
||||
{ id: 4, name: 'Urgent', position: 2 },
|
||||
],
|
||||
ticketFieldDefinitions: [],
|
||||
ticketLinkTypes: [],
|
||||
fieldTypeOptions: [],
|
||||
};
|
||||
}
|
||||
function makePage(
|
||||
extra = {},
|
||||
url = '/projects/1/settings/metadata',
|
||||
component = 'Metadata',
|
||||
) {
|
||||
return {
|
||||
component,
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: {},
|
||||
sidebarOpen: true,
|
||||
appearance: 'system',
|
||||
auth: {
|
||||
user: {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex@example.test',
|
||||
avatar: null,
|
||||
avatar_color: '#10B981',
|
||||
avatar_is_generated: true,
|
||||
},
|
||||
permissions: {
|
||||
globalAdmin: false,
|
||||
projectCreate: false,
|
||||
canManageTeams: false,
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
project: {
|
||||
id: 1,
|
||||
name: 'Core platform',
|
||||
key: 'CORE',
|
||||
canManage: true,
|
||||
},
|
||||
},
|
||||
...initial(),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (serve) {
|
||||
let data = initial();
|
||||
let nextId = 100;
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
if (!request.url.startsWith('/projects')) {
|
||||
return next();
|
||||
}
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const mutation = request.method !== 'GET';
|
||||
if (!mutation && url.searchParams.has('reset')) {
|
||||
data = initial();
|
||||
}
|
||||
let errors = {};
|
||||
if (mutation) {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const submitted = JSON.parse(body || '{}');
|
||||
const result =
|
||||
request.headers.cookie?.match(
|
||||
/metadata_test_result=(\w+)/,
|
||||
)?.[1] ?? 'success';
|
||||
if (result === 'server') {
|
||||
response.statusCode = 500;
|
||||
response.end('Unavailable');
|
||||
return;
|
||||
}
|
||||
const kind = url.pathname.includes('/types') ? 'type' : 'priority';
|
||||
const entries =
|
||||
kind === 'type' ? data.ticketTypes : data.ticketPriorities;
|
||||
const id = Number(url.pathname.split('/').at(-1));
|
||||
if (
|
||||
result === 'validation' ||
|
||||
(submitted.name &&
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.name === submitted.name && entry.id !== id,
|
||||
))
|
||||
) {
|
||||
errors =
|
||||
request.method === 'DELETE'
|
||||
? {
|
||||
replacement_ticket_type_id:
|
||||
'Choose a parent-enabled replacement type.',
|
||||
}
|
||||
: { name: 'This name is already in use.' };
|
||||
} else if (request.method === 'DELETE') {
|
||||
entries.splice(
|
||||
entries.findIndex((item) => item.id === id),
|
||||
1,
|
||||
);
|
||||
} else if (url.pathname.endsWith('/metadata')) {
|
||||
Object.assign(data.project, submitted);
|
||||
} else if (request.method === 'POST') {
|
||||
entries.push({
|
||||
id: nextId++,
|
||||
position: entries.length + 1,
|
||||
tickets_count: 0,
|
||||
...submitted,
|
||||
});
|
||||
} else {
|
||||
const item = entries.find((item) => item.id === id);
|
||||
entries.splice(entries.indexOf(item), 1);
|
||||
Object.assign(item, submitted);
|
||||
entries.splice(submitted.position - 1, 0, item);
|
||||
}
|
||||
entries.forEach((entry, index) => (entry.position = index + 1));
|
||||
data.status = Object.keys(errors).length ? null : 'Updated';
|
||||
}
|
||||
if (url.searchParams.has('single')) {
|
||||
data.ticketTypes = data.ticketTypes.slice(0, 1);
|
||||
data.ticketPriorities = data.ticketPriorities.slice(0, 1);
|
||||
}
|
||||
const page = makePage({ ...data, errors });
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/project-metadata-settings-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Metadata settings fixture ready.');
|
||||
} else {
|
||||
try {
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const { createAppearance } = await server.ssrLoadModule(
|
||||
'/resources/js/composables/useAppearance.ts',
|
||||
);
|
||||
const { en } = await server.ssrLoadModule(
|
||||
'/resources/js/locales/en.ts',
|
||||
);
|
||||
const component = (
|
||||
await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/settings/Metadata.vue',
|
||||
)
|
||||
).default;
|
||||
const render = async (extra = {}, locale = createLocalization()) =>
|
||||
(
|
||||
await createInertiaApp({
|
||||
page: makePage(extra),
|
||||
resolve: () => component,
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(locale)
|
||||
.use(createAppearance()),
|
||||
})
|
||||
).body;
|
||||
const html = await render();
|
||||
assert.match(html, /Ticket settings/);
|
||||
assert.match(html, /aria-current="page"/);
|
||||
assert.ok(
|
||||
html.indexOf('data-metadata-entry="type-1"') <
|
||||
html.indexOf('data-metadata-entry="type-2"'),
|
||||
);
|
||||
assert.match(html, /Assigned tickets: 2/);
|
||||
assert.match(html, /Parent type/);
|
||||
assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
|
||||
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
|
||||
const single = await render({
|
||||
ticketTypes: initial().ticketTypes.slice(0, 1),
|
||||
ticketPriorities: initial().ticketPriorities.slice(0, 1),
|
||||
});
|
||||
const buttons = single.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||
for (const name of ['Task', 'Normal']) {
|
||||
assert.match(
|
||||
buttons.find((button) =>
|
||||
button.includes('aria-label="Delete ' + name + '"'),
|
||||
),
|
||||
/aria-disabled="true"/,
|
||||
);
|
||||
}
|
||||
assert.match(single, /The last ticket type cannot be deleted/);
|
||||
assert.match(single, /The last priority cannot be deleted/);
|
||||
assert.match(
|
||||
await render(
|
||||
{},
|
||||
createLocalization(
|
||||
{ locale: 'de' },
|
||||
{ de: { ...en, 'metadata.title': 'Ticket-Einstellungen' } },
|
||||
),
|
||||
),
|
||||
/Ticket-Einstellungen/,
|
||||
);
|
||||
assert.match(
|
||||
await render({
|
||||
status: 'The last ticket type cannot be deleted.',
|
||||
error: true,
|
||||
}),
|
||||
/role="alert"/,
|
||||
);
|
||||
console.log('Metadata settings checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
@@ -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