ticket type rework
This commit is contained in:
@@ -37,7 +37,6 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||
- `project-request-validation-style` — Always activate for this project when changing Laravel controllers or FormRequest classes. Use dedicated FormRequest classes for controller validation, access validated data through `$validated = $request->validated();` plus `Arr::get(...)`, avoid request helper extraction like `boolean()/integer()/string()`, and do not add custom single-field getter methods on FormRequest classes.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -103,7 +102,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
@@ -210,7 +209,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
|
||||
# Inertia + Vue
|
||||
|
||||
Vue components must have a single root element.
|
||||
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
@@ -3,40 +3,31 @@
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
|
||||
class InitializeProjectTicketMetadata
|
||||
{
|
||||
public function handle(Project $project): void
|
||||
{
|
||||
$project->ticketTypes()->firstOrCreate(
|
||||
['system_key' => 'task'],
|
||||
$project->ticketTypes()->create(
|
||||
[
|
||||
'is_builtin' => true,
|
||||
'name' => 'Task',
|
||||
'position' => 1,
|
||||
],
|
||||
);
|
||||
|
||||
$project->ticketTypes()->firstOrCreate(
|
||||
['system_key' => 'parent'],
|
||||
$project->ticketTypes()->create(
|
||||
[
|
||||
'is_builtin' => true,
|
||||
'name' => 'Parent',
|
||||
'name' => 'Release',
|
||||
'is_parent' => true,
|
||||
'position' => 2,
|
||||
],
|
||||
);
|
||||
|
||||
$project->ticketTypes()
|
||||
->whereIn('system_key', TicketType::BUILTIN_KEYS)
|
||||
->orderBy('position')
|
||||
->get()
|
||||
->values()
|
||||
->each(function (TicketType $ticketType, int $index): void {
|
||||
$ticketType->forceFill([
|
||||
'is_builtin' => true,
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
$project->ticketPriorities()->create(
|
||||
[
|
||||
'name' => 'Medium',
|
||||
'position' => 1,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -9,9 +9,9 @@ use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||
#[Description('Command description')]
|
||||
class CreateInitialAdminUser extends Command
|
||||
class CreateAdminUser extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
@@ -23,12 +23,13 @@ class CreateInitialAdminUser extends Command
|
||||
'email' => $this->argument('email'),
|
||||
'password' => Hash::make($this->argument('password')),
|
||||
]);
|
||||
$this->info('User created');
|
||||
|
||||
$user->permissionAssignments()->create([
|
||||
'permission' => Permission::GlobalAdmin,
|
||||
]);
|
||||
|
||||
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
||||
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
||||
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
|
||||
use App\Http\Requests\UpdateProjectNamedSettingRequest;
|
||||
use App\Http\Requests\UpdateTicketFieldDefinitionRequest;
|
||||
@@ -26,14 +26,12 @@ use Inertia\Response;
|
||||
class ProjectTicketMetadataSettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
|
||||
return Inertia::render('projects/settings/Metadata', [
|
||||
'fieldTypeOptions' => collect(TicketFieldDefinition::FIELD_TYPES)
|
||||
@@ -43,6 +41,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
])
|
||||
->all(),
|
||||
'project' => $project,
|
||||
'error' => session('error'),
|
||||
'status' => session('status'),
|
||||
'ticketFieldDefinitions' => $project->ticketFieldDefinitions()->get(),
|
||||
'ticketLinkTypes' => $project->ticketLinkTypes()->get(),
|
||||
@@ -51,27 +50,23 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeType(StoreProjectNamedSettingRequest $request, Project $project): RedirectResponse
|
||||
public function storeType(StoreProjectTicketTypeRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
|
||||
$validated = $request->validated();
|
||||
$name = Arr::get($validated, 'name');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
$ticketType = DB::transaction(function () use ($project, $name, $position): TicketType {
|
||||
$ticketType = DB::transaction(function () use ($project, $validated): TicketType {
|
||||
$ticketType = $project->ticketTypes()->create([
|
||||
'is_builtin' => false,
|
||||
'name' => $name,
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
'position' => $project->ticketTypes()->count() + 1,
|
||||
'system_key' => null,
|
||||
'is_parent' => Arr::get($validated, 'is_parent', false),
|
||||
]);
|
||||
|
||||
$this->reorderModels(
|
||||
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
|
||||
$ticketType,
|
||||
$position ?? $project->ticketTypes()->count(),
|
||||
Arr::get($validated, 'position') ?? $project->ticketTypes()->count(),
|
||||
);
|
||||
|
||||
return $ticketType;
|
||||
@@ -87,21 +82,19 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
TicketType $ticketType,
|
||||
): RedirectResponse {
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
|
||||
$validated = $request->validated();
|
||||
$name = Arr::get($validated, 'name');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
DB::transaction(function () use ($project, $name, $position, $ticketType): void {
|
||||
DB::transaction(function () use ($project, $validated, $ticketType): void {
|
||||
$ticketType->update([
|
||||
'name' => $name,
|
||||
'name' => Arr::get($validated, 'name'),
|
||||
'is_parent' => Arr::get($validated, 'is_parent', false),
|
||||
]);
|
||||
|
||||
$this->reorderModels(
|
||||
$project->ticketTypes()->whereKeyNot($ticketType->id)->get()->values(),
|
||||
$ticketType,
|
||||
$position,
|
||||
Arr::get($validated, 'position'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,7 +106,11 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
{
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
abort_if($ticketType->is_builtin, 403);
|
||||
if ($project->ticketTypes()->count() === 1) {
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
->with('error', true)
|
||||
->with('status', 'The last ticket type cannot be deleted.');
|
||||
}
|
||||
|
||||
$deletedName = $ticketType->name;
|
||||
|
||||
@@ -188,6 +185,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
): RedirectResponse {
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
if ($project->ticketPriorities()->count() === 1) {
|
||||
return to_route('projects.settings.metadata', $project)
|
||||
->with('error', true)
|
||||
->with('status', 'The last ticket priority cannot be deleted.');
|
||||
}
|
||||
|
||||
$deletedName = $ticketPriority->name;
|
||||
|
||||
DB::transaction(function () use ($project, $ticketPriority): void {
|
||||
@@ -207,21 +210,18 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
$validated = $request->validated();
|
||||
$label = Arr::get($validated, 'label');
|
||||
$fieldType = Arr::get($validated, 'field_type');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
$ticketFieldDefinition = DB::transaction(function () use ($project, $fieldType, $label): TicketFieldDefinition {
|
||||
$ticketFieldDefinition = DB::transaction(function () use ($project, $validated): TicketFieldDefinition {
|
||||
$ticketFieldDefinition = $project->ticketFieldDefinitions()->create([
|
||||
'field_type' => $fieldType,
|
||||
'label' => $label,
|
||||
'field_type' => Arr::get($validated, 'field_type'),
|
||||
'label' => Arr::get($validated, 'label'),
|
||||
'position' => $project->ticketFieldDefinitions()->count() + 1,
|
||||
]);
|
||||
|
||||
$this->reorderModels(
|
||||
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
|
||||
$ticketFieldDefinition,
|
||||
$position ?? $project->ticketFieldDefinitions()->count(),
|
||||
Arr::get($validated, 'position') ?? $project->ticketFieldDefinitions()->count(),
|
||||
);
|
||||
|
||||
return $ticketFieldDefinition;
|
||||
@@ -239,20 +239,17 @@ class ProjectTicketMetadataSettingsController extends Controller
|
||||
$this->abortUnlessCanManage($request, $project);
|
||||
|
||||
$validated = $request->validated();
|
||||
$label = Arr::get($validated, 'label');
|
||||
$fieldType = Arr::get($validated, 'field_type');
|
||||
$position = Arr::get($validated, 'position');
|
||||
|
||||
DB::transaction(function () use ($project, $fieldType, $label, $ticketFieldDefinition, $position): void {
|
||||
DB::transaction(function () use ($project, $ticketFieldDefinition, $validated): void {
|
||||
$ticketFieldDefinition->update([
|
||||
'field_type' => $fieldType,
|
||||
'label' => $label,
|
||||
'field_type' => Arr::get($validated, 'field_type'),
|
||||
'label' => Arr::get($validated, 'label'),
|
||||
]);
|
||||
|
||||
$this->reorderModels(
|
||||
$project->ticketFieldDefinitions()->whereKeyNot($ticketFieldDefinition->id)->get()->values(),
|
||||
$ticketFieldDefinition,
|
||||
$position,
|
||||
Arr::get($validated, 'position'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreProjectTicketTypeRequest 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 [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['nullable', 'integer', 'min:1'],
|
||||
'is_parent' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class UpdateProjectNamedSettingRequest extends FormRequest
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'position' => ['required', 'integer', 'min:1'],
|
||||
'is_parent' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,20 +8,19 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['is_builtin', 'name', 'position', 'project_id', 'system_key'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const BUILTIN_KEYS = [
|
||||
'task',
|
||||
'parent',
|
||||
];
|
||||
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_parent' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -15,13 +15,11 @@ return new class extends Migration
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('system_key')->nullable();
|
||||
$table->boolean('is_builtin')->default(false);
|
||||
$table->boolean('is_parent')->default(false);
|
||||
$table->unsignedInteger('position');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['project_id', 'position']);
|
||||
$table->unique(['project_id', 'system_key']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
BreadcrumbItem,
|
||||
ProjectTicketMetadataSettingsPageProps,
|
||||
} from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
|
||||
|
||||
@@ -94,7 +95,12 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
:class="[
|
||||
'rounded-xl border px-4 py-3 text-sm',
|
||||
error
|
||||
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
|
||||
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
|
||||
]"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
@@ -128,6 +134,16 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
placeholder="Bug"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
|
||||
<Label for="type-is-parent">
|
||||
<Checkbox
|
||||
id="type-is-parent"
|
||||
name="is_parent"
|
||||
required
|
||||
/>
|
||||
Ticket type is can be a parent
|
||||
</Label>
|
||||
<InputError :message="errors.is_parent" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
@@ -149,29 +165,6 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
class="grid gap-4"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3"
|
||||
>
|
||||
<Badge
|
||||
:variant="
|
||||
ticketType.is_builtin
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
"
|
||||
>
|
||||
{{
|
||||
ticketType.is_builtin
|
||||
? 'Built-in'
|
||||
: 'Custom'
|
||||
}}
|
||||
</Badge>
|
||||
<p
|
||||
class="text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Position {{ ticketType.position }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label :for="`type-name-${ticketType.id}`">
|
||||
Name
|
||||
@@ -183,6 +176,21 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
|
||||
<Label
|
||||
:for="`type-is-parent-${ticketType.id}}`"
|
||||
>
|
||||
<Checkbox
|
||||
:id="`type-is-parent-${ticketType.id}}`"
|
||||
name="is_parent"
|
||||
required
|
||||
:default-value="
|
||||
ticketType.is_parent
|
||||
"
|
||||
/>
|
||||
Ticket type is can be a parent
|
||||
</Label>
|
||||
<InputError :message="errors.is_parent" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
@@ -217,7 +225,6 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</Form>
|
||||
|
||||
<Form
|
||||
v-if="!ticketType.is_builtin"
|
||||
v-bind="
|
||||
ProjectTicketMetadataSettingsController.destroyType.form(
|
||||
[project.id, ticketType.id],
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export type TicketTypeModel = {
|
||||
id: number;
|
||||
is_builtin: boolean;
|
||||
is_parent: boolean;
|
||||
name: string;
|
||||
position: number;
|
||||
project_id: number;
|
||||
system_key: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@ export type TicketFieldTypeOption = {
|
||||
export type ProjectTicketMetadataSettingsPageProps = {
|
||||
fieldTypeOptions: TicketFieldTypeOption[];
|
||||
project: ProjectModel;
|
||||
error?: string | null;
|
||||
status?: string | null;
|
||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||
ticketLinkTypes: TicketLinkTypeModel[];
|
||||
|
||||
Reference in New Issue
Block a user