feat(KAN-43): rebuild custom fields and link types
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-15 17:50:10 +02:00
parent b226ee8d48
commit fb4faa6070
15 changed files with 1240 additions and 331 deletions
@@ -0,0 +1,267 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyFieldDefinition,
storeFieldDefinition,
updateFieldDefinition,
} 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 {
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 { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
target: TicketFieldDefinitionEditorTarget | null;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
label: '',
field_type: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addField');
}
if (props.target?.action === 'edit') {
return t('metadata.editField', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteField', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
label: target.entry.label,
field_type: target.entry.field_type,
position: target.entry.position,
}
: {
label: '',
field_type: props.fieldTypeOptions[0]?.value ?? '',
position: props.definitions.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ 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) {
return;
}
const route =
target.action === 'create'
? storeFieldDefinition(props.projectId)
: target.action === 'edit'
? updateFieldDefinition([props.projectId, target.entry.id])
: destroyFieldDefinition([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? { label: data.label, field_type: data.field_type }
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteFieldHelp'
: 'metadata.fieldEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.fieldDeletionImpact', {
count:
target.entry.ticket_field_values_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="field-definition-label">{{
t('metadata.fieldLabel')
}}</Label>
<Input
id="field-definition-label"
v-model="form.label"
name="label"
required
maxlength="255"
:aria-invalid="!!form.errors.label"
aria-describedby="field-definition-label-error"
/>
<InputError
id="field-definition-label-error"
:message="form.errors.label"
/>
</div>
<div class="grid gap-2">
<Label for="field-definition-type">{{
t('metadata.fieldType')
}}</Label>
<select
id="field-definition-type"
v-model="form.field_type"
name="field_type"
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.field_type"
aria-describedby="field-definition-type-help field-definition-type-error"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<p
id="field-definition-type-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.fieldTypeHelp') }}
</p>
<InputError
id="field-definition-type-error"
:message="form.errors.field_type"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="field-definition-position">{{
t('workflow.position')
}}</Label>
<Input
id="field-definition-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="definitions.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="field-definition-position-help field-definition-position-error"
/>
<p
id="field-definition-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: definitions.length,
})
}}
</p>
<InputError
id="field-definition-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
import type { TicketFieldTypeOption } from '@/types';
const props = defineProps<{
projectId: number;
definitions: TicketFieldDefinitionModel[];
fieldTypeOptions: TicketFieldTypeOption[];
}>();
const { t } = useLocalization();
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
const fieldTypeLabels = computed(
() =>
new Map(
props.fieldTypeOptions.map((option) => [
option.value,
option.label,
]),
),
);
function fieldTypeLabel(fieldType: string): string {
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
}
</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-custom-fields"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Braces
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-custom-fields" class="font-semibold">
{{ t('metadata.customFields') }}
</h3>
<Badge variant="secondary">{{ definitions.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addField')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.customFieldsDescription') }}
</p>
<FeedbackState
v-if="definitions.length === 0"
state="empty"
:title="t('metadata.noCustomFields')"
:description="t('metadata.noCustomFieldsDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
<li
v-for="definition in definitions"
:key="definition.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-field-definition="definition.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: definition.position,
})
"
>{{ definition.position }}</span
>
<div class="grid min-w-0 gap-1">
<p class="break-words font-medium">
{{ definition.label }}
</p>
<div
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
>
<Badge variant="outline">{{
fieldTypeLabel(definition.field_type)
}}</Badge>
<span>{{
t('metadata.storedValues', {
count:
definition.ticket_field_values_count ?? 0,
})
}}</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editField', { name: definition.label })
"
@click="target = { action: 'edit', entry: definition }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteField', {
name: definition.label,
})
"
@click="
target = { action: 'delete', entry: definition }
"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketFieldDefinitionEditorDialog
:definitions="definitions"
:field-type-options="fieldTypeOptions"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
import { computed, watch } from 'vue';
import {
destroyLinkType,
storeLinkType,
updateLinkType,
} 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 {
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 { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
const props = defineProps<{
projectId: number;
target: TicketLinkTypeEditorTarget | null;
linkTypes: TicketLinkTypeModel[];
}>();
const emit = defineEmits<{ close: [] }>();
const { t } = useLocalization();
const form = useForm({
name: '',
counterpart_name: '',
position: 1,
});
const protection = useFormProtection(form, {
isDirty: () => props.target !== null && form.isDirty,
});
const deleting = computed(() => props.target?.action === 'delete');
const title = computed(() => {
if (props.target?.action === 'create') {
return t('metadata.addLinkType');
}
if (props.target?.action === 'edit') {
return t('metadata.editLinkType', { name: props.target.entry.label });
}
return props.target
? t('metadata.deleteLinkType', { name: props.target.entry.label })
: '';
});
watch(
() => props.target,
(target) => {
const values =
target?.action === 'edit'
? {
name: target.entry.label,
counterpart_name: target.entry.counterpart_label,
position: target.entry.position,
}
: {
name: '',
counterpart_name: '',
position: props.linkTypes.length + 1,
};
form.defaults(values);
form.resetAndClearErrors();
protection.clearFeedback();
},
{ 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) {
return;
}
const route =
target.action === 'create'
? storeLinkType(props.projectId)
: target.action === 'edit'
? updateLinkType([props.projectId, target.entry.id])
: destroyLinkType([props.projectId, target.entry.id]);
form.transform((data) =>
target.action === 'delete'
? {}
: target.action === 'create'
? {
name: data.name,
counterpart_name: data.counterpart_name,
}
: data,
);
protection.submit(route, {
preserveScroll: true,
onSuccess: () => {
form.resetAndClearErrors();
emit('close');
},
});
}
</script>
<template>
<Dialog :open="target !== null" @update:open="close">
<DialogContent
:show-close-button="!form.processing"
@escape-key-down="form.processing && $event.preventDefault()"
@interact-outside="form.processing && $event.preventDefault()"
>
<DialogHeader>
<DialogTitle class="break-words">{{ title }}</DialogTitle>
<DialogDescription>
{{
t(
deleting
? 'metadata.deleteLinkTypeHelp'
: 'metadata.linkTypeEditorHelp',
)
}}
</DialogDescription>
</DialogHeader>
<form
class="grid min-w-0 gap-4"
:aria-busy="form.processing"
@submit.prevent="submit"
>
<FormFeedback :state="protection.state.value" @retry="submit" />
<div
v-if="deleting && target?.entry"
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
>
<CircleAlert
class="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{{
t('metadata.linkTypeDeletionImpact', {
count: target.entry.ticket_links_count ?? 0,
})
}}
</p>
</div>
<fieldset
v-if="!deleting"
:disabled="form.processing"
class="grid min-w-0 gap-4"
>
<div class="grid gap-2">
<Label for="link-type-source-label">{{
t('metadata.sourceLabel')
}}</Label>
<Input
id="link-type-source-label"
v-model="form.name"
name="name"
required
maxlength="255"
:aria-invalid="!!form.errors.name"
aria-describedby="link-type-source-label-help link-type-source-label-error"
/>
<p
id="link-type-source-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.sourceLabelHelp') }}
</p>
<InputError
id="link-type-source-label-error"
:message="form.errors.name"
/>
</div>
<ArrowRight
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
aria-hidden="true"
/>
<div class="grid gap-2">
<Label for="link-type-counterpart-label">{{
t('metadata.counterpartLabel')
}}</Label>
<Input
id="link-type-counterpart-label"
v-model="form.counterpart_name"
name="counterpart_name"
required
maxlength="255"
:aria-invalid="!!form.errors.counterpart_name"
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
/>
<p
id="link-type-counterpart-label-help"
class="text-caption text-muted-foreground"
>
{{ t('metadata.counterpartLabelHelp') }}
</p>
<InputError
id="link-type-counterpart-label-error"
:message="form.errors.counterpart_name"
/>
</div>
<div v-if="target?.action === 'edit'" class="grid gap-2">
<Label for="link-type-position">{{
t('workflow.position')
}}</Label>
<Input
id="link-type-position"
v-model="form.position"
name="position"
type="number"
min="1"
:max="linkTypes.length"
step="1"
required
:aria-invalid="!!form.errors.position"
aria-describedby="link-type-position-help link-type-position-error"
/>
<p
id="link-type-position-help"
class="text-caption text-muted-foreground"
>
{{
t('metadata.positionHelp', {
count: linkTypes.length,
})
}}
</p>
<InputError
id="link-type-position-error"
:message="form.errors.position"
/>
</div>
</fieldset>
<DialogFooter>
<Button
type="button"
variant="outline"
:disabled="form.processing"
@click="close(false)"
>{{ t('common.cancel') }}</Button
>
<Button
type="submit"
:variant="deleting ? 'destructive' : 'default'"
:loading="form.processing"
>{{
deleting
? t('workflow.confirmDelete')
: target?.action === 'create'
? title
: t('common.save')
}}</Button
>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
import { ref } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
const { t } = useLocalization();
const target = ref<TicketLinkTypeEditorTarget | null>(null);
</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-link-types"
>
<header class="flex flex-wrap items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Link2
class="text-primary size-5 shrink-0"
aria-hidden="true"
/>
<h3 id="metadata-link-types" class="font-semibold">
{{ t('metadata.linkTypes') }}
</h3>
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
</div>
<Button
type="button"
class="w-full sm:w-auto"
@click="target = { action: 'create' }"
>
<Plus class="size-4" aria-hidden="true" />{{
t('metadata.addLinkType')
}}
</Button>
</header>
<p class="text-muted-foreground text-sm">
{{ t('metadata.linkTypesDescription') }}
</p>
<FeedbackState
v-if="linkTypes.length === 0"
state="empty"
:title="t('metadata.noLinkTypes')"
:description="t('metadata.noLinkTypesDescription')"
/>
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
<li
v-for="linkType in linkTypes"
:key="linkType.id"
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
:data-ticket-link-type="linkType.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: linkType.position,
})
"
>{{ linkType.position }}</span
>
<div class="grid min-w-0 gap-2">
<div
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
:aria-label="
t('metadata.linkDirection', {
source: linkType.label,
counterpart: linkType.counterpart_label,
})
"
>
<span class="break-words font-medium">{{
linkType.label
}}</span>
<ArrowRight
class="text-primary size-4 shrink-0"
aria-hidden="true"
/>
<span class="break-words font-medium">{{
linkType.counterpart_label
}}</span>
</div>
<p class="text-caption text-muted-foreground">
{{
t('metadata.ticketRelationships', {
count: linkType.ticket_links_count ?? 0,
})
}}
</p>
</div>
<div class="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="icon"
:aria-label="
t('metadata.editLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'edit', entry: linkType }"
>
<Pencil class="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive"
:aria-label="
t('metadata.deleteLinkType', {
name: linkType.label,
})
"
@click="target = { action: 'delete', entry: linkType }"
>
<Trash2 class="size-4" aria-hidden="true" />
</Button>
</div>
</li>
</ol>
<TicketLinkTypeEditorDialog
:link-types="linkTypes"
:project-id="projectId"
:target="target"
@close="target = null"
/>
</section>
</template>
+43
View File
@@ -960,4 +960,47 @@ export const en = {
'metadata.chooseType': 'Choose a type',
'metadata.parentReplacement':
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
'metadata.customFields': 'Custom fields',
'metadata.customFieldsDescription':
'Define the project-specific user and number fields shown on tickets.',
'metadata.addField': 'Add field',
'metadata.noCustomFields': 'No custom fields',
'metadata.noCustomFieldsDescription':
'Add a field when this project needs structured information beyond the standard ticket details.',
'metadata.storedValues': 'Stored values: {count}',
'metadata.editField': 'Edit {name}',
'metadata.deleteField': 'Delete {name}',
'metadata.fieldEditorHelp':
'Choose a label and one of the supported field types. Existing ticket values remain attached when this definition is edited.',
'metadata.deleteFieldHelp':
'Delete this custom field? This action cannot be undone.',
'metadata.fieldDeletionImpact':
'Deleting this field also permanently removes {count} stored ticket values.',
'metadata.fieldLabel': 'Field label',
'metadata.fieldType': 'Field type',
'metadata.fieldTypeHelp':
'Only the field types already supported by this project are available.',
'metadata.linkTypes': 'Link types',
'metadata.linkTypesDescription':
'Define the labels shown from each side of a ticket relationship.',
'metadata.addLinkType': 'Add link type',
'metadata.noLinkTypes': 'No link types',
'metadata.noLinkTypesDescription':
'Add a link type before tickets can be connected with a project-specific relationship.',
'metadata.ticketRelationships': 'Ticket relationships: {count}',
'metadata.linkDirection': '{source} points to {counterpart}',
'metadata.editLinkType': 'Edit {name}',
'metadata.deleteLinkType': 'Delete {name}',
'metadata.linkTypeEditorHelp':
'The source label is shown on the originating ticket; the counterpart label is shown on the linked ticket.',
'metadata.deleteLinkTypeHelp':
'Delete this link type? This action cannot be undone.',
'metadata.linkTypeDeletionImpact':
'Deleting this link type also permanently removes {count} ticket relationships.',
'metadata.sourceLabel': 'Source label',
'metadata.sourceLabelHelp':
'Shown when viewing the ticket where the relationship starts.',
'metadata.counterpartLabel': 'Counterpart label',
'metadata.counterpartLabelHelp':
'Shown when viewing the ticket at the other end of the relationship.',
} as const;
+12 -316
View File
@@ -1,23 +1,13 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
import TicketFieldDefinitionSettings from '@/components/projects/TicketFieldDefinitionSettings.vue';
import TicketLinkTypeSettings from '@/components/projects/TicketLinkTypeSettings.vue';
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
@@ -76,309 +66,15 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
:entries="ticketPriorities"
@edit="target = $event"
/>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Custom fields</CardTitle>
<CardDescription>
Define project-specific user or number fields for
upcoming ticket data.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeFieldDefinition.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="field-label">New field label</Label>
<Input
id="field-label"
name="label"
required
placeholder="Reviewer"
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label for="field-type">Field type</Label>
<select
id="field-type"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<Button type="submit" :disabled="processing">
Add field
</Button>
</Form>
<div
v-for="fieldDefinition in ticketFieldDefinitions"
:key="fieldDefinition.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`field-label-${fieldDefinition.id}`"
>
Label
</Label>
<Input
:id="`field-label-${fieldDefinition.id}`"
name="label"
:default-value="fieldDefinition.label"
required
/>
<InputError :message="errors.label" />
</div>
<div class="grid gap-2">
<Label
:for="`field-type-${fieldDefinition.id}`"
>
Field type
</Label>
<select
:id="`field-type-${fieldDefinition.id}`"
name="field_type"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="option in fieldTypeOptions"
:key="option.value"
:value="option.value"
:selected="
option.value ===
fieldDefinition.field_type
"
>
{{ option.label }}
</option>
</select>
<InputError :message="errors.field_type" />
</div>
<div class="grid gap-2">
<Label
:for="`field-position-${fieldDefinition.id}`"
>
Position
</Label>
<Input
:id="`field-position-${fieldDefinition.id}`"
name="position"
type="number"
min="1"
:max="ticketFieldDefinitions.length"
:default-value="fieldDefinition.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyFieldDefinition.form(
[project.id, fieldDefinition.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Link types</CardTitle>
<CardDescription>
Define project-specific labels for ticket-to-ticket
relationships.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.storeLinkType.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="link-type-name"> Source label </Label>
<Input
id="link-type-name"
name="name"
required
placeholder="Blocks"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="link-type-counterpart-name">
Counterpart label
</Label>
<Input
id="link-type-counterpart-name"
name="counterpart_name"
required
placeholder="Blocked by"
/>
<InputError :message="errors.counterpart_name" />
</div>
<Button type="submit" :disabled="processing">
Add link type
</Button>
</Form>
<div
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
class="border-border rounded-xl border p-4"
>
<Form
v-bind="
ProjectTicketMetadataSettingsController.updateLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="grid gap-4"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label
:for="`link-type-name-${ticketLinkType.id}`"
>
Source label
</Label>
<Input
:id="`link-type-name-${ticketLinkType.id}`"
name="name"
:default-value="ticketLinkType.label"
required
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
>
Counterpart label
</Label>
<Input
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
name="counterpart_name"
:default-value="
ticketLinkType.counterpart_label
"
required
/>
<InputError
:message="errors.counterpart_name"
/>
</div>
<div class="grid gap-2">
<Label
:for="`link-type-position-${ticketLinkType.id}`"
>
Position
</Label>
<Input
:id="`link-type-position-${ticketLinkType.id}`"
name="position"
type="number"
min="1"
:max="ticketLinkTypes.length"
:default-value="ticketLinkType.position"
required
/>
<InputError :message="errors.position" />
</div>
<div class="flex flex-wrap justify-between gap-3">
<Button
type="submit"
variant="outline"
:disabled="processing"
>
Save
</Button>
</div>
</Form>
<Form
v-bind="
ProjectTicketMetadataSettingsController.destroyLinkType.form(
[project.id, ticketLinkType.id],
)
"
class="mt-3 flex justify-end"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
:disabled="deleting"
>
Delete
</Button>
</Form>
</div>
</CardContent>
</Card>
<TicketFieldDefinitionSettings
:definitions="ticketFieldDefinitions"
:field-type-options="fieldTypeOptions"
:project-id="project.id"
/>
<TicketLinkTypeSettings
:link-types="ticketLinkTypes"
:project-id="project.id"
/>
<MetadataEditorDialog
:project-id="project.id"
@@ -4,6 +4,7 @@ export type TicketFieldDefinitionModel = {
label: string;
position: number;
project_id: number;
ticket_field_values_count?: number;
created_at: string;
updated_at: string;
};
@@ -4,6 +4,7 @@ export type TicketLinkTypeModel = {
counterpart_label: string;
position: number;
project_id: number;
ticket_links_count?: number;
created_at: string;
updated_at: string;
};
+13
View File
@@ -1,4 +1,6 @@
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
export type MetadataKind = 'type' | 'priority';
@@ -8,3 +10,14 @@ export type MetadataEditorTarget = { kind: MetadataKind } & (
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: MetadataEntry }
);
export type TicketFieldDefinitionEditorTarget =
| { action: 'create'; entry?: never }
| {
action: 'edit' | 'delete';
entry: TicketFieldDefinitionModel;
};
export type TicketLinkTypeEditorTarget =
| { action: 'create'; entry?: never }
| { action: 'edit' | 'delete'; entry: TicketLinkTypeModel };