Implement projects and project keys #5

Merged
Twyco merged 2 commits from codex/0.1-kanban-foundation/02-projects-and-project-keys into main 2026-03-25 20:15:42 +00:00
16 changed files with 716 additions and 1 deletions
Showing only changes of commit 7d4b0733b3 - Show all commits
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreProjectRequest;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProjectController extends Controller
{
public function index(): Response
{
return Inertia::render('projects/Index', [
'projects' => Project::query()
Twyco marked this conversation as resolved
Review

Project::latest('id')->get()

Project::latest('id')->get()
->orderByDesc('id')
->get(),
]);
}
Eric marked this conversation as resolved
Review

Pagination hinzufügen aber erstmal optional, muss andere Ticketzs checken ob das da gegebeen ist

Pagination hinzufügen aber erstmal optional, muss andere Ticketzs checken ob das da gegebeen ist
public function create(): Response
{
return Inertia::render('projects/Create');
}
public function store(StoreProjectRequest $request): RedirectResponse
{
$project = Project::query()->create($request->validated());
Twyco marked this conversation as resolved
Review

Note Für später, hier könnte man einfaach Project::create machen oder nicht?

Note Für später, hier könnte man einfaach Project::create machen oder nicht?
return to_route('projects.show', $project);
}
public function show(Project $project): Response
{
return Inertia::render('projects/Show', [
'project' => $project,
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
protected function prepareForValidation(): void
{
if ($this->has('key')) {
$this->merge([
'key' => strtoupper((string) $this->string('key')->trim()),
]);
}
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'key' => [
'required',
'string',
'max:5',
'regex:/^[A-Z0-9]+$/',
Rule::unique('projects', 'key'),
],
'name' => ['required', 'string', 'max:255'],
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
#[Fillable(['name', 'key'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected function key(): Attribute
{
return Attribute::make(
set: fn (string $value): string => Str::upper(trim($value)),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Project>
*/
class ProjectFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => Str::title(fake()->unique()->words(asText: true)),
'key' => Str::upper(fake()->unique()->lexify('?????')),
];
}
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('key', 5)->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('projects');
}
};
+1
View File
@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->call([
ProjectSeeder::class,
UserSeeder::class,
]);
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Database\Seeders;
use App\Models\Project;
use Illuminate\Database\Seeder;
class ProjectSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
Project::factory()
->count(3)
->sequence(
['name' => 'Core Platform', 'key' => 'CORE'],
['name' => 'Authentication', 'key' => 'AUTH'],
['name' => 'Operations', 'key' => 'OPS'],
)
->create();
}
}
+13 -1
View File
@@ -1,6 +1,12 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Shield } from 'lucide-vue-next';
import {
BookOpen,
Folder,
FolderGit2,
LayoutGrid,
Shield,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
@@ -16,6 +22,7 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
@@ -28,6 +35,11 @@ const mainNavItems = computed<NavItem[]>(() => {
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projects',
href: projectsIndex(),
icon: Folder,
},
];
if (page.props.auth.user.is_admin) {
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
Twyco marked this conversation as resolved
Review

import { store } from '@/actions/App/Http/Controllers/ProjectController'

import { store } from '@/actions/App/Http/Controllers/ProjectController'
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
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 { create, index } from '@/routes/projects';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: 'New project',
href: create(),
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="New project" />
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
<Heading
title="Create project"
description="Create a project with a stable uppercase key that will prefix all future ticket keys."
/>
<Card>
<CardHeader>
<CardTitle>Project details</CardTitle>
<CardDescription>
The key is uppercased automatically, must be unique, and
can contain up to 5 characters.
</CardDescription>
</CardHeader>
<CardContent>
<Form
v-bind="ProjectController.store.form()"
Twyco marked this conversation as resolved
Review

dann :action="store()"

dann :action="store()"
class="space-y-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="name">Project name</Label>
<Input
id="name"
name="name"
required
autofocus
placeholder="Kanban foundation"
/>
<InputError :message="errors.name" />
</div>
<div class="grid gap-2">
<Label for="key">Project key</Label>
<Input
id="key"
name="key"
required
maxlength="5"
placeholder="CORE"
class="uppercase"
/>
<p class="text-sm text-muted-foreground">
Example ticket keys will look like
<span class="font-medium text-foreground"
>CORE-1</span
>.
</p>
<InputError :message="errors.key" />
</div>
<div class="flex items-center gap-3">
<Button type="submit" :disabled="processing">
Create project
</Button>
<Button as-child variant="outline">
<Link :href="index()">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
</AppLayout>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Folder, Plus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Projects" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Projects"
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<Button as-child>
<Link :href="create()">
<Plus class="size-4" />
New project
</Link>
</Button>
</div>
<div
v-if="projects.length === 0"
class="rounded-xl border border-dashed px-6 py-10 text-center text-sm text-muted-foreground"
>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
<Card
class="h-full border-border/80 transition-colors group-hover:border-primary/40"
>
<CardHeader class="gap-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div>
<Badge variant="outline" class="px-2.5 py-1">
{{ project.key }}
</Badge>
</div>
</CardHeader>
<CardContent
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
</CardContent>
</Card>
</Link>
</div>
</div>
</AppLayout>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
];
const projectAreas = [
{
title: 'Backlog',
description:
'This area will receive new tickets first and act as the intake queue for project work.',
icon: ListTodo,
},
{
title: 'Board',
description:
'This area will become the configurable Kanban board with project-specific columns.',
icon: LayoutGrid,
},
{
title: 'Project settings',
description:
'This area will host project-level configuration such as ticket metadata and workflow options.',
icon: Settings2,
},
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
</div>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="area in projectAreas"
:key="area.title"
class="h-full"
>
<CardHeader>
<div
class="mb-3 flex size-10 items-center justify-center rounded-full bg-muted text-foreground"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Coming next in the project workflow implementation.
</CardContent>
</Card>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,8 @@
export type ProjectModel = {
id: number;
name: string;
key: string;
created_at: string;
updated_at: string;
[key: string]: unknown;
};
+9
View File
@@ -1,6 +1,15 @@
import type { ProjectModel } from '@/types/models/project-model';
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
users: UserModel[];
status?: string | null;
};
export type ProjectsIndexPageProps = {
projects: ProjectModel[];
};
export type ProjectShowPageProps = {
project: ProjectModel;
};
+4
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Admin\UserManagementController;
use App\Http\Controllers\ProjectController;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
@@ -10,6 +11,9 @@ Route::inertia('/', 'Welcome', [
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::resource('projects', ProjectController::class)
Twyco marked this conversation as resolved
Review

Note:
"Once created, the project key is not editable through the UI or standard update flow."
Gilt das gleiche auch fürs project selber
Sollte auch deletion beinhalten

Note: "Once created, the project key is not editable through the UI or standard update flow." Gilt das gleiche auch fürs project selber Sollte auch deletion beinhalten
->only(['index', 'create', 'store', 'show']);
});
Route::middleware(['auth', 'verified', 'can:access-admin'])
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\Models;
use App\Models\Project;
use Database\Seeders\ProjectSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure project keys are always stored in uppercase.
*/
public function test_project_keys_are_stored_in_uppercase(): void
{
$project = Project::factory()->create([
'key' => 'core',
]);
$this->assertSame('CORE', $project->fresh()->key);
}
/**
* Ensure the project seeder creates the expected starter projects.
*/
public function test_project_seeder_creates_projects(): void
{
$this->seed(ProjectSeeder::class);
$this->assertDatabaseHas('projects', [
'name' => 'Core Platform',
'key' => 'CORE',
]);
$this->assertDatabaseHas('projects', [
'name' => 'Authentication',
'key' => 'AUTH',
]);
$this->assertDatabaseHas('projects', [
'name' => 'Operations',
'key' => 'OPS',
]);
}
}
@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature\Projects;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure guests cannot access authenticated project pages.
*/
public function test_guests_are_redirected_from_project_pages(): void
{
$this->get(route('projects.index'))
->assertRedirect(route('login'));
$this->get(route('projects.create'))
->assertRedirect(route('login'));
}
/**
* Ensure authenticated users can view the projects index.
*/
public function test_authenticated_users_can_view_the_projects_index(): void
{
$user = User::factory()->create();
$firstProject = Project::factory()->create([
'name' => 'Core Platform',
'key' => 'CORE',
]);
$secondProject = Project::factory()->create([
'name' => 'Authentication',
'key' => 'AUTH',
]);
$this->actingAs($user)
->get(route('projects.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Index')
->has('projects', 2)
->where('projects.0.id', $secondProject->id)
->where('projects.1.id', $firstProject->id),
);
}
/**
* Ensure authenticated users can view the project creation screen.
*/
public function test_authenticated_users_can_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('projects.create'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Create'),
);
}
/**
* Ensure project keys are normalized to uppercase on creation.
*/
public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post(route('projects.store'), [
'name' => 'Kanban Foundation',
'key' => 'core',
]);
$project = Project::query()->firstOrFail();
$response->assertRedirect(route('projects.show', $project));
$this->assertDatabaseHas('projects', [
'name' => 'Kanban Foundation',
'key' => 'CORE',
]);
}
/**
* Ensure project keys remain unique after normalization.
*/
public function test_project_keys_must_be_unique_case_insensitively(): void
{
$user = User::factory()->create();
Project::factory()->create([
'key' => 'AUTH',
]);
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Another Auth Project',
'key' => 'auth',
])
->assertSessionHasErrors('key')
->assertRedirect(route('projects.create'));
}
/**
* Ensure project keys cannot exceed five characters.
*/
public function test_project_keys_may_not_exceed_five_characters(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->from(route('projects.create'))
->post(route('projects.store'), [
'name' => 'Too Long',
'key' => 'KANBAN',
])
->assertSessionHasErrors('key')
->assertRedirect(route('projects.create'));
}
/**
* Ensure authenticated users can view the project hub page.
*/
public function test_authenticated_users_can_view_a_project_hub_page(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
'name' => 'Core Platform',
'key' => 'CORE',
]);
$this->actingAs($user)
->get(route('projects.show', $project))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Show')
->where('project.id', $project->id)
->where('project.key', 'CORE'),
);
}
}