-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
- {{ t('ticketDetail.customFields') }}
-
-
- {{ t('ticketDetail.customFieldsDescription') }}
-
-
-
-
+
+
+
-
-
-
+
+
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+ {{
+ isDirty
+ ? t('ticketForm.unsaved')
+ : t('ticketForm.saved')
+ }}
+
diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts
index b5d8e5b..6679c8f 100644
--- a/resources/js/locales/en.ts
+++ b/resources/js/locales/en.ts
@@ -462,4 +462,28 @@ export const en = {
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
'ticketDetail.system': 'System',
'ticketDetail.sourceBranch': 'Source branch: {name}',
+ 'ticketForm.create': 'Create ticket',
+ 'ticketForm.pageTitle': 'Create {project} ticket',
+ 'ticketForm.createDescription': 'Add a ticket to {project}.',
+ 'ticketForm.backToBoard': 'Back to board',
+ 'ticketForm.backToBacklog': 'Back to backlog',
+ 'ticketForm.details': 'Ticket details',
+ 'ticketForm.keyAssigned': 'The ticket key is assigned after creation.',
+ 'ticketForm.titlePlaceholder': 'Implement repository sync',
+ 'ticketForm.descriptionPlaceholder':
+ 'Describe the expected behavior and relevant context.',
+ 'ticketForm.checklistHelp':
+ 'Use [ ] / [x] for checklist items. Indent with two spaces for nested steps.',
+ 'ticketForm.checklistPreview': 'Checklist preview',
+ 'ticketForm.metadataRequired':
+ 'Ticket creation needs at least one type, one priority, and one status.',
+ 'ticketForm.openSettings': 'Open ticket settings',
+ 'ticketForm.creating': 'Creating ticket…',
+ 'ticketForm.saving': 'Saving changes…',
+ 'ticketForm.reset': 'Discard changes',
+ 'ticketForm.unsaved': 'Unsaved changes',
+ 'ticketForm.ready': 'Ready for a new ticket',
+ 'ticketForm.saved': 'All changes saved',
+ 'ticketForm.authorRestricted': 'Only project admins can change the author.',
+ 'ticketForm.blocker': 'Blocker',
} as const;
diff --git a/resources/js/pages/projects/TicketCreate.vue b/resources/js/pages/projects/TicketCreate.vue
index dcd85af..cb89609 100644
--- a/resources/js/pages/projects/TicketCreate.vue
+++ b/resources/js/pages/projects/TicketCreate.vue
@@ -2,8 +2,9 @@
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
import { computed, toRef, unref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
-import Heading from '@/components/Heading.vue';
-import InputError from '@/components/InputError.vue';
+import TicketFormField from '@/components/tickets/TicketFormField.vue';
+import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
+import { useLocalization } from '@/composables/useLocalization';
import FormFeedback from '@/components/FormFeedback.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { Badge } from '@/components/ui/badge';
@@ -15,8 +16,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
@@ -27,20 +26,22 @@ import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
-import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps
();
+const { t } = useLocalization();
const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel =
- props.source === 'board' ? 'Back to board' : 'Back to backlog';
+ props.source === 'board'
+ ? t('ticketForm.backToBoard')
+ : t('ticketForm.backToBacklog');
const breadcrumbs: BreadcrumbItem[] = [
{
- title: 'Projects',
+ title: t('navigation.projects'),
href: index(),
},
{
@@ -48,21 +49,26 @@ const breadcrumbs: BreadcrumbItem[] = [
href: show(props.project.id),
},
{
- title: props.source === 'board' ? 'Board' : 'Backlog',
+ title:
+ props.source === 'board'
+ ? t('navigation.board')
+ : t('navigation.backlog'),
href: returnHref,
},
{
- title: 'Create ticket',
+ title: t('ticketForm.create'),
href: ProjectTicketController.create({
project: props.project.id,
}),
},
];
-const hasTicketMetadata =
- props.ticketTypes.length > 0 &&
- props.ticketPriorities.length > 0 &&
- props.ticketStatusOptions.length > 0;
+const hasTicketMetadata = computed(
+ () =>
+ props.ticketTypes.length > 0 &&
+ props.ticketPriorities.length > 0 &&
+ props.ticketStatusOptions.length > 0,
+);
const draftKey = `projects:${props.project.id}:tickets:create`;
const acceptanceCriteriaDraft = useRemember(
@@ -90,9 +96,19 @@ const form = useForm(draftKey, {
title: '',
});
-const protection = useFormProtection(form, {
- isDirty: () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
-});
+const isDirty = computed(
+ () => form.isDirty || acceptanceCriteriaChecklist.value !== '',
+);
+const protection = useFormProtection(form, { isDirty: () => isDirty.value });
+const reset = (): void => {
+ if (form.processing || !protection.confirmLeave()) {
+ return;
+ }
+ form.reset();
+ form.clearErrors();
+ acceptanceCriteriaChecklist.value = '';
+ protection.clearFeedback();
+};
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
@@ -109,6 +125,9 @@ const acceptanceCriteriaError = computed(() => {
});
const submit = (): void => {
+ if (!hasTicketMetadata.value || form.processing) {
+ return;
+ }
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaChecklist.value,
);
@@ -117,31 +136,22 @@ const submit = (): void => {
ProjectTicketController.store({ project: props.project.id }),
{
preserveScroll: true,
+ onSuccess: () => {
+ form.reset();
+ acceptanceCriteriaChecklist.value = '';
+ },
},
);
};
-
-const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
- if (
- ticketStatus.board_column === null ||
- ticketStatus.board_column === undefined
- ) {
- return ticketStatus.name;
- }
-
- if (ticketStatus.board_column.name === ticketStatus.name) {
- return ticketStatus.name;
- }
-
- return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
-};
-
+
-
+
@@ -149,10 +159,16 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ project.key }}
-
+
+ {{ t('ticketForm.create') }}
+
+
+ {{
+ t('ticketForm.createDescription', {
+ project: project.name,
+ })
+ }}
+
@@ -160,7 +176,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
{{ returnLabel }}
@@ -174,9 +192,9 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
- Ticket details
+ {{ t('ticketForm.details') }}
- The ticket key is assigned after creation.
+ {{ t('ticketForm.keyAssigned') }}
@@ -186,258 +204,93 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
:state="protection.state.value"
@retry="submit"
/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Use [ ] /
- [x] for checklist
- items. Indent with two spaces for
- nested steps.
-
-
-
-
-
-
- Checklist preview
-
-
- -
- {{
- item.checked ? '☑' : '☐'
- }}
- {{ item.text }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
+
+ {{ t('ticketForm.checklistPreview') }}
+
+
+ -
+ {{
+ item.checked ? '☑' : '☐'
+ }}
+ {{ item.text }}
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+ {{
+ isDirty
+ ? t('ticketForm.unsaved')
+ : t('ticketForm.ready')
+ }}
+
@@ -446,13 +299,12 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
- Ticket creation needs at least one type, one priority,
- and one status.
+ {{ t('ticketForm.metadataRequired') }}
diff --git a/tests/Feature/Projects/ProjectTicketControllerTest.php b/tests/Feature/Projects/ProjectTicketControllerTest.php
index b262b56..5662d62 100644
--- a/tests/Feature/Projects/ProjectTicketControllerTest.php
+++ b/tests/Feature/Projects/ProjectTicketControllerTest.php
@@ -1511,6 +1511,74 @@ class ProjectTicketControllerTest extends TestCase
]);
}
+ public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): void
+ {
+ $viewer = User::factory()->create();
+ $project = Project::factory()->create();
+ app(InitializeProjectTicketMetadata::class)->handle($project);
+ $this->syncProjectPermissions($project, $viewer, ['view']);
+
+ $this->actingAs($viewer)
+ ->get(route('projects.tickets.create', $project))
+ ->assertForbidden();
+
+ $this->post(route('projects.tickets.store', $project), [
+ 'title' => 'Unauthorized ticket',
+ 'ticket_type_id' => $this->nonParentType($project)->id,
+ 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
+ 'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
+ ])->assertForbidden();
+
+ $this->assertDatabaseCount('tickets', 0);
+ }
+
+ public function test_ticket_forms_reject_metadata_from_another_project(): void
+ {
+ $editor = User::factory()->create();
+ $project = Project::factory()->create();
+ $otherProject = Project::factory()->create();
+ app(InitializeProjectTicketMetadata::class)->handle($project);
+ app(InitializeProjectTicketMetadata::class)->handle($otherProject);
+ $this->syncProjectPermissions($project, $editor, ['write', 'view']);
+ $parent = Ticket::factory()->create([
+ 'project_id' => $otherProject->id,
+ 'ticket_key' => 'OTHER-1',
+ 'ticket_type_id' => $this->parentType($otherProject)->id,
+ 'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
+ ]);
+ $invalidMetadata = [
+ 'parent_ticket_id' => $parent->id,
+ 'ticket_type_id' => $this->nonParentType($otherProject)->id,
+ 'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
+ ];
+
+ $this->actingAs($editor)
+ ->post(route('projects.tickets.store', $project), [
+ ...$invalidMetadata,
+ 'title' => 'Rejected creation',
+ 'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
+ ])
+ ->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
+
+ $this->assertSame(0, $project->tickets()->count());
+
+ $ticket = Ticket::factory()->create([
+ 'project_id' => $project->id,
+ 'ticket_key' => 'LOCAL-1',
+ 'ticket_type_id' => $this->nonParentType($project)->id,
+ 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
+ 'title' => 'Original title',
+ ]);
+
+ $this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
+ ...$invalidMetadata,
+ 'title' => 'Rejected update',
+ ]))->assertSessionHasErrors(array_keys($invalidMetadata));
+
+ $this->assertSame('Original title', $ticket->fresh()->title);
+ $this->assertNull($ticket->fresh()->parent_ticket_id);
+ }
+
private function nonParentType(Project $project): TicketType
{
return $project->ticketTypes()
diff --git a/tests/Fixtures/ticket-detail-preview.mjs b/tests/Fixtures/ticket-detail-preview.mjs
index 3c1ed55..91d09d0 100644
--- a/tests/Fixtures/ticket-detail-preview.mjs
+++ b/tests/Fixtures/ticket-detail-preview.mjs
@@ -2,14 +2,17 @@ import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
+import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
- name === 'Ticket'
- ? Ticket
- : { render: () => h('h1', 'Destination reached') },
+ name === 'TicketCreate'
+ ? TicketCreate
+ : name === 'Ticket'
+ ? Ticket
+ : { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
diff --git a/tests/Fixtures/ticket-detail.mjs b/tests/Fixtures/ticket-detail.mjs
index 8aad8ed..6260d63 100644
--- a/tests/Fixtures/ticket-detail.mjs
+++ b/tests/Fixtures/ticket-detail.mjs
@@ -123,10 +123,13 @@ const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' },
];
+let savedForm = {};
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
- const detail = parsed.pathname.startsWith('/projects/7/tickets/');
+ const creating = parsed.pathname.endsWith('/create');
+ const detail =
+ !creating && parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, {
@@ -136,7 +139,7 @@ function makePage(url) {
});
}
if (params.has('saved')) {
- data.title = params.get('saved');
+ Object.assign(data, savedForm, { title: params.get('saved') });
}
if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
@@ -168,7 +171,11 @@ function makePage(url) {
? 'Repository temporarily unavailable.'
: null;
return {
- component: detail ? 'Ticket' : 'Destination',
+ component: creating
+ ? 'TicketCreate'
+ : detail
+ ? 'Ticket'
+ : 'Destination',
url,
version: null,
clearHistory: false,
@@ -176,7 +183,28 @@ function makePage(url) {
props: {
name: 'KanbanGit',
errors: params.has('rejected')
- ? { title: 'This title is not allowed.' }
+ ? {
+ title: 'This title is not allowed.',
+ ...(params.has('allErrors')
+ ? {
+ description: 'Invalid description.',
+ ticket_type_id: 'Invalid type.',
+ ticket_priority_id: 'Invalid priority.',
+ ticket_status_id: 'Invalid status.',
+ assignee_user_id: 'Invalid assignee.',
+ author_user_id: 'Invalid author.',
+ parent_ticket_id: 'Invalid parent.',
+ story_points: 'Invalid points.',
+ 'acceptance_criteria.0.text':
+ 'Invalid checklist.',
+ blocked_comment: 'Invalid blocker.',
+ 'field_values.0.value_number':
+ 'Invalid number.',
+ 'field_values.1.value_user_id':
+ 'Invalid reviewer.',
+ }
+ : {}),
+ }
: {},
sidebarOpen: true,
auth: {
@@ -204,7 +232,17 @@ function makePage(url) {
canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author],
- ticketTypes: [data.ticket_type],
+ ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
+ source: params.get('source') === 'board' ? 'board' : 'backlog',
+ defaultTicketStatusId: 3,
+ ticketStatusOptions: [
+ {
+ id: 3,
+ name: 'In progress',
+ board_column: { name: 'Delivery' },
+ },
+ { id: 4, name: 'Backlog', board_column: null },
+ ],
ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent],
@@ -282,6 +320,11 @@ if (serve) {
const data = JSON.parse(body);
const previous = new URL(request.headers.referer);
const params = previous.searchParams;
+ if (params.has('serverError')) {
+ response.writeHead(503);
+ response.end('Simulated failure');
+ return;
+ }
if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42');
@@ -311,18 +354,19 @@ if (serve) {
);
} else {
assert.ok(
- 'field_values' in data &&
+ (request.method === 'POST' || 'field_values' in data) &&
'acceptance_criteria' in data &&
'parent_ticket_id' in data,
);
if (params.has('reject')) {
params.set('rejected', '1');
} else {
+ savedForm = data;
params.set('saved', data.title);
}
}
response.writeHead(303, {
- Location: `/projects/7/tickets/42?${params}`,
+ Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
});
response.end();
return;
@@ -364,10 +408,15 @@ if (serve) {
const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue',
);
- const render = async (query = '') => {
+ const { default: TicketCreate } = await server.ssrLoadModule(
+ '/resources/js/pages/projects/TicketCreate.vue',
+ );
+ const render = async (query = '', creating = false) => {
const result = await createInertiaApp({
- page: makePage(`/projects/7/tickets/42${query}`),
- resolve: () => Ticket,
+ page: makePage(
+ `/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
+ ),
+ resolve: () => (creating ? TicketCreate : Ticket),
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
@@ -473,6 +522,37 @@ if (serve) {
await render('?gitError'),
/Repository temporarily unavailable/,
);
+ const create = await render('?source=board', true);
+ assert.match(create, /Create ticket/);
+ assert.match(create, /Delivery \/ In progress/);
+ assert.match(create, /Back to board/);
+ assert.match(create, /id="ticket_status_id"/);
+ assert.doesNotMatch(
+ create,
+ /id="author_user_id"|id="blocked_comment"|id="field-1"/,
+ );
+ assert.doesNotMatch(
+ await render('?hidePoints', true),
+ /id="story_points"/,
+ );
+ const unavailable = await render('?noMetadata', true);
+ assert.doesNotMatch(unavailable, /