diff --git a/resources/js/components/projects/MetadataEditorDialog.vue b/resources/js/components/projects/MetadataEditorDialog.vue new file mode 100644 index 0000000..e89c84f --- /dev/null +++ b/resources/js/components/projects/MetadataEditorDialog.vue @@ -0,0 +1,363 @@ + + + diff --git a/resources/js/components/projects/MetadataOptionList.vue b/resources/js/components/projects/MetadataOptionList.vue new file mode 100644 index 0000000..7130dce --- /dev/null +++ b/resources/js/components/projects/MetadataOptionList.vue @@ -0,0 +1,147 @@ + + + diff --git a/resources/js/components/projects/TicketDisplaySettings.vue b/resources/js/components/projects/TicketDisplaySettings.vue new file mode 100644 index 0000000..f891ac3 --- /dev/null +++ b/resources/js/components/projects/TicketDisplaySettings.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index e7feea4..f0bcc11 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -913,4 +913,51 @@ export const en = { 'gitConnections.added': 'Git connection added.', 'gitConnections.updated': 'Git connection updated.', 'gitConnections.removed': 'Git connection removed.', + 'metadata.title': 'Ticket settings', + 'metadata.pageTitle': '{project} ticket settings', + 'metadata.description': + 'Manage ticket types, priorities, and the information shown on your tickets.', + 'metadata.updated': 'Ticket settings updated.', + 'metadata.types': 'Ticket types', + 'metadata.priorities': 'Priorities', + 'metadata.addType': 'Add type', + 'metadata.addPriority': 'Add priority', + 'metadata.typesDescription': + 'Define your project’s ticket types and their order. Parent types can contain child tickets.', + 'metadata.prioritiesDescription': + 'Define and order project-specific priorities. The last priority must be kept.', + 'metadata.parentType': 'Parent type', + 'metadata.standardType': 'Standard type', + 'metadata.assignedTickets': 'Assigned tickets: {count}', + 'metadata.editEntry': 'Edit {name}', + 'metadata.deleteEntry': 'Delete {name}', + 'metadata.lastType': + 'The last ticket type cannot be deleted. Add another type first.', + 'metadata.lastPriority': + 'The last priority cannot be deleted. Add another priority first.', + 'metadata.display': 'Display options', + 'metadata.displayDescription': + 'Choose which optional information is available on this project’s tickets.', + 'metadata.author': 'Show ticket author', + 'metadata.authorHelp': 'Display the author on ticket details.', + 'metadata.storyPoints': 'Enable story points', + 'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.', + 'metadata.name': 'Name', + 'metadata.positionHelp': + 'Choose a position from 1 to {count}. Other entries shift automatically.', + 'metadata.allowChildren': 'Allow child tickets', + 'metadata.parentFixed': + 'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.', + 'metadata.editHelp': + 'Choose a project-specific name. Changes apply to tickets using this entry.', + 'metadata.deleteTypeHelp': + 'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.', + 'metadata.deletePriorityHelp': + 'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.', + 'metadata.replacementHelp': + 'Move {count} assigned tickets to another type before deleting this one.', + 'metadata.replacement': 'Replacement ticket type', + 'metadata.chooseType': 'Choose a type', + 'metadata.parentReplacement': + 'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.', } as const; diff --git a/resources/js/pages/projects/settings/Metadata.vue b/resources/js/pages/projects/settings/Metadata.vue index 15c6d2e..51cfdcf 100644 --- a/resources/js/pages/projects/settings/Metadata.vue +++ b/resources/js/pages/projects/settings/Metadata.vue @@ -1,10 +1,13 @@ diff --git a/resources/js/types/project-metadata.ts b/resources/js/types/project-metadata.ts new file mode 100644 index 0000000..9981b90 --- /dev/null +++ b/resources/js/types/project-metadata.ts @@ -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>; +export type MetadataEditorTarget = { kind: MetadataKind } & ( + | { action: 'create'; entry?: never } + | { action: 'edit' | 'delete'; entry: MetadataEntry } +); diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php index 9773916..36f65a4 100644 --- a/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php @@ -487,4 +487,75 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase 'id' => $ticketLinkType->id, ]); } + + public function test_last_priority_is_protected_from_deletion(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $priority = TicketPriority::factory()->for($project)->create(); + $this->syncProjectPermissions($project, $user, ['edit']); + + $this->actingAs($user) + ->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority])) + ->assertRedirect(route('projects.settings.metadata', $project)) + ->assertSessionHas('error', true) + ->assertSessionHas('status', 'The last ticket priority cannot be deleted.'); + + $this->assertModelExists($priority); + } + + public function test_type_order_and_usage_counts_are_returned_after_reordering(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + app(InitializeProjectTicketMetadata::class)->handle($project); + $types = $project->ticketTypes()->get(); + $first = $types->first(); + $last = $types->last(); + Ticket::factory()->for($project)->create([ + 'ticket_type_id' => $last->id, + 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, + ]); + $this->syncProjectPermissions($project, $user, ['edit']); + + $this->actingAs($user) + ->patch(route('projects.settings.metadata.types.update', [$project, $last]), [ + 'name' => 'Customer request', 'position' => 1, + ]) + ->assertSessionHasNoErrors(); + + $this->get(route('projects.settings.metadata', $project)) + ->assertInertia(fn (Assert $page) => $page + ->where('ticketTypes.0.id', $last->id) + ->where('ticketTypes.0.name', 'Customer request') + ->where('ticketTypes.0.position', 1) + ->where('ticketTypes.0.tickets_count', 1) + ->where('ticketTypes.1.id', $first->id) + ->where('ticketTypes.1.position', 2) + ->where('ticketTypes.1.tickets_count', 0)); + } + + public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + app(InitializeProjectTicketMetadata::class)->handle($project); + $type = $project->ticketTypes()->firstOrFail(); + $foreignType = TicketType::factory()->create(); + $ticket = Ticket::factory()->for($project)->create([ + 'ticket_type_id' => $type->id, + 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, + ]); + $this->syncProjectPermissions($project, $user, ['edit']); + + $this->actingAs($user) + ->from(route('projects.settings.metadata', $project)) + ->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [ + 'replacement_ticket_type_id' => $foreignType->id, + ]) + ->assertSessionHasErrors('replacement_ticket_type_id'); + + $this->assertModelExists($type); + $this->assertSame($type->id, $ticket->fresh()->ticket_type_id); + } } diff --git a/tests/Feature/Projects/ProjectTicketMetadataSettingsUiTest.php b/tests/Feature/Projects/ProjectTicketMetadataSettingsUiTest.php new file mode 100644 index 0000000..6d4a7f8 --- /dev/null +++ b/tests/Feature/Projects/ProjectTicketMetadataSettingsUiTest.php @@ -0,0 +1,18 @@ +setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-metadata-settings-preview.mjs b/tests/Fixtures/project-metadata-settings-preview.mjs new file mode 100644 index 0000000..44b43db --- /dev/null +++ b/tests/Fixtures/project-metadata-settings-preview.mjs @@ -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), +}); diff --git a/tests/Fixtures/project-metadata-settings.mjs b/tests/Fixtures/project-metadata-settings.mjs new file mode 100644 index 0000000..23e5c67 --- /dev/null +++ b/tests/Fixtures/project-metadata-settings.mjs @@ -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, + '
', + ), + ); + }); + 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(/]*>[\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(); + } +}