Implement projects and project keys #5
@@ -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
|
||||
->orderByDesc('id')
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
Eric marked this conversation as resolved
Eric
commented
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
Eric
commented
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
$this->call([
|
||||
ProjectSeeder::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">
|
||||
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) {
|
||||
|
||||
@@ -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
Eric
commented
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
Eric
commented
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>
|
||||
@@ -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';
|
||||
|
||||
export type AdminUsersPageProps = {
|
||||
users: UserModel[];
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
};
|
||||
|
||||
export type ProjectShowPageProps = {
|
||||
project: ProjectModel;
|
||||
};
|
||||
|
||||
@@ -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
Eric
commented
Note: 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'])
|
||||
|
||||
@@ -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
Project::latest('id')->get()