Merge pull request 'KAN-42: Rebuild ticket types, priorities and display settings' (#137) from feature/KAN-42-ticket-types-priorities-display into main
Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
@@ -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>
|
||||||
@@ -913,4 +913,51 @@ 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
@@ -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 }
|
||||||
|
);
|
||||||
@@ -487,4 +487,75 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user