ticket-rework #27

Merged
Twyco merged 8 commits from ticket-rework into codex/permissions-refactoring 2026-04-14 16:42:26 +00:00
17 changed files with 208 additions and 73 deletions
Showing only changes of commit b3f2cc3afd - Show all commits
@@ -5,6 +5,7 @@ namespace App\Actions\Tickets;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Arr;
use Illuminate\Support\Facades\DB;
class CreateProjectTicket
@@ -39,6 +40,7 @@ class CreateProjectTicket
'ticket_priority_id' => $attributes['ticket_priority_id'],
'ticket_type_id' => $attributes['ticket_type_id'],
'title' => $attributes['title'],
'story_points' => Arr::get($attributes, 'story_points'),
]);
if ($actor !== null) {
@@ -47,7 +47,7 @@ class ProjectTicketController extends Controller
'parentTicketOptions' => Ticket::query()
->where('project_id', $project->id)
->whereNotIn('id', $excludedParentIds)
->whereHas('ticketType', fn ($query) => $query->where('system_key', 'parent'))
->whereHas('ticketType', fn ($query) => $query->where('is_parent', true))
->orderByDesc('sequence_number')
->get()
->map(fn (Ticket $parentTicket): array => [
@@ -119,6 +119,7 @@ class ProjectTicketController extends Controller
'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'),
'ticket_type_id' => Arr::get($validated, 'ticket_type_id'),
'title' => Arr::get($validated, 'title'),
'story_points' => Arr::get($validated, 'story_points'),
];
$wasUpdated = $this->updateProjectTicket->handle(
@@ -8,6 +8,7 @@ use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
use App\Http\Requests\UpdateProjectTicketMetadataSettings;
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
@@ -50,6 +51,16 @@ class ProjectTicketMetadataSettingsController extends Controller
]);
}
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$project->update(['show_story_points' => Arr::get($validated, 'show_story_points', false)]);
return to_route('projects.settings.metadata', $project)
->with('status', 'Settings have been updated.');
}
public function storeType(StoreProjectTicketTypeRequest $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
@@ -40,6 +40,7 @@ class StoreProjectTicketRequest extends FormRequest
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
],
'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'],
];
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProjectTicketMetadataSettings extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'show_story_points' => 'nullable|boolean',
];
}
}
@@ -61,6 +61,7 @@ class UpdateProjectTicketRequest extends FormRequest
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
],
'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'gt:0'],
];
}
+8 -1
View File
@@ -11,12 +11,19 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key'])]
#[Fillable(['name', 'key', 'show_story_points'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected function casts(): array
{
return [
'show_story_points' => 'boolean',
];
}
protected static function booted(): void
{
static::deleting(function (self $project): void {
+1
View File
@@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'ticket_priority_id',
'ticket_type_id',
'title',
'story_points',
])]
class Ticket extends Model
{
@@ -15,6 +15,7 @@ return new class extends Migration
$table->id();
$table->string('name');
$table->string('key', 5)->unique();
$table->boolean('show_story_points')->default(true);
$table->timestamps();
});
}
@@ -22,6 +22,7 @@ return new class extends Migration
$table->string('title');
$table->text('description')->nullable();
$table->text('acceptance_criteria')->nullable();
$table->unsignedInteger('story_points')->nullable()->default(null);
$table->timestamps();
$table->unique(['project_id', 'sequence_number']);
+1 -1
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
modelValue?: string | number | null
class?: HTMLAttributes["class"]
}>()
+90 -64
View File
@@ -107,74 +107,100 @@ const hasTicketMetadata =
<InputError :message="errors.title" />
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_type_id"
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="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError :message="errors.ticket_type_id" />
</div>
<div class="flex gap-2">
<div class="flex-4/5 grid gap-2 md:gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
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="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div>
<div
class="grid h-fit flex-1 gap-2 md:ml-4 md:gap-6"
>
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_type_id"
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]"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_priority_id">
Priority
</Label>
<select
id="ticket_priority_id"
name="ticket_priority_id"
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="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
</div>
</div>
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
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]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
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]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError :message="errors.acceptance_criteria" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
+20
View File
@@ -117,6 +117,7 @@ const form = useForm({
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
story_points: props.ticket.story_points,
});
const isCreateBranchDialogOpen = ref(false);
@@ -460,6 +461,25 @@ const formatDateTime = (value: string): string => {
</div>
</div>
<div
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
v-model="form.story_points"
type="number"
step="1"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
@@ -106,13 +106,43 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
<div class="grid gap-6 xl:grid-cols-2">
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>General ticket settings</CardTitle>
</CardHeader>
<CardContent class="space-y-4 p-6">
<Form
v-bind="
ProjectTicketMetadataSettingsController.update.form(
project.id,
)
"
class="border-border space-y-4 rounded-xl border border-dashed p-4"
v-slot="{ errors, processing }"
>
<Label for="enable-story-points">
<Checkbox
id="enable-story-points"
name="show_story_points"
:default-value="project.show_story_points"
value="1"
/>
Enable story points for tickets
</Label>
<InputError :message="errors.show_story_points" />
<Button type="submit" :disabled="processing">
Save
</Button>
</Form>
</CardContent>
</Card>
<Card class="gap-0 overflow-hidden">
<CardHeader class="border-b">
<CardTitle>Ticket types</CardTitle>
<CardDescription>
Built-in Task and Parent always exist. Custom types
can be added and reordered per project.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
@@ -139,7 +169,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<Checkbox
id="type-is-parent"
name="is_parent"
required
value="1"
/>
Ticket type is can be a parent
</Label>
@@ -183,10 +213,10 @@ const breadcrumbs: BreadcrumbItem[] = [
<Checkbox
:id="`type-is-parent-${ticketType.id}}`"
name="is_parent"
required
:default-value="
ticketType.is_parent
"
value="1"
/>
Ticket type is can be a parent
</Label>
@@ -2,6 +2,7 @@ export type ProjectModel = {
id: number;
name: string;
key: string;
show_story_points: boolean;
created_at: string;
updated_at: string;
};
@@ -21,6 +21,7 @@ export type TicketModel = {
ticket_type_id: number;
title: string;
worklog_entries?: TicketWorklogEntryModel[];
story_points: number | null;
created_at: string;
updated_at: string;
};
+2
View File
@@ -82,6 +82,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.settings.metadata.types.store');
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
->name('projects.settings.metadata.types.update');
Route::patch('projects/{project}/settings/metadata}', [ProjectTicketMetadataSettingsController::class, 'update'])
->name('projects.settings.metadata.update');
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
->name('projects.settings.metadata.types.destroy');
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])