Compare commits
4
Commits
v0.2.4
..
4518de5bde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4518de5bde | ||
|
|
a028eacec2 | ||
|
|
d9de375b59 | ||
|
|
7e483d0b58 |
@@ -1,363 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,282 +1,91 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { Form } from '@inertiajs/vue3';
|
||||||
import { ArrowRightLeft } from 'lucide-vue-next';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{ projectId: number; statuses: TicketStatusModel[] }>();
|
||||||
projectId: number;
|
|
||||||
statuses: TicketStatusModel[];
|
|
||||||
}>();
|
|
||||||
const { t } = useLocalization();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<details class="bg-card min-w-0 rounded-xl border">
|
||||||
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
<summary
|
||||||
aria-labelledby="workflow-transitions"
|
class="focus-visible:ring-ring/40 cursor-pointer rounded-xl p-5 font-semibold outline-none focus-visible:ring-2 sm:p-6"
|
||||||
>
|
>
|
||||||
<header class="grid gap-3 border-b p-5 sm:p-6">
|
{{ t('workflow.transitions') }}
|
||||||
<div class="flex items-center gap-2">
|
</summary>
|
||||||
<ArrowRightLeft
|
<div class="grid min-w-0 gap-4 border-t p-5 sm:p-6">
|
||||||
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">
|
<p class="text-muted-foreground text-sm">
|
||||||
{{ t('workflow.transitionsDescription') }}
|
{{ t('workflow.transitionsDescription') }}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
<Form
|
||||||
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
v-for="source in statuses"
|
||||||
<p v-if="!source" class="text-muted-foreground text-sm">
|
:key="source.id"
|
||||||
{{ t('workflow.noStatuses') }}
|
v-bind="updateStatusTransitions.form([projectId, source.id])"
|
||||||
</p>
|
preserve-scroll
|
||||||
<template v-else>
|
set-defaults-on-success
|
||||||
<div class="grid min-w-0 gap-2">
|
class="grid min-w-0 gap-3 rounded-lg border p-4"
|
||||||
<Label for="workflow-source">{{
|
v-slot="{ errors, processing, recentlySuccessful }"
|
||||||
t('workflow.sourceStatus')
|
>
|
||||||
}}</Label>
|
<fieldset :disabled="processing" class="grid min-w-0 gap-3">
|
||||||
<select
|
<legend class="mb-3 break-words font-medium">
|
||||||
id="workflow-source"
|
{{ ticketStatusLabel(source, '') }}
|
||||||
:value="sourceId"
|
</legend>
|
||||||
: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
|
<p
|
||||||
v-if="
|
v-if="statuses.length <= 1"
|
||||||
targets.length > 0 &&
|
|
||||||
form.allowed_ticket_status_ids.length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground text-sm"
|
class="text-muted-foreground text-sm"
|
||||||
>
|
>
|
||||||
{{ t('workflow.noMovesHelp') }}
|
{{ t('workflow.noTargets') }}
|
||||||
</p>
|
</p>
|
||||||
<div id="workflow-transition-errors" role="alert">
|
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||||
<InputError
|
<label
|
||||||
v-for="(message, key) in form.errors"
|
v-for="target in statuses.filter(
|
||||||
:key="key"
|
(item) => item.id !== source.id,
|
||||||
:message="message"
|
)"
|
||||||
/>
|
:key="target.id"
|
||||||
|
class="border-input flex min-h-11 min-w-0 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="allowed_ticket_status_ids[]"
|
||||||
|
:value="target.id"
|
||||||
|
:checked="
|
||||||
|
source.allowed_target_statuses?.some(
|
||||||
|
(item) => item.id === target.id,
|
||||||
|
) ?? false
|
||||||
|
"
|
||||||
|
class="border-input accent-primary focus-visible:ring-ring/40 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-words">{{
|
||||||
|
ticketStatusLabel(target, '')
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<FormFeedback
|
</fieldset>
|
||||||
:state="
|
<InputError
|
||||||
!saved && protection.state.value === 'success'
|
v-for="(message, key) in errors"
|
||||||
? 'idle'
|
:key="key"
|
||||||
: protection.state.value
|
:message="message"
|
||||||
"
|
/>
|
||||||
@retry="submit"
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
/>
|
<Button
|
||||||
<div
|
type="submit"
|
||||||
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
variant="outline"
|
||||||
|
:loading="processing"
|
||||||
|
>{{ t('common.save') }}</Button
|
||||||
>
|
>
|
||||||
<Badge
|
<span
|
||||||
:variant="form.isDirty ? 'warning' : 'secondary'"
|
v-if="recentlySuccessful"
|
||||||
role="status"
|
role="status"
|
||||||
>{{
|
class="text-primary text-sm"
|
||||||
t(
|
>{{ t('feedback.saved') }}</span
|
||||||
form.isDirty
|
>
|
||||||
? 'workflow.unsavedMoves'
|
</div>
|
||||||
: 'workflow.savedMoves',
|
</Form>
|
||||||
{ 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>
|
</div>
|
||||||
</section>
|
</details>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -46,22 +46,6 @@ export const en = {
|
|||||||
'workflow.transitions': 'Status moves',
|
'workflow.transitions': 'Status moves',
|
||||||
'workflow.transitionsDescription':
|
'workflow.transitionsDescription':
|
||||||
'Choose exactly where each status is allowed to move next.',
|
'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.noTargets': 'No other statuses exist yet.',
|
||||||
'workflow.confirmDelete': 'Delete',
|
'workflow.confirmDelete': 'Delete',
|
||||||
'security.title': 'Security settings',
|
'security.title': 'Security settings',
|
||||||
@@ -913,51 +897,4 @@ export const en = {
|
|||||||
'gitConnections.added': 'Git connection added.',
|
'gitConnections.added': 'Git connection added.',
|
||||||
'gitConnections.updated': 'Git connection updated.',
|
'gitConnections.updated': 'Git connection updated.',
|
||||||
'gitConnections.removed': 'Git connection removed.',
|
'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;
|
} as const;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
@@ -298,79 +298,6 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
$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.
|
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -487,75 +487,4 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'id' => $ticketLinkType->id,
|
'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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -254,6 +254,23 @@ try {
|
|||||||
.getByText('Building', { exact: true })
|
.getByText('Building', { exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(await button('Edit column In progress').count(), 0);
|
assert.equal(await button('Edit column In progress').count(), 0);
|
||||||
|
await page.locator('summary').filter({ hasText: 'Status moves' }).click();
|
||||||
|
const transition = page.locator('details form').first();
|
||||||
|
const checkbox = transition.getByRole('checkbox').first();
|
||||||
|
await checkbox.check();
|
||||||
|
const move = page.waitForRequest((request) => request.method() === 'POST');
|
||||||
|
await transition.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
assert.equal(
|
||||||
|
new URL((await move).url()).pathname,
|
||||||
|
'/projects/1/settings/board/statuses/1/transitions',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new URL((await move).url()).searchParams.get('_method'),
|
||||||
|
'PATCH',
|
||||||
|
);
|
||||||
|
assert.deepEqual((await move).postDataJSON(), {
|
||||||
|
allowed_ticket_status_ids: [await checkbox.getAttribute('value')],
|
||||||
|
});
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
console.log(
|
console.log(
|
||||||
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function initial() {
|
|||||||
const ready = makeStatus(2, 'Ready', 10);
|
const ready = makeStatus(2, 'Ready', 10);
|
||||||
const active = makeStatus(3, 'Building', 20);
|
const active = makeStatus(3, 'Building', 20);
|
||||||
const review = makeStatus(4, 'Review', 20, 2);
|
const review = makeStatus(4, 'Review', 20, 2);
|
||||||
backlog.allowed_target_statuses = [ready];
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
|
{ id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] },
|
||||||
{
|
{
|
||||||
@@ -135,17 +134,13 @@ if (serve) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result === 'validation') {
|
if (result === 'validation') {
|
||||||
errors = url.pathname.endsWith('/transitions')
|
errors =
|
||||||
? {
|
request.method === 'DELETE'
|
||||||
'allowed_ticket_status_ids.0':
|
? {
|
||||||
'Choose a destination from this project.',
|
ticket_status_id:
|
||||||
}
|
'This group needs at least one status.',
|
||||||
: request.method === 'DELETE'
|
}
|
||||||
? {
|
: { name: 'Choose a different name.' };
|
||||||
ticket_status_id:
|
|
||||||
'This group needs at least one status.',
|
|
||||||
}
|
|
||||||
: { name: 'Choose a different name.' };
|
|
||||||
} else if (result === 'position') {
|
} else if (result === 'position') {
|
||||||
errors = { position: 'The position must be at least 1.' };
|
errors = { position: 'The position must be at least 1.' };
|
||||||
} else {
|
} else {
|
||||||
@@ -193,16 +188,7 @@ if (serve) {
|
|||||||
column.position = index + 1;
|
column.position = index + 1;
|
||||||
});
|
});
|
||||||
} else if (url.pathname.endsWith('/transitions')) {
|
} else if (url.pathname.endsWith('/transitions')) {
|
||||||
const sourceId = Number(segments.at(-2));
|
// The browser assertion checks the preserved transition endpoint and payload.
|
||||||
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') {
|
} else if (request.method === 'POST') {
|
||||||
const group =
|
const group =
|
||||||
submitted.board_column_id === null
|
submitted.board_column_id === null
|
||||||
@@ -308,16 +294,6 @@ if (serve) {
|
|||||||
html.indexOf('data-column-id="10"') <
|
html.indexOf('data-column-id="10"') <
|
||||||
html.indexOf('data-column-id="20"'),
|
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);
|
const buttons = html.match(/<button\b[^>]*>[\s\S]*?<\/button>/g);
|
||||||
for (const name of ['Backlog', 'Ready']) {
|
for (const name of ['Backlog', 'Ready']) {
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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