Implement projects and project keys
This commit is contained in:
@@ -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()
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('projects/Create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProjectRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$project = Project::query()->create($request->validated());
|
||||||
|
|
||||||
|
return to_route('projects.show', $project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Project $project): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('projects/Show', [
|
||||||
|
'project' => $project,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
|
ProjectSeeder::class,
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
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 { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
@@ -16,6 +22,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
|
import { index as projectsIndex } from '@/routes/projects';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
@@ -28,6 +35,11 @@ const mainNavItems = computed<NavItem[]>(() => {
|
|||||||
href: dashboard(),
|
href: dashboard(),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Projects',
|
||||||
|
href: projectsIndex(),
|
||||||
|
icon: Folder,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (page.props.auth.user.is_admin) {
|
if (page.props.auth.user.is_admin) {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
|
import ProjectController 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()"
|
||||||
|
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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
import type { UserModel } from '@/types/models/user-model';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
export type AdminUsersPageProps = {
|
export type AdminUsersPageProps = {
|
||||||
users: UserModel[];
|
users: UserModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectsIndexPageProps = {
|
||||||
|
projects: ProjectModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectShowPageProps = {
|
||||||
|
project: ProjectModel;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\UserManagementController;
|
use App\Http\Controllers\Admin\UserManagementController;
|
||||||
|
use App\Http\Controllers\ProjectController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ Route::inertia('/', 'Welcome', [
|
|||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||||
|
|
||||||
|
Route::resource('projects', ProjectController::class)
|
||||||
|
->only(['index', 'create', 'store', 'show']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
Route::middleware(['auth', 'verified', 'can:access-admin'])
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user