diff --git a/app/Actions/Tickets/CreateProjectTicket.php b/app/Actions/Tickets/CreateProjectTicket.php index 395fa49..383f7f7 100644 --- a/app/Actions/Tickets/CreateProjectTicket.php +++ b/app/Actions/Tickets/CreateProjectTicket.php @@ -4,10 +4,15 @@ namespace App\Actions\Tickets; use App\Models\Project; use App\Models\Ticket; +use App\Models\User; use Illuminate\Support\Facades\DB; class CreateProjectTicket { + public function __construct( + private readonly CreateTicketWorklogEntry $createTicketWorklogEntry, + ) {} + /** * @param array{ * acceptance_criteria: string|null, @@ -17,14 +22,14 @@ class CreateProjectTicket * title: string * } $attributes */ - public function handle(Project $project, array $attributes): Ticket + public function handle(Project $project, array $attributes, ?User $actor = null): Ticket { - return DB::transaction(function () use ($attributes, $project): Ticket { + return DB::transaction(function () use ($actor, $attributes, $project): Ticket { $nextSequenceNumber = (int) Ticket::query() ->where('project_id', $project->id) ->max('sequence_number') + 1; - return Ticket::query()->create([ + $ticket = Ticket::query()->create([ 'acceptance_criteria' => $attributes['acceptance_criteria'], 'board_column_id' => null, 'description' => $attributes['description'], @@ -35,6 +40,23 @@ class CreateProjectTicket 'ticket_type_id' => $attributes['ticket_type_id'], 'title' => $attributes['title'], ]); + + if ($actor !== null) { + $this->createTicketWorklogEntry->handle( + $ticket, + $actor, + 'created', + 'Ticket was created in Backlog.', + [ + 'from' => null, + 'to' => [ + 'label' => 'Backlog', + ], + ], + ); + } + + return $ticket; }); } } diff --git a/app/Actions/Tickets/UpdateProjectTicket.php b/app/Actions/Tickets/UpdateProjectTicket.php new file mode 100644 index 0000000..5ae957c --- /dev/null +++ b/app/Actions/Tickets/UpdateProjectTicket.php @@ -0,0 +1,306 @@ + $fieldValueAttributes + */ + public function handle( + Ticket $ticket, + User $actor, + array $ticketAttributes, + array $fieldValueAttributes, + ): bool { + return DB::transaction(function () use ($actor, $fieldValueAttributes, $ticket, $ticketAttributes): bool { + $ticket->loadMissing([ + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'project', + 'ticketPriority', + 'ticketType', + ]); + + /** @var Collection $fieldDefinitions */ + $fieldDefinitions = $ticket->project->ticketFieldDefinitions() + ->get() + ->keyBy('id'); + + $ticketTypeOptions = $ticket->project->ticketTypes() + ->get() + ->keyBy('id'); + $ticketPriorityOptions = $ticket->project->ticketPriorities() + ->get() + ->keyBy('id'); + + /** @var Collection $existingFieldValues */ + $existingFieldValues = $ticket->fieldValues + ->keyBy('ticket_field_definition_id'); + + $changes = []; + $summaryLabels = []; + + $this->trackScalarChange( + $changes, + $summaryLabels, + 'title', + 'title', + $ticket->title, + $ticketAttributes['title'], + ); + $this->trackScalarChange( + $changes, + $summaryLabels, + 'description', + 'description', + $ticket->description, + $ticketAttributes['description'], + ); + $this->trackScalarChange( + $changes, + $summaryLabels, + 'acceptance_criteria', + 'acceptance criteria', + $ticket->acceptance_criteria, + $ticketAttributes['acceptance_criteria'], + ); + + if ($ticket->ticket_type_id !== $ticketAttributes['ticket_type_id']) { + $changes['ticket_type'] = [ + 'from' => $ticket->ticketType?->name, + 'to' => $ticketTypeOptions->get($ticketAttributes['ticket_type_id'])?->name, + ]; + $summaryLabels[] = 'type'; + } + + if ($ticket->ticket_priority_id !== $ticketAttributes['ticket_priority_id']) { + $changes['ticket_priority'] = [ + 'from' => $ticket->ticketPriority?->name, + 'to' => $ticketPriorityOptions->get($ticketAttributes['ticket_priority_id'])?->name, + ]; + $summaryLabels[] = 'priority'; + } + + $ticket->fill($ticketAttributes); + + if ($ticket->isDirty()) { + $ticket->save(); + } + + foreach ($fieldValueAttributes as $fieldValueAttribute) { + $fieldDefinition = $fieldDefinitions->get((int) $fieldValueAttribute['ticket_field_definition_id']); + + if ($fieldDefinition === null) { + continue; + } + + $existingFieldValue = $existingFieldValues->get($fieldDefinition->id); + + if ($fieldDefinition->field_type === 'user') { + $this->syncUserFieldValue( + $changes, + $existingFieldValue, + $fieldDefinition, + $fieldValueAttribute, + $summaryLabels, + $ticket, + ); + + continue; + } + + $this->syncNumberFieldValue( + $changes, + $existingFieldValue, + $fieldDefinition, + $fieldValueAttribute, + $summaryLabels, + $ticket, + ); + } + + if ($summaryLabels === []) { + return false; + } + + $this->createTicketWorklogEntry->handle( + $ticket->fresh(), + $actor, + 'updated', + $this->buildSummary($summaryLabels), + $changes, + ); + + return true; + }); + } + + /** + * @param array $changes + * @param array $summaryLabels + */ + private function trackScalarChange( + array &$changes, + array &$summaryLabels, + string $changeKey, + string $summaryLabel, + ?string $currentValue, + ?string $newValue, + ): void { + if ($currentValue === $newValue) { + return; + } + + $changes[$changeKey] = [ + 'from' => $currentValue, + 'to' => $newValue, + ]; + $summaryLabels[] = $summaryLabel; + } + + /** + * @param array $changes + * @param array{value_user_id?: int|string|null} $fieldValueAttribute + * @param array $summaryLabels + */ + private function syncUserFieldValue( + array &$changes, + ?TicketFieldValue $existingFieldValue, + TicketFieldDefinition $fieldDefinition, + array $fieldValueAttribute, + array &$summaryLabels, + Ticket $ticket, + ): void { + $newUserId = $fieldValueAttribute['value_user_id'] ?? null; + $newUserId = $newUserId === null || $newUserId === '' ? null : (int) $newUserId; + + if ($existingFieldValue?->value_user_id === $newUserId) { + return; + } + + $oldUserName = $existingFieldValue?->userValue?->name; + $newUserName = $newUserId === null ? null : User::query()->find($newUserId)?->name; + + $changes['field_values'][$fieldDefinition->id] = [ + 'field' => $fieldDefinition->label, + 'from' => $oldUserName, + 'to' => $newUserName, + ]; + $summaryLabels[] = "{$fieldDefinition->label} field"; + + if ($newUserId === null) { + $existingFieldValue?->delete(); + + return; + } + + $ticket->fieldValues()->updateOrCreate( + [ + 'ticket_field_definition_id' => $fieldDefinition->id, + ], + [ + 'value_number' => null, + 'value_user_id' => $newUserId, + ], + ); + } + + /** + * @param array $changes + * @param array{value_number?: float|int|string|null} $fieldValueAttribute + * @param array $summaryLabels + */ + private function syncNumberFieldValue( + array &$changes, + ?TicketFieldValue $existingFieldValue, + TicketFieldDefinition $fieldDefinition, + array $fieldValueAttribute, + array &$summaryLabels, + Ticket $ticket, + ): void { + $newNumber = $fieldValueAttribute['value_number'] ?? null; + $newNumber = $newNumber === null || $newNumber === '' ? null : (float) $newNumber; + $oldNumber = $existingFieldValue?->value_number; + + if ($oldNumber === $newNumber) { + return; + } + + $changes['field_values'][$fieldDefinition->id] = [ + 'field' => $fieldDefinition->label, + 'from' => $this->formatNumber($oldNumber), + 'to' => $this->formatNumber($newNumber), + ]; + $summaryLabels[] = "{$fieldDefinition->label} field"; + + if ($newNumber === null) { + $existingFieldValue?->delete(); + + return; + } + + $ticket->fieldValues()->updateOrCreate( + [ + 'ticket_field_definition_id' => $fieldDefinition->id, + ], + [ + 'value_number' => $newNumber, + 'value_user_id' => null, + ], + ); + } + + /** + * @param array $summaryLabels + */ + private function buildSummary(array $summaryLabels): string + { + $uniqueLabels = collect($summaryLabels) + ->unique() + ->values(); + + if ($uniqueLabels->count() === 1) { + return 'Updated '.$uniqueLabels->first().'.'; + } + + if ($uniqueLabels->count() === 2) { + return 'Updated '.$uniqueLabels->first().' and '.$uniqueLabels->last().'.'; + } + + $lastLabel = $uniqueLabels->pop(); + + return 'Updated '.$uniqueLabels->implode(', ').', and '.$lastLabel.'.'; + } + + private function formatNumber(?float $value): ?string + { + if ($value === null) { + return null; + } + + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/app/Http/Controllers/ProjectBacklogController.php b/app/Http/Controllers/ProjectBacklogController.php index ae4869c..92958fb 100644 --- a/app/Http/Controllers/ProjectBacklogController.php +++ b/app/Http/Controllers/ProjectBacklogController.php @@ -50,7 +50,7 @@ class ProjectBacklogController extends Controller 403, ); - $ticket = $this->createProjectTicket->handle($project, $request->validated()); + $ticket = $this->createProjectTicket->handle($project, $request->validated(), $request->user()); return to_route('projects.backlog.index', $project) ->with('status', "{$ticket->ticket_key} was added to the backlog."); diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php new file mode 100644 index 0000000..2e0b79b --- /dev/null +++ b/app/Http/Controllers/ProjectTicketController.php @@ -0,0 +1,74 @@ +projectPermissionService->userCanViewProject($request->user(), $project), + 403, + ); + + return Inertia::render('projects/Ticket', [ + 'canEditTicket' => $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 'project' => $project, + 'status' => session('status'), + 'ticket' => $ticket->load([ + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketType', + 'worklogEntries.actor', + ]), + 'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(), + 'ticketPriorities' => $project->ticketPriorities()->get(), + 'ticketTypes' => $project->ticketTypes()->get(), + 'users' => User::query() + ->orderBy('name') + ->get(), + ]); + } + + public function update( + UpdateProjectTicketRequest $request, + Project $project, + Ticket $ticket, + ): RedirectResponse { + abort_unless( + $this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'), + 403, + ); + + $wasUpdated = $this->updateProjectTicket->handle( + $ticket, + $request->user(), + $request->ticketAttributes(), + $request->fieldValues(), + ); + + $statusMessage = $wasUpdated + ? "{$ticket->ticket_key} was updated." + : 'No ticket changes were detected.'; + + return to_route('projects.tickets.show', [$project, $ticket]) + ->with('status', $statusMessage); + } +} diff --git a/app/Http/Requests/UpdateProjectTicketRequest.php b/app/Http/Requests/UpdateProjectTicketRequest.php new file mode 100644 index 0000000..aed4581 --- /dev/null +++ b/app/Http/Requests/UpdateProjectTicketRequest.php @@ -0,0 +1,106 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + /** @var Project $project */ + $project = $this->route('project'); + + return [ + 'acceptance_criteria' => ['nullable', 'string'], + 'description' => ['nullable', 'string'], + 'field_values' => ['array'], + 'field_values.*' => ['array'], + 'field_values.*.ticket_field_definition_id' => [ + 'required', + 'integer', + Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), + ], + 'field_values.*.value_number' => ['nullable', 'numeric'], + 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], + 'ticket_priority_id' => [ + 'required', + 'integer', + Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), + ], + 'ticket_type_id' => [ + 'required', + 'integer', + Rule::exists('ticket_types', 'id')->where('project_id', $project->id), + ], + 'title' => ['required', 'string', 'max:255'], + ]; + } + + /** + * @return array{ + * acceptance_criteria: string|null, + * description: string|null, + * ticket_priority_id: int, + * ticket_type_id: int, + * title: string + * } + */ + public function ticketAttributes(): array + { + /** @var array{ + * acceptance_criteria: string|null, + * description: string|null, + * ticket_priority_id: int, + * ticket_type_id: int, + * title: string + * } $attributes + */ + $attributes = Arr::only($this->validated(), [ + 'acceptance_criteria', + 'description', + 'ticket_priority_id', + 'ticket_type_id', + 'title', + ]); + + return $attributes; + } + + /** + * @return array + */ + public function fieldValues(): array + { + /** @var array $fieldValues + */ + $fieldValues = $this->validated('field_values', []); + + return $fieldValues; + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index fe43817..f986fab 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -30,6 +30,12 @@ class Ticket extends Model return $this->belongsTo(BoardColumn::class); } + public function fieldValues(): HasMany + { + return $this->hasMany(TicketFieldValue::class) + ->orderBy('ticket_field_definition_id'); + } + public function project(): BelongsTo { return $this->belongsTo(Project::class); diff --git a/app/Models/TicketFieldDefinition.php b/app/Models/TicketFieldDefinition.php index c7c4e2a..017a758 100644 --- a/app/Models/TicketFieldDefinition.php +++ b/app/Models/TicketFieldDefinition.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable(['field_type', 'label', 'position', 'project_id'])] class TicketFieldDefinition extends Model @@ -26,4 +27,10 @@ class TicketFieldDefinition extends Model { return $this->belongsTo(Project::class); } + + public function ticketFieldValues(): HasMany + { + return $this->hasMany(TicketFieldValue::class) + ->orderBy('ticket_id'); + } } diff --git a/app/Models/TicketFieldValue.php b/app/Models/TicketFieldValue.php new file mode 100644 index 0000000..1e142a5 --- /dev/null +++ b/app/Models/TicketFieldValue.php @@ -0,0 +1,46 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'value_number' => 'float', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function ticketFieldDefinition(): BelongsTo + { + return $this->belongsTo(TicketFieldDefinition::class); + } + + public function userValue(): BelongsTo + { + return $this->belongsTo(User::class, 'value_user_id'); + } +} diff --git a/database/factories/TicketFieldValueFactory.php b/database/factories/TicketFieldValueFactory.php new file mode 100644 index 0000000..4cf0cfc --- /dev/null +++ b/database/factories/TicketFieldValueFactory.php @@ -0,0 +1,60 @@ + + */ +class TicketFieldValueFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'value_number' => fake()->randomFloat(1, 1, 13), + 'value_user_id' => null, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (TicketFieldValue $ticketFieldValue): void { + $ticket = $ticketFieldValue->ticket_id === null + ? Ticket::factory()->create() + : Ticket::query()->findOrFail($ticketFieldValue->ticket_id); + + app(InitializeProjectTicketMetadata::class)->handle($ticket->project); + + $ticketFieldDefinition = $ticketFieldValue->ticket_field_definition_id === null + ? TicketFieldDefinition::factory()->for($ticket->project)->create([ + 'field_type' => 'number', + 'label' => 'Story Points', + 'position' => $ticket->project->ticketFieldDefinitions()->count() + 1, + ]) + : TicketFieldDefinition::query()->findOrFail($ticketFieldValue->ticket_field_definition_id); + + $ticketFieldValue->ticket_id = $ticket->id; + $ticketFieldValue->ticket_field_definition_id = $ticketFieldDefinition->id; + + if ($ticketFieldDefinition->field_type === 'user') { + $ticketFieldValue->value_number = null; + $ticketFieldValue->value_user_id ??= User::factory()->create()->id; + + return; + } + + $ticketFieldValue->value_user_id = null; + }); + } +} diff --git a/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php b/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php new file mode 100644 index 0000000..5096546 --- /dev/null +++ b/database/migrations/2026_03_25_070706_create_ticket_field_values_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('ticket_id')->constrained()->cascadeOnDelete(); + $table->foreignId('ticket_field_definition_id')->constrained()->cascadeOnDelete(); + $table->foreignId('value_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->decimal('value_number', 16, 4)->nullable(); + $table->timestamps(); + + $table->unique(['ticket_id', 'ticket_field_definition_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_field_values'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index adc152e..5f9789b 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, TicketSeeder::class, + TicketFieldValueSeeder::class, TicketWorklogEntrySeeder::class, TeamSeeder::class, ProjectAccessSeeder::class, diff --git a/database/seeders/TicketFieldValueSeeder.php b/database/seeders/TicketFieldValueSeeder.php new file mode 100644 index 0000000..96e76ee --- /dev/null +++ b/database/seeders/TicketFieldValueSeeder.php @@ -0,0 +1,46 @@ +orderBy('id') + ->first(); + + Project::query() + ->with(['ticketFieldDefinitions', 'tickets']) + ->orderBy('id') + ->get() + ->each(function (Project $project) use ($defaultUser): void { + /** @var TicketFieldDefinition $fieldDefinition */ + foreach ($project->ticketFieldDefinitions as $fieldDefinition) { + /** @var Ticket $ticket */ + foreach ($project->tickets as $ticket) { + TicketFieldValue::factory()->create([ + 'ticket_field_definition_id' => $fieldDefinition->id, + 'ticket_id' => $ticket->id, + 'value_number' => $fieldDefinition->field_type === 'number' + ? (float) ($ticket->sequence_number + 2) + : null, + 'value_user_id' => $fieldDefinition->field_type === 'user' + ? $defaultUser?->id + : null, + ]); + } + } + }); + } +} diff --git a/docs/missing-features.md b/docs/missing-features.md index 0bebc69..3b3a084 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -43,7 +43,7 @@ - use Arr:get(\$validated, 'value'). instead of these messages or \$request->value - Made ticket priority more static(change to has one) in ticket, but only show if there are more than one configured. - made story points more static(change to table field in table), but only show if checked in project settings → new value on Project: hasStoryPoints - - more ticket field types (string, string array, boolean) + - more ticket field types (string, string array, boolean, precision (double)) - abortUnlessCanManage(xxx) entfernen, auch gate benutzen (ProjectTicketMetadataSettingsController) - 06 - add Ticket Status @@ -54,7 +54,9 @@ - (ui friendly example all following or select) - change Project Backlog Controller → Project Controller - remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create - +- 08 + - worklog überarbeiten → type als worklog enum → json je nach enum type + - UpdateProjectTicket aufräumen - later: - if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index 9b5637c..33b0fb6 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -19,6 +19,7 @@ import AppLayout from '@/layouts/AppLayout.vue'; import { index as boardIndex } from '@/routes/projects/board'; import { index as backlogIndex } from '@/routes/projects/backlog'; import { index, show } from '@/routes/projects'; +import { show as showTicket } from '@/routes/projects/tickets'; import { metadata as metadataSettings } from '@/routes/projects/settings'; import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; @@ -245,16 +246,31 @@ const hasTicketMetadata = class="bg-background" > - - {{ ticket.ticket_key }} - + + + {{ ticket.ticket_key }} + + -
{{ ticket.title }} -
+
(); @@ -150,7 +151,17 @@ const statusOptions = [
- {{ ticket.title }} + + {{ ticket.title }} + +import { useForm, Head, Link } from '@inertiajs/vue3'; +import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; +import Heading from '@/components/Heading.vue'; +import InputError from '@/components/InputError.vue'; +import { Badge } from '@/components/ui/badge'; +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 AppLayout from '@/layouts/AppLayout.vue'; +import { index as backlogIndex } from '@/routes/projects/backlog'; +import { index as boardIndex } from '@/routes/projects/board'; +import { index as projectIndex, show } from '@/routes/projects'; +import { show as showTicket } from '@/routes/projects/tickets'; +import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types'; + +const props = defineProps(); + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Projects', + href: projectIndex(), + }, + { + title: props.project.name, + href: show(props.project.id), + }, + { + title: props.ticket.ticket_key, + href: showTicket({ + project: props.project.id, + ticket: props.ticket.id, + }), + }, +]; + +const form = useForm({ + acceptance_criteria: props.ticket.acceptance_criteria ?? '', + description: props.ticket.description ?? '', + field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => { + const currentFieldValue = props.ticket.field_values?.find( + (fieldValue) => + fieldValue.ticket_field_definition_id === + ticketFieldDefinition.id, + ); + + return { + ticket_field_definition_id: ticketFieldDefinition.id, + value_number: currentFieldValue?.value_number ?? '', + value_user_id: currentFieldValue?.value_user_id ?? '', + }; + }), + ticket_priority_id: props.ticket.ticket_priority_id, + ticket_type_id: props.ticket.ticket_type_id, + title: props.ticket.title, +}); + +const submit = (): void => { + if (!props.canEditTicket) { + return; + } + + form.submit( + ProjectTicketController.update({ + project: props.project.id, + ticket: props.ticket.id, + }), + { + preserveScroll: true, + }, + ); +}; + +const formatDateTime = (value: string): string => { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +}; + + +