WIP: Redesign app layout chrome #105

Closed
Twyco wants to merge 28 commits from codex/app-layout-redesign into main
158 changed files with 11110 additions and 3465 deletions
@@ -2,6 +2,8 @@
namespace App\Actions\Notifications;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Enums\DiscordAllowedMentionType;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
{
public function __construct(
private readonly ListProjectNotificationActions $listProjectNotificationActions,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
]),
'templateVariables' => $this->templateVariables(),
];
}
@@ -35,6 +35,7 @@ class BuildProjectBacklogPayload
)->resolve(),
'canCreateTickets' => $canManageTickets,
'canEditTickets' => $canManageTickets,
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'ticketStatusOptions' => TicketStatusResource::collection(
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses',
'tickets' => fn ($query) => $query
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -39,6 +40,7 @@ class BuildProjectBoardPayload
)->resolve(),
'canCreateTickets' => $canManageTickets,
'canEditTickets' => $canManageTickets,
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
];
@@ -9,6 +9,10 @@ use App\Models\Project;
class BuildProjectBoardSettingsPayload
{
public function __construct(
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
]),
'ticketStatuses' => TicketStatusResource::collection(
$project->ticketStatuses()
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
@@ -0,0 +1,111 @@
<?php
namespace App\Actions\Projects;
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
use App\Actions\Permissions\PermissionService;
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
use App\Enums\ProjectSettingsSection;
use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Arr;
class BuildProjectSettingsPayload
{
public function __construct(
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
public function handle(
Project $project,
User $user,
ProjectSettingsSection $section,
mixed $status = null,
mixed $error = null,
): array {
return [
'activeSection' => $section->value,
'error' => $error,
'project' => ProjectResource::make($project)->resolve(),
'sectionData' => $this->sectionData($project, $user, $section),
'status' => $status,
];
}
/**
* @return array<string, mixed>
*/
private function sectionData(
Project $project,
User $user,
ProjectSettingsSection $section,
): array {
$payload = match ($section) {
ProjectSettingsSection::General => $this->generalData($project),
ProjectSettingsSection::Board => $this->buildProjectBoardSettingsPayload->handle($project),
ProjectSettingsSection::Metadata => $this->buildProjectTicketMetadataSettingsPayload->handle($project),
ProjectSettingsSection::Repositories => $this->buildProjectRepositorySettingsPayload->handle($project, $user),
ProjectSettingsSection::Notifications => $this->buildProjectNotificationSettingsPayload->handle($project),
ProjectSettingsSection::Access => $this->accessData($project),
};
return Arr::except($payload, ['error', 'project', 'status']);
}
/**
* @return array<string, mixed>
*/
private function generalData(Project $project): array
{
return [
'sourceTemplates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get(),
)->resolve(),
'templateState' => $this->resolveProjectTemplateState->handle($project),
];
}
/**
* @return array<string, mixed>
*/
private function accessData(Project $project): array
{
return [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
];
}
}
@@ -47,6 +47,7 @@ class BuildProjectTicketDetailPayload
'fieldValues.userValue',
'parentTicket',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
'ticketType',
'worklogEntries.actor',
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
class BuildProjectTicketMetadataSettingsPayload
{
public function __construct(
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
'project' => ProjectResource::make($project)->resolve(),
'error' => $error,
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
use App\Http\Resources\TicketReferenceResource;
use App\Http\Resources\TicketStatusResource;
use App\Http\Resources\TicketTypeResource;
use App\Models\Ticket;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
class BuildProjectTicketOptionsPayload
+60 -5
View File
@@ -6,25 +6,80 @@ use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProject
{
public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly PermissionService $permissionService,
) {}
/**
* @param array{key: string, name: string} $attributes
* @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array<int, string>|null} $attributes
*/
public function handle(array $attributes, User $actor): Project
{
$project = Project::query()->create($attributes);
return DB::transaction(function () use ($attributes, $actor): Project {
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
$baseTemplate = null;
$sourceTemplate = null;
$this->initializeProjectTicketMetadata->handle($project);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query()
->published()
->findOrFail((int) $sourceTemplateId);
return $project;
$project->forceFill([
'project_template_id' => $sourceTemplate->id,
])->save();
} else {
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
$project = $project->fresh() ?? $project;
$this->initializeProjectTicketMetadata->handle($project);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
$sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id'));
$this->duplicateProjectConfiguration->handle(
$sourceProject,
$project,
array_values(Arr::get($attributes, 'copy_areas', [])),
);
}
if ($creationMode === 'template' && $sourceTemplate !== null) {
$this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [],
);
}
if ($baseTemplate !== null) {
$baseTemplate->forceFill([
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
$project->fresh() ?? $project,
DuplicateProjectConfiguration::copyableAreas(),
),
])->save();
}
return $project->fresh() ?? $project;
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
class CreateProjectBaseTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(Project $project): ProjectTemplate
{
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
return ProjectTemplate::query()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
class CreateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
{
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
}
}
@@ -0,0 +1,400 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class DuplicateProjectConfiguration
{
public const COPY_PROJECT_SETTINGS = 'project_settings';
public const COPY_BOARD_WORKFLOW = 'board_workflow';
public const COPY_TICKET_TYPES = 'ticket_types';
public const COPY_TICKET_PRIORITIES = 'ticket_priorities';
public const COPY_TICKET_FIELDS = 'ticket_fields';
public const COPY_TICKET_LINK_TYPES = 'ticket_link_types';
public const COPY_REPOSITORY_CONNECTIONS = 'repository_connections';
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
/**
* @return list<string>
*/
public static function copyableAreas(): array
{
return [
self::COPY_PROJECT_SETTINGS,
self::COPY_BOARD_WORKFLOW,
self::COPY_TICKET_TYPES,
self::COPY_TICKET_PRIORITIES,
self::COPY_TICKET_FIELDS,
self::COPY_TICKET_LINK_TYPES,
self::COPY_REPOSITORY_CONNECTIONS,
self::COPY_NOTIFICATION_ACTIONS,
];
}
/**
* @return list<array{value: string, label: string, description: string}>
*/
public static function copyAreaOptions(): array
{
return [
[
'value' => self::COPY_PROJECT_SETTINGS,
'label' => 'Project settings',
'description' => 'Copy general project preferences like story points and author visibility.',
],
[
'value' => self::COPY_BOARD_WORKFLOW,
'label' => 'Board workflow',
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
],
[
'value' => self::COPY_TICKET_TYPES,
'label' => 'Ticket types',
'description' => 'Copy available ticket types, including parent-ticket types.',
],
[
'value' => self::COPY_TICKET_PRIORITIES,
'label' => 'Ticket priorities',
'description' => 'Copy the project-specific ticket priorities.',
],
[
'value' => self::COPY_TICKET_FIELDS,
'label' => 'Custom ticket fields',
'description' => 'Copy custom ticket field definitions without any ticket values.',
],
[
'value' => self::COPY_TICKET_LINK_TYPES,
'label' => 'Ticket link types',
'description' => 'Copy available relationship labels between tickets.',
],
[
'value' => self::COPY_REPOSITORY_CONNECTIONS,
'label' => 'Repository connections',
'description' => 'Copy linked repositories without changing the new project identity.',
],
[
'value' => self::COPY_NOTIFICATION_ACTIONS,
'label' => 'Notification actions',
'description' => 'Copy notification rules and delivery settings.',
],
];
}
/**
* @param list<string> $copyAreas
* @return list<string>
*/
public function normalizeCopyAreas(array $copyAreas): array
{
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
}
/**
* @param list<string> $copyAreas
* @return array<string, mixed>
*/
public function export(Project $sourceProject, array $copyAreas): array
{
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
if ($selectedAreas === []) {
return [];
}
$sourceProject->loadMissing([
'boardColumns',
'notificationActions',
'repositoryConnections',
'ticketFieldDefinitions',
'ticketLinkTypes',
'ticketPriorities',
'ticketStatuses.allowedTargetStatuses',
'ticketTypes',
]);
$configuration = [];
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
$configuration[self::COPY_PROJECT_SETTINGS] = [
'show_author' => $sourceProject->show_author,
'show_story_points' => $sourceProject->show_story_points,
];
}
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
$boardColumnIndexes = [];
$boardColumns = [];
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
$boardColumnIndexes[$boardColumn->id] = $index;
$boardColumns[] = [
'name' => $boardColumn->name,
'position' => $boardColumn->position,
];
}
$ticketStatusIndexes = [];
$ticketStatuses = [];
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatusIndexes[$ticketStatus->id] = $index;
$ticketStatuses[] = [
'board_column_index' => $ticketStatus->board_column_id === null
? null
: ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null),
'name' => $ticketStatus->name,
'position' => $ticketStatus->position,
'allowed_target_indexes' => [],
];
}
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
->pluck('id')
->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null)
->filter(static fn (?int $statusIndex): bool => $statusIndex !== null)
->values()
->all();
}
$configuration[self::COPY_BOARD_WORKFLOW] = [
'board_columns' => $boardColumns,
'ticket_statuses' => $ticketStatuses,
];
}
if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes
->map(fn ($ticketType): array => [
'name' => $ticketType->name,
'position' => $ticketType->position,
'is_parent' => $ticketType->is_parent,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities
->map(fn ($ticketPriority): array => [
'name' => $ticketPriority->name,
'position' => $ticketPriority->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions
->map(fn ($ticketFieldDefinition): array => [
'field_type' => $ticketFieldDefinition->field_type,
'label' => $ticketFieldDefinition->label,
'position' => $ticketFieldDefinition->position,
])
->values()
->all();
}
if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes
->map(fn ($ticketLinkType): array => [
'label' => $ticketLinkType->label,
'counterpart_label' => $ticketLinkType->counterpart_label,
'position' => $ticketLinkType->position,
])
->values()
->all();
}
if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) {
$configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections
->map(fn ($repositoryConnection): array => [
'provider' => $repositoryConnection->provider->value,
'base_url' => $repositoryConnection->base_url,
'provider_repository_id' => $repositoryConnection->provider_repository_id,
'owner_name' => $repositoryConnection->owner_name,
'repository_name' => $repositoryConnection->repository_name,
'repository_full_name' => $repositoryConnection->repository_full_name,
'repository_url' => $repositoryConnection->repository_url,
])
->values()
->all();
}
if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) {
$configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions
->map(fn ($notificationAction): array => [
'name' => $notificationAction->name,
'enabled' => $notificationAction->enabled,
'event_type' => $notificationAction->event_type->value,
'channel' => $notificationAction->channel->value,
'channel_configuration' => $notificationAction->channel_configuration ?? [],
'message_template' => $notificationAction->message_template,
'delivery_url' => $notificationAction->delivery_url,
'filters' => $notificationAction->filters ?? [],
])
->values()
->all();
}
return $configuration;
}
/**
* @param array<string, mixed> $configuration
*/
public function apply(Project $targetProject, array $configuration): void
{
if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) {
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
$targetProject->forceFill([
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
])->save();
}
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
$targetProject->ticketStatuses()->delete();
$targetProject->boardColumns()->delete();
$boardColumnIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
$newBoardColumn = $targetProject->boardColumns()->create([
'name' => $boardColumn['name'],
'position' => $boardColumn['position'],
]);
$boardColumnIds[] = $newBoardColumn->id;
}
$ticketStatusIds = [];
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
$newTicketStatus = $targetProject->ticketStatuses()->create([
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
'name' => $ticketStatus['name'],
'position' => $ticketStatus['position'],
]);
$ticketStatusIds[] = $newTicketStatus->id;
}
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
if ($newTicketStatusId === null) {
continue;
}
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
->allowedTargetStatuses()
->sync(
collect($ticketStatus['allowed_target_indexes'] ?? [])
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
->filter(static fn (?int $statusId): bool => $statusId !== null)
->values()
->all(),
);
}
}
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
$targetProject->ticketTypes()->delete();
foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) {
$targetProject->ticketTypes()->create([
'name' => $ticketType['name'],
'position' => $ticketType['position'],
'is_parent' => $ticketType['is_parent'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) {
$targetProject->ticketPriorities()->delete();
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
$targetProject->ticketPriorities()->create([
'name' => $ticketPriority['name'],
'position' => $ticketPriority['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
$targetProject->ticketFieldDefinitions()->delete();
foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) {
$targetProject->ticketFieldDefinitions()->create([
'field_type' => $ticketFieldDefinition['field_type'],
'label' => $ticketFieldDefinition['label'],
'position' => $ticketFieldDefinition['position'],
]);
}
}
if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) {
$targetProject->ticketLinkTypes()->delete();
foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) {
$targetProject->ticketLinkTypes()->create([
'label' => $ticketLinkType['label'],
'counterpart_label' => $ticketLinkType['counterpart_label'],
'position' => $ticketLinkType['position'],
]);
}
}
if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) {
$targetProject->repositoryConnections()->delete();
foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) {
$targetProject->repositoryConnections()->create([
'provider' => $repositoryConnection['provider'],
'base_url' => $repositoryConnection['base_url'],
'provider_repository_id' => $repositoryConnection['provider_repository_id'],
'owner_name' => $repositoryConnection['owner_name'],
'repository_name' => $repositoryConnection['repository_name'],
'repository_full_name' => $repositoryConnection['repository_full_name'],
'repository_url' => $repositoryConnection['repository_url'],
]);
}
}
if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) {
$targetProject->notificationActions()->delete();
foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) {
$targetProject->notificationActions()->create([
'name' => $notificationAction['name'],
'enabled' => $notificationAction['enabled'],
'event_type' => $notificationAction['event_type'],
'channel' => $notificationAction['channel'],
'channel_configuration' => $notificationAction['channel_configuration'] ?? [],
'message_template' => $notificationAction['message_template'] ?? null,
'delivery_url' => $notificationAction['delivery_url'],
'filters' => $notificationAction['filters'] ?? [],
]);
}
}
}
/**
* @param list<string> $copyAreas
*/
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
class ResolveProjectTemplateState
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param list<string>|null $areas
* @return array<string, mixed>|null
*/
public function handle(Project $project, ?array $areas = null): ?array
{
$project->loadMissing('projectTemplate');
/** @var ProjectTemplate|null $projectTemplate */
$projectTemplate = $project->projectTemplate;
if ($projectTemplate === null) {
return null;
}
$selectedAreas = $areas === null
? DuplicateProjectConfiguration::copyableAreas()
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
->only($selectedAreas)
->all();
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
$overriddenAreas = [];
$inheritedAreas = [];
foreach ($selectedAreas as $area) {
$label = $this->labelForArea($area);
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
if (! $hasTemplateValue && ! $hasCurrentValue) {
continue;
}
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
$inheritedAreas[] = [
'label' => $label,
'value' => $area,
];
continue;
}
$overriddenAreas[] = [
'label' => $label,
'value' => $area,
];
}
$overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value'));
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published,
'overriddenAreas' => $overriddenAreas,
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
'templateConfiguration' => $templateConfiguration,
'templateId' => $projectTemplate->id,
'templateName' => $projectTemplate->name,
'templateSlug' => $projectTemplate->slug,
];
}
private function labelForArea(string $area): string
{
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
if ($option['value'] === $area) {
return $option['label'];
}
}
return $area;
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Actions\Projects;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param array{name: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
$sourceProject = $projectTemplate->sourceProject;
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
$previousConfiguration = $projectTemplate->configuration_snapshot ?? [];
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = array_values(array_unique([
...array_keys($previousConfiguration),
...array_keys($nextConfiguration),
]));
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
$projectTemplate->forceFill([
'name' => $attributes['name'],
'copy_areas' => $normalizedCopyAreas,
'configuration_snapshot' => $nextConfiguration,
])->save();
foreach ($projectTemplate->linkedProjects as $linkedProject) {
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas);
$replacementConfiguration = [];
foreach ($nextConfiguration as $area => $configuration) {
$isNewTemplateArea = ! array_key_exists($area, $previousConfiguration);
$matchesPreviousTemplate = array_key_exists($area, $previousConfiguration)
&& array_key_exists($area, $currentConfiguration)
&& $currentConfiguration[$area] === $previousConfiguration[$area];
if ($isNewTemplateArea || $matchesPreviousTemplate) {
$replacementConfiguration[$area] = $configuration;
}
}
if ($replacementConfiguration === []) {
continue;
}
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
}
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplateLink
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$project->forceFill([
'project_template_id' => $template->id,
])->save();
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
public function reset(Project $project): void
{
$template = $project->projectTemplate;
if ($template === null) {
return;
}
DB::transaction(function () use ($project, $template): void {
$this->duplicateProjectConfiguration->apply(
$project->fresh(),
$template->configuration_snapshot ?? [],
);
});
}
}
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderManager;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Http\Resources\AvailableGitRepositorySourceResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\RepositoryConnectionResource;
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
'project' => ProjectResource::make($project)->resolve(),
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
]),
];
}
+7 -3
View File
@@ -47,9 +47,13 @@ class CreateProjectTicket
$nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id)
->max('sequence_number') + 1;
$ticketStatus = TicketStatus::query()
->where('project_id', $project->id)
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticketStatusQuery = TicketStatus::query()
->where(
$project->project_template_id === null ? 'project_id' : 'project_template_id',
$project->project_template_id ?? $project->id,
);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum ProjectSettingsSection: string
{
case General = 'general';
case Board = 'board';
case Metadata = 'metadata';
case Repositories = 'repositories';
case Notifications = 'notifications';
case Access = 'access';
}
@@ -7,8 +7,10 @@ use App\Actions\Projects\BuildProjectBoardPayload;
use App\Actions\Tickets\MoveProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\MoveProjectTicketRequest;
use App\Http\Resources\TicketStatusResource;
use App\Models\Project;
use App\Models\Ticket;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
@@ -42,7 +44,7 @@ class ProjectBoardController extends Controller
MoveProjectTicketRequest $request,
Project $project,
Ticket $ticket,
): RedirectResponse {
): RedirectResponse|JsonResponse {
$user = $request->user();
abort_unless(
@@ -56,13 +58,32 @@ class ProjectBoardController extends Controller
: 'projects.board.index';
$result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated);
$statusMessage = $result['moved']
? "{$ticket->ticket_key} moved to {$result['destination_label']}."
: "{$ticket->ticket_key} already belongs to {$result['destination_label']}.";
if ($request->expectsJson()) {
$ticket->refresh()->load([
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
]);
return response()->json([
'message' => $statusMessage,
'moved' => $result['moved'],
'ticket_status' => $ticket->ticketStatus === null
? null
: TicketStatusResource::make($ticket->ticketStatus)->resolve(),
'ticket_status_id' => $ticket->ticket_status_id,
]);
}
if (! $result['moved']) {
return to_route($redirectRoute, $project)
->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}.");
->with('status', $statusMessage);
}
return to_route($redirectRoute, $project)
->with('status', "{$ticket->ticket_key} moved to {$result['destination_label']}.");
->with('status', $statusMessage);
}
}
@@ -10,8 +10,8 @@ use App\Actions\ProjectBoard\StoreTicketStatus;
use App\Actions\ProjectBoard\UpdateBoardColumn;
use App\Actions\ProjectBoard\UpdateTicketStatus;
use App\Actions\ProjectBoard\UpdateTicketStatusTransitions;
use App\Actions\Projects\BuildProjectBoardSettingsPayload;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreBoardColumnRequest;
use App\Http\Requests\StoreTicketStatusRequest;
use App\Http\Requests\UpdateBoardColumnRequest;
@@ -22,13 +22,10 @@ use App\Models\Project;
use App\Models\TicketStatus;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectBoardSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
private readonly DeleteBoardColumn $deleteBoardColumn,
private readonly DeleteTicketStatus $deleteTicketStatus,
private readonly PermissionService $permissionService,
@@ -39,19 +36,6 @@ class ProjectBoardSettingsController extends Controller
private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions,
) {}
public function show(Request $request, Project $project): Response
{
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
return Inertia::render(
'projects/settings/Board',
$this->buildProjectBoardSettingsPayload->handle($project, session('status')),
);
}
public function store(
StoreBoardColumnRequest $request,
Project $project,
@@ -62,7 +46,7 @@ class ProjectBoardSettingsController extends Controller
);
$boardColumn = $this->storeBoardColumn->handle($project, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$boardColumn->name} was added to the board workflow.");
}
@@ -77,7 +61,7 @@ class ProjectBoardSettingsController extends Controller
);
$boardColumn = $this->updateBoardColumn->handle($project, $boardColumn, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$boardColumn->fresh()->name} was updated.");
}
@@ -93,7 +77,7 @@ class ProjectBoardSettingsController extends Controller
$deletedName = $this->deleteBoardColumn->handle($project, $boardColumn);
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedName} was removed from the board workflow.");
}
@@ -107,7 +91,7 @@ class ProjectBoardSettingsController extends Controller
);
$ticketStatus = $this->storeTicketStatus->handle($project, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->name} was added to {$this->statusContextLabel($ticketStatus)}.");
}
@@ -122,7 +106,7 @@ class ProjectBoardSettingsController extends Controller
);
$ticketStatus = $this->updateTicketStatus->handle($ticketStatus, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->fresh()->name} was updated.");
}
@@ -138,7 +122,7 @@ class ProjectBoardSettingsController extends Controller
$result = $this->deleteTicketStatus->handle($project, $ticketStatus);
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$result['deleted_name']} was removed from {$result['context_label']}.");
}
@@ -153,7 +137,7 @@ class ProjectBoardSettingsController extends Controller
);
$this->updateTicketStatusTransitions->handle($ticketStatus, $request->validated());
return to_route('projects.settings.board', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketStatus->name} move targets were updated.");
}
@@ -161,4 +145,12 @@ class ProjectBoardSettingsController extends Controller
{
return $ticketStatus->boardColumn?->name ?? 'Backlog';
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Board->value,
]);
}
}
+62 -43
View File
@@ -6,16 +6,20 @@ use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectBacklogPayload;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DeleteProject;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ListVisibleProjects;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\ResetProjectTemplateRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SwitchProjectTemplateRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
@@ -32,6 +36,7 @@ class ProjectController extends Controller
private readonly DeleteProject $deleteProject,
private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService,
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
) {}
public function index(Request $request): Response
@@ -51,17 +56,33 @@ class ProjectController extends Controller
403,
);
return Inertia::render('projects/Create');
$sourceTemplates = ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get();
$selectedTemplateId = $request->integer('template');
return Inertia::render('projects/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'sourceProjects' => ProjectResource::collection(
$this->listVisibleProjects->handle($request->user()),
)->resolve(),
'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(),
'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId)
? $selectedTemplateId
: null,
]);
}
public function store(StoreProjectRequest $request): RedirectResponse
{
$project = $this->createProject->handle($request->validated(), $request->user());
return to_route('projects.show', $project);
return to_route('projects.board.index', $project);
}
public function show(Request $request, Project $project): Response
public function show(Request $request, Project $project): RedirectResponse
{
$user = $request->user();
@@ -70,10 +91,7 @@ class ProjectController extends Controller
403,
);
return Inertia::render('projects/Show', [
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
]);
return to_route('projects.board.index', $project);
}
public function backlog(Request $request, Project $project): Response
@@ -91,42 +109,37 @@ class ProjectController extends Controller
);
}
public function general(Request $request, Project $project): Response
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
return Inertia::render('projects/settings/General', [
'project' => ProjectResource::make($project)->resolve(),
'status' => session('status'),
]);
$validated = $request->validated();
$template = ProjectTemplate::query()
->published()
->findOrFail((int) Arr::get($validated, 'source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::General->value,
])
->with('status', "{$project->name} now uses the {$template->name} template.");
}
public function access(Request $request, Project $project): Response
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
$request->validated();
return Inertia::render('projects/Access', [
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => PermissionOptionResource::collection(
$this->permissionService->permissionOptions(User::class, Project::class),
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => session('status'),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => TeamResource::collection(
Team::query()
->with('users')
->orderBy('name')
->get(),
)->resolve(),
'users' => UserResource::collection(
User::query()
->orderBy('name')
->get(),
)->resolve(),
]);
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::General->value,
])
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
}
public function updateUserPermissions(
@@ -145,7 +158,10 @@ class ProjectController extends Controller
$this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Access->value,
])
->with('status', "{$user->name} permissions were updated.");
}
@@ -164,7 +180,10 @@ class ProjectController extends Controller
$this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project)
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Access->value,
])
->with('status', "{$team->name} permissions were updated.");
}
@@ -2,41 +2,28 @@
namespace App\Http\Controllers;
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
use App\Actions\Notifications\DeleteProjectNotificationAction;
use App\Actions\Notifications\StoreProjectNotificationAction;
use App\Actions\Notifications\UpdateProjectNotificationAction;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreProjectNotificationActionRequest;
use App\Http\Requests\UpdateProjectNotificationActionRequest;
use App\Models\NotificationAction;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectNotificationSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
private readonly PermissionService $permissionService,
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
private readonly UpdateProjectNotificationAction $updateProjectNotificationAction,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
return Inertia::render(
'projects/settings/Notifications',
$this->buildProjectNotificationSettingsPayload->handle($project, session('status')),
);
}
public function store(
StoreProjectNotificationActionRequest $request,
Project $project,
@@ -45,7 +32,7 @@ class ProjectNotificationSettingsController extends Controller
$notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated());
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$notificationAction->name} notification was added.");
}
@@ -62,7 +49,7 @@ class ProjectNotificationSettingsController extends Controller
$request->validated(),
);
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$notificationAction->name} notification was updated.");
}
@@ -80,7 +67,7 @@ class ProjectNotificationSettingsController extends Controller
$this->deleteProjectNotificationAction->handle($project, $notificationAction);
return to_route('projects.settings.notifications', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedName} notification was deleted.");
}
@@ -91,4 +78,12 @@ class ProjectNotificationSettingsController extends Controller
403,
);
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Notifications->value,
]);
}
}
@@ -3,44 +3,27 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
use App\Actions\Repositories\DeleteProjectRepositoryConnection;
use App\Actions\Repositories\StoreProjectRepositoryConnection;
use App\Actions\Repositories\UpdateProjectRepositoryConnection;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\StoreRepositoryConnectionRequest;
use App\Http\Requests\UpdateRepositoryConnectionRequest;
use App\Models\Project;
use App\Models\RepositoryConnection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectRepositorySettingsController extends Controller
{
public function __construct(
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection,
private readonly PermissionService $permissionService,
private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection,
private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection,
) {}
public function show(Request $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
return Inertia::render(
'projects/settings/Repositories',
$this->buildProjectRepositorySettingsPayload->handle($project, $user, session('status')),
);
}
public function store(
StoreRepositoryConnectionRequest $request,
Project $project,
@@ -53,7 +36,7 @@ class ProjectRepositorySettingsController extends Controller
$repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated());
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}.");
}
@@ -76,7 +59,7 @@ class ProjectRepositorySettingsController extends Controller
$request->validated(),
);
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
}
@@ -92,7 +75,15 @@ class ProjectRepositorySettingsController extends Controller
$repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection);
return to_route('projects.settings.repositories', $project)
return $this->settingsRedirect($project)
->with('status', "{$repositoryLabel} was removed from the project.");
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Repositories->value,
]);
}
}
@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectSettingsPayload;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\ProjectSettingsRequest;
use App\Models\Project;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;
use Inertia\Response;
class ProjectSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectSettingsPayload $buildProjectSettingsPayload,
private readonly PermissionService $permissionService,
) {}
public function index(ProjectSettingsRequest $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$section = ProjectSettingsSection::from(
(string) Arr::get($validated, 'section', ProjectSettingsSection::General->value),
);
return Inertia::render(
'projects/Settings',
$this->buildProjectSettingsPayload->handle(
$project,
$user,
$section,
session('status'),
session('error'),
),
);
}
public function general(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::General);
}
public function board(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Board);
}
public function metadata(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Metadata);
}
public function repositories(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Repositories);
}
public function notifications(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Notifications);
}
public function access(Request $request, Project $project): RedirectResponse
{
return $this->redirectToSection($request, $project, ProjectSettingsSection::Access);
}
private function redirectToSection(
Request $request,
Project $project,
ProjectSettingsSection $section,
): RedirectResponse {
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
return to_route('projects.settings.index', [
'project' => $project,
'section' => $section->value,
]);
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectTemplateController extends Controller
{
public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate,
) {}
public function index(Request $request): Response
{
return Inertia::render('project-templates/Index', [
'status' => session('status'),
'templates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->withCount('linkedProjects')
->orderBy('name')
->get(),
)->resolve(),
]);
}
public function edit(ProjectTemplate $projectTemplate): Response
{
abort_if(! $projectTemplate->is_published, 404);
$projectTemplate->load('sourceProject')->loadCount('linkedProjects');
return Inertia::render('project-templates/Edit', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'template' => ProjectTemplateResource::make($projectTemplate)->resolve(),
]);
}
public function create(Project $project): Response
{
return Inertia::render('project-templates/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'project' => ProjectResource::make($project)->resolve(),
]);
}
public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse
{
$template = $this->createProjectTemplate->handle($project, $request->validated());
return to_route('project-templates.index')
->with('status', "Template {$template->name} was created.");
}
public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse
{
abort_if(! $projectTemplate->is_published, 404);
$template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated());
return to_route('project-templates.index')
->with('status', "Template {$template->name} was updated.");
}
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
{
abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name;
$projectTemplate->delete();
return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted.");
}
}
@@ -6,6 +6,7 @@ use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectTicketDetailPayload;
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest;
@@ -40,11 +41,18 @@ class ProjectTicketController extends Controller
return Inertia::render(
'projects/TicketCreate',
$this->buildProjectTicketOptionsPayload->handle(
$project,
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
session('status'),
),
[
...$this->buildProjectTicketOptionsPayload->handle(
$project,
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
session('status'),
),
'canManageProject' => $this->permissionService->allows(
$user,
Permission::ProjectAdmin,
$project,
),
],
);
}
@@ -108,6 +116,9 @@ class ProjectTicketController extends Controller
if ($request->expectsJson()) {
return response()->json([
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
$ticket->acceptanceCriteria()->get(),
),
'message' => $statusMessage,
'updated' => $wasUpdated,
]);
@@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload;
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority;
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType;
use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition;
@@ -18,6 +17,7 @@ use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType;
use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition;
use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType;
use App\Enums\Permission;
use App\Enums\ProjectSettingsSection;
use App\Http\Requests\DestroyProjectTicketTypeRequest;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreProjectTicketTypeRequest;
@@ -35,13 +35,10 @@ use App\Models\TicketPriority;
use App\Models\TicketType;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectTicketMetadataSettingsController extends Controller
{
public function __construct(
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority,
private readonly DeleteProjectTicketType $deleteProjectTicketType,
private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition,
@@ -58,23 +55,13 @@ class ProjectTicketMetadataSettingsController extends Controller
private readonly UpdateTicketLinkType $updateTicketLinkType,
) {}
public function show(Request $request, Project $project): Response
{
$this->abortUnlessCanManage($request, $project);
return Inertia::render(
'projects/settings/Metadata',
$this->buildProjectTicketMetadataSettingsPayload->handle($project, session('status'), session('error')),
);
}
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
{
$this->abortUnlessCanManage($request, $project);
$this->updateProjectTicketMetadataSettings->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', 'Settings have been updated.');
}
@@ -84,7 +71,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketType = $this->storeProjectTicketType->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketType->name} type was added.");
}
@@ -97,7 +84,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketType->fresh()->name} type was updated.");
}
@@ -116,12 +103,12 @@ class ProjectTicketMetadataSettingsController extends Controller
);
if (! $result['deleted']) {
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('error', true)
->with('status', $result['message']);
}
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', $result['message']);
}
@@ -131,7 +118,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketPriority->name} priority was added.");
}
@@ -144,7 +131,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketPriority->fresh()->name} priority was updated.");
}
@@ -158,12 +145,12 @@ class ProjectTicketMetadataSettingsController extends Controller
$result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority);
if (! $result['deleted']) {
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('error', true)
->with('status', $result['message']);
}
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', $result['message']);
}
@@ -175,7 +162,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketFieldDefinition->label} field was added.");
}
@@ -188,7 +175,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated.");
}
@@ -201,7 +188,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition);
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedLabel} field was removed.");
}
@@ -211,7 +198,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketLinkType->label} link type was added.");
}
@@ -225,7 +212,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated());
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$ticketLinkType->fresh()->label} link type was updated.");
}
@@ -238,7 +225,7 @@ class ProjectTicketMetadataSettingsController extends Controller
$deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType);
return to_route('projects.settings.metadata', $project)
return $this->settingsRedirect($project)
->with('status', "{$deletedLabel} link type was removed.");
}
@@ -249,4 +236,12 @@ class ProjectTicketMetadataSettingsController extends Controller
403,
);
}
private function settingsRedirect(Project $project): RedirectResponse
{
return to_route('projects.settings.index', [
'project' => $project,
'section' => ProjectSettingsSection::Metadata->value,
]);
}
}
@@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
@@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'replacement_ticket_type_id' => [
'nullable',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
@@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
];
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Enums\ProjectSettingsSection;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
class ProjectSettingsRequest 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 [
'section' => ['required', Rule::enum(ProjectSettingsSection::class)],
];
}
protected function prepareForValidation(): void
{
$section = Arr::get($this->query(), 'section');
$this->merge([
'section' => is_string($section) && ProjectSettingsSection::tryFrom($section) !== null
? $section
: ProjectSettingsSection::General->value,
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ResetProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
];
}
}
+59
View File
@@ -2,6 +2,10 @@
namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
protected function prepareForValidation(): void
{
$key = Arr::get($this->all(), 'key');
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
if ($key !== null) {
$this->merge([
'key' => strtoupper(trim((string) $key)),
]);
}
$this->merge([
'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch',
'copy_areas' => is_array($copyAreas)
? array_values($copyAreas)
: ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]),
]);
}
/**
@@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest
public function rules(): array
{
return [
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
'key' => [
'required',
'string',
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
Rule::unique('projects', 'key'),
],
'name' => ['required', 'string', 'max:255'],
'source_project_id' => [
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'),
'nullable',
'integer',
Rule::exists('projects', 'id'),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === null || $value === '') {
return;
}
$project = Project::query()->find($value);
if ($project === null) {
return;
}
$permissionService = app(PermissionService::class);
if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) {
$fail('Select a project you can access as the duplication source.');
}
},
],
'source_template_id' => [
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
'nullable',
'integer',
Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
],
'copy_areas' => ['nullable', 'array'],
'copy_areas.*' => [
'string',
Rule::in(DuplicateProjectConfiguration::copyableAreas()),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'source_project_id.required' => 'Select a source project to duplicate from.',
'source_template_id.required' => 'Select a template to create the project from.',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StoreProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
}
protected function prepareForValidation(): void
{
if ($this->filled('slug')) {
$this->merge([
'slug' => Str::slug((string) $this->input('slug')),
]);
}
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
];
}
}
@@ -2,8 +2,10 @@
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
}
@@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'acceptance_criteria' => ['nullable', 'array'],
@@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'],
+6 -1
View File
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'linked_ticket_id' => [
@@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest
'ticket_link_type_id' => [
'required',
'integer',
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_link_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
@@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'board_column_id' => [
'nullable',
'integer',
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
Rule::exists('board_columns', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'name' => ['required', 'string', 'max:255'],
];
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SwitchProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
'source_template_id' => [
'required',
'integer',
Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true),
Rule::notIn(array_filter([$project->project_template_id])),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.',
'source_template_id.not_in' => 'Choose a different published template to switch this project.',
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var ProjectTemplate|null $projectTemplate */
$projectTemplate = $this->route('projectTemplate');
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'confirm_linked_projects_update' => [
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.',
];
}
}
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'acceptance_criteria' => ['nullable', 'array'],
@@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest
'field_values.*.ticket_field_definition_id' => [
'required',
'integer',
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
Rule::exists('ticket_field_definitions', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'field_values.*.value_number' => ['nullable', 'numeric'],
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
@@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'min:0'],
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$requestedTicketType = TicketType::query()
->where('project_id', $project->id)
->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
->find($requestedTicketTypeId);
if (
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
@@ -2,8 +2,10 @@
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ValidateProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
];
}
}
+24
View File
@@ -2,8 +2,10 @@
namespace App\Http\Resources;
use App\Actions\Projects\ResolveProjectTemplateState;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class ProjectResource extends JsonResource
{
@@ -14,10 +16,32 @@ class ProjectResource extends JsonResource
*/
public function toArray(Request $request): array
{
$this->resource->loadMissing('projectTemplate');
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
return [
'id' => $this->id,
'name' => $this->name,
'key' => $this->key,
'project_template_id' => $this->project_template_id,
'project_template' => $this->projectTemplate === null
? null
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
'template_state' => $templateState === null
? null
: [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
'template_id' => Arr::get($templateState, 'templateId'),
'template_name' => Arr::get($templateState, 'templateName'),
'template_slug' => Arr::get($templateState, 'templateSlug'),
],
'show_author' => $this->show_author,
'show_story_points' => $this->show_story_points,
'created_at' => $this->created_at?->toISOString(),
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Resources;
use App\Models\ProjectTemplate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class ProjectTemplateResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
if ($this->resource instanceof ProjectTemplate) {
return [
'id' => $this->id,
'project_id' => $this->project_id,
'source_project_id' => $this->source_project_id,
'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null
? ProjectResource::make($this->sourceProject)->resolve()
: null,
'name' => $this->name,
'slug' => $this->slug,
'is_published' => $this->is_published,
'copy_areas' => $this->copy_areas ?? [],
'configuration_snapshot' => $this->configuration_snapshot ?? [],
'linked_project_count' => $this->whenCounted('linkedProjects'),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
return [
'id' => Arr::get($this->resource, 'id'),
'project_id' => Arr::get($this->resource, 'project_id'),
'source_project_id' => Arr::get($this->resource, 'source_project_id'),
'source_project' => Arr::get($this->resource, 'source_project'),
'name' => Arr::get($this->resource, 'name'),
'slug' => Arr::get($this->resource, 'slug'),
'is_published' => Arr::get($this->resource, 'is_published', true),
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
'linked_project_count' => Arr::get($this->resource, 'linked_project_count'),
'created_at' => Arr::get($this->resource, 'created_at'),
'updated_at' => Arr::get($this->resource, 'updated_at'),
];
}
}
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\BoardColumnFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $boardColumn): void {
$boardColumn->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
@@ -0,0 +1,41 @@
<?php
namespace App\Models\Concerns;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait SyncsProjectTemplateOwnership
{
protected function syncProjectTemplateOwnership(): void
{
$projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id');
if ($projectTemplateId === null && $projectId !== null) {
$projectTemplateId = Project::query()
->whereKey($projectId)
->value('project_template_id');
if ($projectTemplateId !== null) {
$this->setAttribute('project_template_id', $projectTemplateId);
}
}
if ($projectId === null && $projectTemplateId !== null) {
$projectId = ProjectTemplate::query()
->whereKey($projectTemplateId)
->value('project_id');
if ($projectId !== null) {
$this->setAttribute('project_id', $projectId);
}
}
}
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class);
}
}
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\NotificationActionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'message_template',
'name',
'project_id',
'project_template_id',
])]
#[Hidden(['delivery_url'])]
class NotificationAction extends Model
{
/** @use HasFactory<NotificationActionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @return array<string, string>
@@ -45,6 +48,13 @@ class NotificationAction extends Model
];
}
protected static function booted(): void
{
static::saving(function (self $notificationAction): void {
$notificationAction->syncProjectTemplateOwnership();
});
}
public function scopeEnabled(Builder $query): Builder
{
return $query->where('enabled', true);
+14 -1
View File
@@ -7,11 +7,13 @@ 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\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
@@ -44,6 +46,17 @@ class Project extends Model
return $this->morphMany(PermissionAssignment::class, 'scope');
}
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
}
public function baseTemplate(): HasOne
{
return $this->hasOne(ProjectTemplate::class)
->where('is_published', false);
}
public function boardColumns(): HasMany
{
return $this->hasMany(BoardColumn::class)
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Database\Factories\ProjectTemplateFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
class ProjectTemplate extends Model
{
/** @use HasFactory<ProjectTemplateFactory> */
use HasFactory;
protected function casts(): array
{
return [
'copy_areas' => 'array',
'configuration_snapshot' => 'array',
'is_published' => 'boolean',
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function sourceProject(): BelongsTo
{
return $this->belongsTo(Project::class, 'source_project_id');
}
public function linkedProjects(): HasMany
{
return $this->hasMany(Project::class, 'project_template_id');
}
}
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\RepositoryConnectionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'base_url',
'owner_name',
'project_id',
'project_template_id',
'provider',
'provider_repository_id',
'repository_full_name',
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
{
/** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @return array<string, string>
@@ -35,6 +38,13 @@ class RepositoryConnection extends Model
];
}
protected static function booted(): void
{
static::saving(function (self $repositoryConnection): void {
$repositoryConnection->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model
{
/**
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketFieldDefinition): void {
$ticketFieldDefinition->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketLinkTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketLinkType): void {
$ticketLinkType->syncProjectTemplateOwnership();
});
}
public function counterpartLabel(): string
{
+10 -1
View File
@@ -2,17 +2,26 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketPriorityFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketPriority): void {
$ticketPriority->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketStatusFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketStatus): void {
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
$ticketStatus->project_template_id = BoardColumn::query()
->whereKey($ticketStatus->board_column_id)
->value('project_template_id');
}
$ticketStatus->syncProjectTemplateOwnership();
$ticketStatus->slug = Str::slug($ticketStatus->name);
});
}
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketType): void {
$ticketType->syncProjectTemplateOwnership();
});
}
protected function casts(): array
{
@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<ProjectTemplate>
*/
class ProjectTemplateFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'source_project_id' => Project::factory(),
'name' => Str::title(fake()->unique()->words(3, true)),
'slug' => fake()->unique()->slug(),
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
],
];
}
}
@@ -0,0 +1,34 @@
<?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('project_templates', function (Blueprint $table): void {
$table->id();
$table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->json('copy_areas');
$table->json('configuration_snapshot');
$table->timestamps();
$table->index(['source_project_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('project_templates');
}
};
@@ -0,0 +1,54 @@
<?php
use App\Actions\Projects\CreateProjectBaseTemplate;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('project_templates', function (Blueprint $table): void {
$table->foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete();
$table->boolean('is_published')->default(true)->after('slug');
});
Schema::table('projects', function (Blueprint $table): void {
$table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete();
});
$projects = Project::query()->whereNull('project_template_id')->get();
$createProjectBaseTemplate = app(CreateProjectBaseTemplate::class);
foreach ($projects as $project) {
$baseTemplate = $createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
ProjectTemplate::query()
->whereNull('project_id')
->update(['is_published' => true]);
}
public function down(): void
{
Project::query()->update(['project_template_id' => null]);
ProjectTemplate::query()->where('is_published', false)->delete();
Schema::table('projects', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
Schema::table('project_templates', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_id');
$table->dropColumn('is_published');
});
}
};
@@ -0,0 +1,116 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* @var list<string>
*/
private array $tables = [
'board_columns',
'ticket_field_definitions',
'ticket_link_types',
'ticket_priorities',
'ticket_statuses',
'ticket_types',
'repository_connections',
'notification_actions',
];
public function up(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->foreignId('project_template_id')
->nullable()
->after('project_id')
->constrained('project_templates')
->nullOnDelete();
});
}
$projects = DB::table('projects')
->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id')
->select([
'projects.id',
'projects.key',
'projects.name',
'projects.project_template_id',
'project_templates.project_id as template_project_id',
])
->get();
foreach ($projects as $project) {
$projectTemplateId = $project->project_template_id;
if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) {
$projectTemplateId = DB::table('project_templates')->insertGetId([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => $project->name.' base template',
'slug' => Str::lower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => json_encode([
'project_settings',
'board_workflow',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
'repository_connections',
'notification_actions',
], JSON_THROW_ON_ERROR),
'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('projects')
->where('id', $project->id)
->update([
'project_template_id' => $projectTemplateId,
'updated_at' => now(),
]);
}
foreach ($this->tables as $table) {
DB::table($table)
->where('project_id', $project->id)
->update(['project_template_id' => $projectTemplateId]);
}
$projectModel = Project::query()->find($project->id);
if ($projectModel !== null) {
DB::table('project_templates')
->where('id', $projectTemplateId)
->update([
'configuration_snapshot' => json_encode(
app(DuplicateProjectConfiguration::class)->export(
$projectModel,
DuplicateProjectConfiguration::copyableAreas(),
),
JSON_THROW_ON_ERROR,
),
'updated_at' => now(),
]);
}
}
}
public function down(): void
{
foreach (array_reverse($this->tables) as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
}
}
};
+339 -79
View File
@@ -1,6 +1,6 @@
@import 'tailwindcss';
@import "tailwindcss";
@import 'tw-animate-css';
@import "tw-animate-css";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@@ -9,13 +9,69 @@
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-headline-lg:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-headline-lg-mobile:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-headline-md:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-body-md:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-body-sm:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--font-label-uppercase:
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--radius-sm: 0.25rem;
--radius-md: 0.25rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--spacing-container-margin: 1.5rem;
--spacing-column-gap: 1rem;
--spacing-card-padding: 0.75rem;
--spacing-stack-gap-sm: 0.5rem;
--spacing-stack-gap-md: 0.75rem;
--text-headline-lg: 1.5rem;
--text-headline-lg--line-height: 2rem;
--text-headline-lg--font-weight: 600;
--text-headline-lg--letter-spacing: -0.025em;
--text-headline-md: 1.125rem;
--text-headline-md--line-height: 1.5rem;
--text-headline-md--font-weight: 600;
--text-headline-md--letter-spacing: -0.015em;
--text-headline-lg-mobile: 1.25rem;
--text-headline-lg-mobile--line-height: 1.75rem;
--text-headline-lg-mobile--font-weight: 600;
--text-body-md: 0.875rem;
--text-body-md--line-height: 1.25rem;
--text-body-md--font-weight: 400;
--text-body-sm: 0.8125rem;
--text-body-sm--line-height: 1.125rem;
--text-body-sm--font-weight: 400;
--text-label-uppercase: 0.6875rem;
--text-label-uppercase--line-height: 1rem;
--text-label-uppercase--font-weight: 700;
--text-label-uppercase--letter-spacing: 0.05em;
--shadow-kanban-card: 0 4px 12px rgb(0 0 0 / 0.08);
--shadow-kanban-nav: 0 12px 30px rgb(15 23 42 / 0.06);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -59,6 +115,54 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface: var(--surface);
--color-surface-dim: var(--surface-dim);
--color-surface-bright: var(--surface-bright);
--color-surface-container-lowest: var(--surface-container-lowest);
--color-surface-container-low: var(--surface-container-low);
--color-surface-container: var(--surface-container);
--color-surface-container-high: var(--surface-container-high);
--color-surface-container-highest: var(--surface-container-highest);
--color-surface-variant: var(--surface-variant);
--color-surface-tint: var(--surface-tint);
--color-on-surface: var(--on-surface);
--color-on-surface-variant: var(--on-surface-variant);
--color-on-background: var(--on-background);
--color-outline: var(--outline);
--color-outline-variant: var(--outline-variant);
--color-primary-container: var(--primary-container);
--color-on-primary: var(--on-primary);
--color-on-primary-container: var(--on-primary-container);
--color-primary-fixed: var(--primary-fixed);
--color-primary-fixed-dim: var(--primary-fixed-dim);
--color-on-primary-fixed: var(--on-primary-fixed);
--color-on-primary-fixed-variant: var(--on-primary-fixed-variant);
--color-secondary-container: var(--secondary-container);
--color-on-secondary: var(--on-secondary);
--color-on-secondary-container: var(--on-secondary-container);
--color-secondary-fixed: var(--secondary-fixed);
--color-secondary-fixed-dim: var(--secondary-fixed-dim);
--color-on-secondary-fixed: var(--on-secondary-fixed);
--color-on-secondary-fixed-variant: var(--on-secondary-fixed-variant);
--color-tertiary: var(--tertiary);
--color-tertiary-container: var(--tertiary-container);
--color-on-tertiary: var(--on-tertiary);
--color-on-tertiary-container: var(--on-tertiary-container);
--color-tertiary-fixed: var(--tertiary-fixed);
--color-tertiary-fixed-dim: var(--tertiary-fixed-dim);
--color-on-tertiary-fixed: var(--on-tertiary-fixed);
--color-on-tertiary-fixed-variant: var(--on-tertiary-fixed-variant);
--color-error: var(--error);
--color-error-container: var(--error-container);
--color-on-error: var(--on-error);
--color-on-error-container: var(--on-error-container);
--color-inverse-surface: var(--inverse-surface);
--color-inverse-on-surface: var(--inverse-on-surface);
--color-inverse-primary: var(--inverse-primary);
--color-priority-high: #ef4444;
--color-priority-medium: #f59e0b;
--color-priority-low: #3b82f6;
}
/*
@@ -83,83 +187,171 @@
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
Inter, "Instrument Sans", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
--background: #f7f9fb;
--foreground: #191c1e;
--card: #ffffff;
--card-foreground: #191c1e;
--popover: #ffffff;
--popover-foreground: #191c1e;
--primary: #006e2f;
--primary-foreground: #ffffff;
--secondary: #d0e1fb;
--secondary-foreground: #0b1c30;
--muted: #f2f4f6;
--muted-foreground: #3d4a3d;
--accent: #e6e8ea;
--accent-foreground: #191c1e;
--destructive: #ba1a1a;
--destructive-foreground: #ffffff;
--border: #bccbb9;
--input: #bccbb9;
--ring: #006e2f;
--chart-1: #22c55e;
--chart-2: #3b82f6;
--chart-3: #f59e0b;
--chart-4: #ef4444;
--chart-5: #505f76;
--radius: 0.25rem;
--surface: #f7f9fb;
--surface-dim: #d8dadc;
--surface-bright: #f7f9fb;
--surface-container-lowest: #ffffff;
--surface-container-low: #f2f4f6;
--surface-container: #eceef0;
--surface-container-high: #e6e8ea;
--surface-container-highest: #e0e3e5;
--surface-variant: #e0e3e5;
--surface-tint: #006e2f;
--on-surface: #191c1e;
--on-surface-variant: #3d4a3d;
--on-background: #191c1e;
--outline: #6d7b6c;
--outline-variant: #bccbb9;
--primary-container: #22c55e;
--on-primary: #ffffff;
--on-primary-container: #004b1e;
--primary-fixed: #6bff8f;
--primary-fixed-dim: #4ae176;
--on-primary-fixed: #002109;
--on-primary-fixed-variant: #005321;
--secondary-container: #d0e1fb;
--on-secondary: #ffffff;
--on-secondary-container: #54647a;
--secondary-fixed: #d3e4fe;
--secondary-fixed-dim: #b7c8e1;
--on-secondary-fixed: #0b1c30;
--on-secondary-fixed-variant: #38485d;
--tertiary: #9e4036;
--tertiary-container: #ff8b7c;
--on-tertiary: #ffffff;
--on-tertiary-container: #76231b;
--tertiary-fixed: #ffdad5;
--tertiary-fixed-dim: #ffb4a9;
--on-tertiary-fixed: #410001;
--on-tertiary-fixed-variant: #7f2a21;
--error: #ba1a1a;
--error-container: #ffdad6;
--on-error: #ffffff;
--on-error-container: #93000a;
--inverse-surface: #2d3133;
--inverse-on-surface: #eff1f3;
--inverse-primary: #4ae176;
--sidebar-background: #f2f4f6;
--sidebar-foreground: #191c1e;
--sidebar-primary: #006e2f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #e6e8ea;
--sidebar-accent-foreground: #191c1e;
--sidebar-border: #bccbb9;
--sidebar-ring: #006e2f;
--sidebar: #f2f4f6;
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--background: #0b1326;
--foreground: #e5eaf5;
--card: #131b2e;
--card-foreground: #e5eaf5;
--popover: #171f33;
--popover-foreground: #e5eaf5;
--primary: #4be277;
--primary-foreground: #002109;
--secondary: #222a3d;
--secondary-foreground: #e5eaf5;
--muted: #171f33;
--muted-foreground: #aeb8cd;
--accent: #222a3d;
--accent-foreground: #f3f6fc;
--destructive: #ffb4ab;
--destructive-foreground: #690005;
--border: #3b4358;
--input: #4a5268;
--ring: #4be277;
--chart-1: #4be277;
--chart-2: #60a5fa;
--chart-3: #fbbf24;
--chart-4: #f87171;
--chart-5: #b7c8e1;
--surface: #0b1326;
--surface-dim: #060e20;
--surface-bright: #2d3449;
--surface-container-lowest: #060e20;
--surface-container-low: #131b2e;
--surface-container: #171f33;
--surface-container-high: #222a3d;
--surface-container-highest: #2d3449;
--surface-variant: #2d3449;
--surface-tint: #4be277;
--on-surface: #e5eaf5;
--on-surface-variant: #aeb8cd;
--on-background: #e5eaf5;
--outline: #8993aa;
--outline-variant: #3b4358;
--primary-container: #136c35;
--on-primary: #002109;
--on-primary-container: #bbf7d0;
--primary-fixed: #6bff8f;
--primary-fixed-dim: #4be277;
--on-primary-fixed: #002109;
--on-primary-fixed-variant: #005321;
--secondary-container: #1e3a5f;
--on-secondary: #ffffff;
--on-secondary-container: #d0e1fb;
--secondary-fixed: #d3e4fe;
--secondary-fixed-dim: #b7c8e1;
--on-secondary-fixed: #0b1c30;
--on-secondary-fixed-variant: #38485d;
--tertiary: #ffb4a9;
--tertiary-container: #7f2a21;
--on-tertiary: #410001;
--on-tertiary-container: #ffdad5;
--tertiary-fixed: #ffdad5;
--tertiary-fixed-dim: #ffb4a9;
--on-tertiary-fixed: #410001;
--on-tertiary-fixed-variant: #ffb4a9;
--error: #f87171;
--error-container: #7f1d1d;
--on-error: #ffffff;
--on-error-container: #fee2e2;
--inverse-surface: #eff1f3;
--inverse-on-surface: #191c1e;
--inverse-primary: #006e2f;
--sidebar-background: #060e20;
--sidebar-foreground: #e5eaf5;
--sidebar-primary: #4be277;
--sidebar-primary-foreground: #002109;
--sidebar-accent: #171f33;
--sidebar-accent-foreground: #f3f6fc;
--sidebar-border: #2d3449;
--sidebar-ring: #4be277;
--sidebar: #060e20;
}
@layer base {
@@ -167,6 +359,74 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply min-h-screen bg-background text-foreground antialiased;
letter-spacing: -0.006em;
}
::selection {
background: color-mix(in srgb, var(--primary) 24%, transparent);
color: var(--foreground);
}
textarea,
select {
@apply border-input bg-surface-container-lowest text-foreground rounded-md border px-3 py-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow,background-color];
background-color: var(--surface-container-lowest) !important;
}
textarea:focus-visible,
select:focus-visible {
@apply border-ring ring-ring/25 ring-3;
}
textarea:disabled,
select:disabled {
@apply cursor-not-allowed opacity-55;
}
table {
@apply w-full border-separate border-spacing-0;
}
thead {
@apply bg-surface-container-low text-on-surface-variant;
}
tbody tr {
@apply transition-colors hover:bg-primary/3;
}
th {
@apply text-label-uppercase font-bold tracking-[0.08em];
}
}
@layer components {
.app-page {
@apply mx-auto flex w-full max-w-[96rem] flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8;
}
.app-page-narrow {
@apply mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-5 sm:px-6 sm:py-7 lg:px-8;
}
.page-actions {
@apply flex flex-wrap items-center gap-2;
}
.status-success {
@apply border-primary/25 bg-primary/8 text-on-primary-container rounded-lg border px-4 py-3 text-sm dark:text-primary-fixed;
}
.empty-state {
@apply border-outline-variant bg-surface-container-low text-on-surface-variant rounded-lg border border-dashed px-5 py-8 text-center text-sm;
}
.table-shell {
@apply border-outline-variant bg-surface-container-lowest overflow-hidden rounded-xl border;
}
.eyebrow {
@apply text-label-uppercase text-primary font-bold uppercase tracking-[0.12em];
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ createInertiaApp({
.mount(el);
},
progress: {
color: '#4B5563',
color: '#22c55e',
},
});
+3 -8
View File
@@ -1,14 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { SidebarInset } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
import type { AppContentProps } from '@/types';
type Props = {
variant?: AppVariant;
class?: string;
};
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<AppContentProps>(), {
variant: 'sidebar',
});
const className = computed(() => props.class);
@@ -20,7 +15,7 @@ const className = computed(() => props.class);
</SidebarInset>
<main
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
class="flex h-full w-full flex-1 flex-col gap-4"
:class="className"
>
<slot />
+156 -218
View File
@@ -1,9 +1,15 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
import {
Folder,
LayoutGrid,
Layers3,
Menu,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Button } from '@/components/ui/button';
import {
@@ -11,12 +17,6 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
@@ -24,246 +24,184 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem, NavItem } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectTemplatesIndex } from '@/routes/project-templates';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { AppHeaderProps, NavItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
const props = withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<AppHeaderProps>(), {
breadcrumbs: () => [],
});
const page = usePage();
const auth = computed(() => page.props.auth);
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
const { isCurrentOrParentUrl } = useCurrentUrl();
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projects',
href: projectsIndex(),
icon: Folder,
},
];
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
});
const navItemClasses = (item: NavItem): string =>
[
'inline-flex h-9 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-200',
isCurrentOrParentUrl(item.href)
? 'bg-primary text-primary-foreground shadow-[0_8px_20px_rgba(0,110,47,0.16)]'
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface',
].join(' ');
</script>
<template>
<div>
<div class="border-sidebar-border/80 border-b">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="mr-2 h-9 w-9"
>
<Menu class="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only"
>Navigation menu</SheetTitle
>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon
class="size-6 fill-current text-black dark:text-white"
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
>
<nav class="-mx-3 space-y-1">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
class="hover:bg-accent flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium"
:class="
whenCurrentUrl(
item.href,
activeItemStyles,
)
"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link :href="dashboard()" class="flex items-center gap-x-2">
<AppLogo />
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList
class="flex h-full items-stretch space-x-2"
<header
class="border-outline-variant/80 bg-surface/90 shadow-kanban-nav sticky top-0 z-50 border-b backdrop-blur-md"
>
<div
class="sm:px-container-margin flex h-16 w-full items-center gap-3 px-4"
>
<Sheet>
<SheetTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface size-9 rounded-lg lg:hidden"
>
<Menu class="size-5" />
<span class="sr-only">Open navigation</span>
</Button>
</SheetTrigger>
<SheetContent
side="left"
class="border-outline-variant bg-surface w-[320px] p-0"
>
<SheetTitle class="sr-only">Navigation menu</SheetTitle>
<SheetHeader class="border-outline-variant border-b p-4">
<Link
:href="dashboard()"
class="flex items-center gap-3"
>
<NavigationMenuItem
v-for="(item, index) in mainNavItems"
:key="index"
class="relative flex h-full items-center"
>
<Link
:class="[
navigationMenuTriggerStyle(),
whenCurrentUrl(
item.href,
activeItemStyles,
),
'h-9 cursor-pointer px-3',
]"
:href="item.href"
>
<component
v-if="item.icon"
:is="item.icon"
class="mr-2 h-4 w-4"
/>
{{ item.title }}
</Link>
<div
v-if="isCurrentUrl(item.href)"
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<AppLogo />
</Link>
</SheetHeader>
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<div class="flex flex-col gap-5 p-4">
<nav class="flex flex-col gap-1" aria-label="Main">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
:class="navItemClasses(item)"
>
<component
v-if="item.icon"
:is="item.icon"
class="size-4"
/>
{{ item.title }}
</Link>
</nav>
</div>
</SheetContent>
</Sheet>
<Link
:href="dashboard()"
class="flex shrink-0 items-center gap-2"
aria-label="KanbanGit dashboard"
>
<AppLogo />
</Link>
<nav
class="ml-3 hidden min-w-0 flex-1 items-center gap-1 lg:flex"
aria-label="Main"
>
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
:class="navItemClasses(item)"
>
<component
v-if="item.icon"
:is="item.icon"
class="size-4"
/>
<span class="truncate">{{ item.title }}</span>
</Link>
</nav>
<div class="ml-auto flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
class="focus-visible:ring-primary/30 hover:bg-surface-container-high size-10 rounded-full p-1 focus-visible:ring-2"
>
<Search
class="size-5 opacity-80 group-hover:opacity-100"
/>
<UserAvatar rounded />
<span class="sr-only">Open user menu</span>
</Button>
<div class="hidden space-x-1 lg:flex">
<template
v-for="item in rightNavItems"
:key="item.title"
>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
as-child
class="group h-9 w-9 cursor-pointer"
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<span class="sr-only">{{
item.title
}}</span>
<component
:is="item.icon"
class="size-5 opacity-80 group-hover:opacity-100"
/>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="focus-within:ring-primary relative size-10 w-auto rounded-full p-1 focus-within:ring-2"
>
<UserAvatar rounded />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="border-outline-variant bg-popover w-56"
>
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
v-if="props.breadcrumbs.length > 1"
class="border-sidebar-border/70 flex w-full border-b"
class="border-outline-variant/70 bg-surface-container-low/70 border-t"
>
<div
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
class="text-on-surface-variant sm:px-container-margin flex h-11 w-full items-center px-4 text-sm"
>
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</div>
</div>
</div>
</header>
</template>
+5 -5
View File
@@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<template>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
class="bg-primary text-primary-foreground flex aspect-square size-9 items-center justify-center rounded-lg shadow-[0_8px_24px_rgba(0,110,47,0.18)]"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
<AppLogoIcon class="size-5 fill-current" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
>
<span class="text-primary truncate text-sm font-semibold leading-tight">
KanbanGit
</span>
</div>
</template>
+7 -7
View File
@@ -15,15 +15,15 @@ defineProps<Props>();
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
viewBox="0 0 32 32"
:class="className"
v-bind="$attrs"
aria-hidden="true"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
<rect x="4" y="5" width="7" height="22" rx="2" />
<rect x="13" y="5" width="7" height="14" rx="2" opacity="0.82" />
<rect x="22" y="5" width="6" height="18" rx="2" opacity="0.64" />
<path d="M14 24h7v3h-7z" opacity="0.82" />
<path d="M22 26h6v1h-6z" opacity="0.64" />
</svg>
</template>
+6 -7
View File
@@ -1,13 +1,9 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { SidebarProvider } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
import type { AppShellProps } from '@/types';
type Props = {
variant?: AppVariant;
};
withDefaults(defineProps<Props>(), {
withDefaults(defineProps<AppShellProps>(), {
variant: 'sidebar',
});
@@ -15,7 +11,10 @@ const isOpen = usePage().props.sidebarOpen;
</script>
<template>
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
<div
v-if="variant === 'header'"
class="bg-background text-foreground flex min-h-screen w-full flex-col"
>
<slot />
</div>
<SidebarProvider v-else :default-open="isOpen">
+71 -7
View File
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import {
ArrowLeft,
BookOpen,
CirclePlus,
Folder,
FolderGit2,
LayoutGrid,
Layers3,
Shield,
Users,
} from 'lucide-vue-next';
@@ -24,9 +27,15 @@ import {
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { index as projectTemplatesIndex } from '@/routes/project-templates';
import {
create as projectsCreate,
index as projectsIndex,
} from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
import type { AppSidebarProps, NavItem } from '@/types';
const props = defineProps<AppSidebarProps>();
const page = usePage();
@@ -44,6 +53,14 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth.permissions.projectCreate) {
items.push({
title: 'Project templates',
href: projectTemplatesIndex(),
icon: Layers3,
});
}
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
@@ -66,24 +83,24 @@ const mainNavItems = computed<NavItem[]>(() => {
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
href: 'https://git.twyco.de/OpenSource/KanbanGit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
href: 'https://git.twyco.de/OpenSource/KanbanGit/src/branch/main/README.md',
icon: BookOpen,
},
];
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<Sidebar collapsible="icon" variant="sidebar" class="border-sidebar-border">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<Link :href="project ? projectsIndex() : dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
@@ -92,7 +109,54 @@ const footerNavItems: NavItem[] = [
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
<template v-if="project">
<div
class="border-sidebar-border bg-sidebar-accent/60 mx-3 mb-3 rounded-lg border p-3 group-data-[collapsible=icon]:hidden"
>
<div
class="text-sidebar-foreground/55 text-[0.65rem] font-bold uppercase tracking-[0.12em]"
>
{{ project.key }}
</div>
<div class="mt-1 truncate text-sm font-semibold">
{{ project.name }}
</div>
</div>
<div class="mt-auto px-3 pb-3">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child tooltip="All projects">
<Link :href="projectsIndex()">
<ArrowLeft />
<span>All projects</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
</template>
<template v-else>
<NavMain :items="mainNavItems" label="Workspace" />
<div
v-if="page.props.auth.permissions.projectCreate"
class="px-3 pt-3 group-data-[collapsible=icon]:px-2"
>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
as-child
tooltip="New project"
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
>
<Link :href="projectsCreate()">
<CirclePlus />
<span>New project</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
</template>
</SidebarContent>
<SidebarFooter>
+9 -12
View File
@@ -1,24 +1,21 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
import type { AppSidebarHeaderProps } from '@/types';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
);
withDefaults(defineProps<AppSidebarHeaderProps>(), {
breadcrumbs: () => [],
});
</script>
<template>
<header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
class="border-border/70 bg-background/92 sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-xl transition-[width,height] ease-linear md:px-6"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<div class="flex min-w-0 items-center gap-3">
<SidebarTrigger
class="text-muted-foreground hover:text-foreground -ml-1"
/>
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
+4 -4
View File
@@ -13,17 +13,17 @@ const tabs = [
<template>
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
class="bg-surface-container-low border-outline-variant inline-flex gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
'flex items-center rounded-md px-3.5 py-2 text-sm font-medium transition-all',
appearance === value
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
? 'bg-surface-container-lowest text-foreground shadow-xs ring-outline-variant ring-1'
: 'text-muted-foreground hover:bg-surface-container-high hover:text-foreground',
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
+9 -10
View File
@@ -1,27 +1,26 @@
<script setup lang="ts">
type Props = {
title: string;
description?: string;
variant?: 'default' | 'small';
};
import type { HeadingProps } from '@/types';
withDefaults(defineProps<Props>(), {
withDefaults(defineProps<HeadingProps>(), {
variant: 'default',
});
</script>
<template>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
<header :class="variant === 'small' ? '' : 'space-y-1.5'">
<h2
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
? 'mb-0.5 text-base font-semibold tracking-tight'
: 'text-2xl font-semibold tracking-[-0.025em] sm:text-[1.75rem]'
"
>
{{ title }}
</h2>
<p v-if="description" class="text-muted-foreground text-sm">
<p
v-if="description"
class="text-muted-foreground max-w-3xl text-sm leading-6"
>
{{ description }}
</p>
</header>
+1 -1
View File
@@ -25,7 +25,7 @@ defineProps<Props>();
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
class="text-sidebar-foreground/60 hover:text-sidebar-foreground"
as-child
>
<a
+9 -7
View File
@@ -8,23 +8,25 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import type { NavItem } from '@/types';
import type { NavMainProps } from '@/types';
defineProps<{
items: NavItem[];
}>();
withDefaults(defineProps<NavMainProps>(), {
label: 'Workspace',
});
const { isCurrentUrl } = useCurrentUrl();
const { isCurrentOrParentUrl } = useCurrentUrl();
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{{ label }}</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="isCurrentUrl(item.href)"
:is-active="
item.isActive ?? isCurrentOrParentUrl(item.href)
"
:tooltip="item.title"
>
<Link :href="item.href">
+3 -17
View File
@@ -1,28 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { UserInfoProps } from '@/types';
type Props = {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<UserInfoProps>(), {
showEmail: false,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script>
<template>
<UserAvatar />
<UserAvatar :user="user" />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
@@ -0,0 +1,493 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowUp,
CircleAlert,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from 'lucide-vue-next';
import { nextTick, ref } from 'vue';
import {
addAcceptanceCriterionAfter,
indentAcceptanceCriterionSubtree,
moveAcceptanceCriterionSubtree,
nextAcceptanceCriteriaSiblingIndex,
outdentAcceptanceCriterionSubtree,
previousAcceptanceCriteriaSiblingIndex,
removeAcceptanceCriterion,
syncAcceptanceCriteriaDescendantCheckedState,
} from '@/lib/acceptance-criteria';
import { Button } from '@/components/ui/button';
import type {
AcceptanceCriteriaEditorEmits,
AcceptanceCriteriaEditorProps,
} from '@/types';
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
const props = withDefaults(defineProps<AcceptanceCriteriaEditorProps>(), {
disabled: false,
error: '',
saveState: 'idle',
});
const emit = defineEmits<AcceptanceCriteriaEditorEmits>();
const editorRoot = ref<HTMLElement | null>(null);
const editingIndex = ref<number | null>(null);
const originalText = ref<{ index: number; text: string } | null>(null);
const skippedBlurIndex = ref<number | null>(null);
const emitUpdate = (items: AcceptanceCriteriaModel): void => {
emit('update:modelValue', items);
};
const emitCommit = (
items: AcceptanceCriteriaModel,
source: 'check' | 'delete' | 'indent' | 'move' | 'text',
): void => {
emitUpdate(items);
emit('commit', { items, source });
};
const focusRow = (index: number): void => {
editingIndex.value = index;
void nextTick(() => {
editorRoot.value
?.querySelector<HTMLInputElement>(`[data-ac-row-index="${index}"]`)
?.focus();
});
};
const startTextEdit = (index: number): void => {
if (!props.editable || props.disabled) {
return;
}
rememberText(index);
focusRow(index);
};
const addCriterion = (index: number | null): void => {
if (!props.editable || props.disabled) {
return;
}
const emptyCriterionIndex = props.modelValue.findIndex(
(criterion) => criterion.text.trim() === '',
);
if (emptyCriterionIndex !== -1) {
focusRow(emptyCriterionIndex);
return;
}
const result = addAcceptanceCriterionAfter(props.modelValue, index);
emitUpdate(result.items);
focusRow(result.index);
};
const updateText = (index: number, event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
const items = props.modelValue.map((item, itemIndex) => ({
...item,
text: itemIndex === index ? target.value : item.text,
}));
emitUpdate(items);
};
const rememberText = (index: number): void => {
originalText.value = {
index,
text: props.modelValue[index]?.text ?? '',
};
};
const commitText = (index: number): void => {
if (skippedBlurIndex.value === index) {
skippedBlurIndex.value = null;
if (editingIndex.value === index) {
editingIndex.value = null;
}
if (originalText.value?.index === index) {
originalText.value = null;
}
return;
}
const item = props.modelValue[index];
if (item === undefined) {
return;
}
const trimmedText = item.text.trim();
if (trimmedText === '') {
const items = removeAcceptanceCriterion(props.modelValue, index);
if (item.id === undefined) {
emitUpdate(items);
} else {
emitCommit(items, 'delete');
}
originalText.value = null;
editingIndex.value = null;
return;
}
const items = props.modelValue.map((criterion, criterionIndex) => ({
...criterion,
text: criterionIndex === index ? trimmedText : criterion.text,
}));
const previousText =
originalText.value?.index === index
? originalText.value.text
: item.text;
if (trimmedText !== previousText || props.error !== '') {
emitCommit(items, 'text');
} else if (trimmedText !== item.text) {
emitUpdate(items);
}
originalText.value = null;
editingIndex.value = null;
};
const cancelTextEdit = (index: number, event: KeyboardEvent): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
const fallbackText =
originalText.value?.index === index ? originalText.value.text : '';
const items = props.modelValue.map((item, itemIndex) => ({
...item,
text: itemIndex === index ? fallbackText : item.text,
}));
skippedBlurIndex.value = index;
emitUpdate(items);
target.blur();
};
const insertAfterCurrent = (index: number, event: KeyboardEvent): void => {
event.preventDefault();
const item = props.modelValue[index];
const trimmedText = item?.text.trim() ?? '';
if (item === undefined || trimmedText === '') {
return;
}
const committedItems = props.modelValue.map(
(criterion, criterionIndex) => ({
...criterion,
text: criterionIndex === index ? trimmedText : criterion.text,
}),
);
const result = addAcceptanceCriterionAfter(committedItems, index);
originalText.value = null;
skippedBlurIndex.value = index;
emitUpdate(result.items);
focusRow(result.index);
emit('commit', { items: committedItems, source: 'text' });
};
const toggleCriterion = (index: number, event: Event): void => {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
emitCommit(
syncAcceptanceCriteriaDescendantCheckedState(
props.modelValue,
index,
target.checked,
),
'check',
);
};
const indentCriterion = (index: number): void => {
const items = indentAcceptanceCriterionSubtree(props.modelValue, index);
if (props.modelValue[index]?.text.trim() === '') {
emitUpdate(items);
focusRow(index);
return;
}
emitCommit(items, 'indent');
};
const outdentCriterion = (index: number): void => {
const items = outdentAcceptanceCriterionSubtree(props.modelValue, index);
if (props.modelValue[index]?.text.trim() === '') {
emitUpdate(items);
focusRow(index);
return;
}
emitCommit(items, 'indent');
};
const moveCriterion = (index: number, direction: 'down' | 'up'): void => {
emitCommit(
moveAcceptanceCriterionSubtree(props.modelValue, index, direction),
'move',
);
};
const deleteCriterion = (index: number): void => {
emitCommit(removeAcceptanceCriterion(props.modelValue, index), 'delete');
};
const handleIndentKeyboard = (index: number, event: KeyboardEvent): void => {
event.preventDefault();
if (event.shiftKey) {
outdentCriterion(index);
} else {
indentCriterion(index);
}
};
</script>
<template>
<div ref="editorRoot" class="space-y-3">
<div
v-if="modelValue.length === 0"
class="border-outline-variant bg-surface-container-low flex min-h-28 flex-col items-center justify-center gap-3 rounded border border-dashed px-4 py-6 text-center"
>
<p class="text-muted-foreground text-sm">
No acceptance criteria have been added yet.
</p>
<Button
v-if="editable"
type="button"
variant="outline"
size="sm"
:disabled="disabled"
@click="addCriterion(null)"
>
<Plus class="size-4" />
Add criterion
</Button>
</div>
<ol v-else class="space-y-1.5">
<li
v-for="(item, index) in modelValue"
:key="item.id ?? `draft-${index}`"
class="group/ac-row"
:style="{ paddingLeft: `${item.indent * 1.1}rem` }"
>
<div
class="border-outline-variant/80 bg-background hover:border-primary/35 focus-within:border-primary/50 focus-within:bg-primary/[0.025] flex min-h-10 flex-wrap items-start gap-2 rounded border px-2.5 py-2 transition sm:flex-nowrap"
>
<input
type="checkbox"
:checked="item.checked"
:disabled="!editable || disabled"
class="border-input text-primary focus:ring-ring mt-0.5 size-4 shrink-0 rounded"
:aria-label="`Mark ${item.text || 'criterion'} as complete`"
@change="toggleCriterion(index, $event)"
/>
<input
v-if="editable && editingIndex === index"
:data-ac-row-index="index"
:value="item.text"
type="text"
:disabled="disabled"
class="placeholder:text-muted-foreground/70 min-w-0 flex-1 border-0 bg-transparent p-0 text-sm leading-5 outline-none disabled:cursor-not-allowed"
placeholder="Describe a verifiable outcome"
@input="updateText(index, $event)"
@blur="commitText(index)"
@keydown.enter="insertAfterCurrent(index, $event)"
@keydown.esc="cancelTextEdit(index, $event)"
@keydown.tab="handleIndentKeyboard(index, $event)"
/>
<button
v-else-if="editable"
type="button"
class="placeholder:text-muted-foreground/70 hover:text-primary focus-visible:ring-ring min-w-0 flex-1 whitespace-normal rounded text-left text-sm leading-5 outline-none focus-visible:ring-2"
:disabled="disabled"
@click="startTextEdit(index)"
>
<span
:class="
item.checked
? 'text-muted-foreground line-through'
: ''
"
>
{{ item.text || 'Describe a verifiable outcome' }}
</span>
</button>
<span
v-else
class="min-w-0 flex-1 text-sm leading-5"
:class="
item.checked
? 'text-muted-foreground line-through'
: 'text-foreground'
"
>
{{ item.text }}
</span>
<div
v-if="editable"
class="flex w-full shrink-0 items-center justify-end gap-0.5 pl-6 opacity-100 transition sm:w-auto sm:pl-0 sm:opacity-0 sm:group-focus-within/ac-row:opacity-100 sm:group-hover/ac-row:opacity-100"
>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="disabled"
aria-label="Add criterion below"
title="Add below"
@mousedown.prevent
@click="addCriterion(index)"
>
<Plus class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="disabled || item.indent === 0"
aria-label="Outdent criterion"
title="Outdent (Shift+Tab)"
@mousedown.prevent
@click="outdentCriterion(index)"
>
<IndentDecrease class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
previousAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Indent criterion"
title="Indent (Tab)"
@mousedown.prevent
@click="indentCriterion(index)"
>
<IndentIncrease class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
previousAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Move criterion up"
title="Move up"
@mousedown.prevent
@click="moveCriterion(index, 'up')"
>
<ArrowUp class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="size-6"
:disabled="
disabled ||
nextAcceptanceCriteriaSiblingIndex(
modelValue,
index,
) === null
"
aria-label="Move criterion down"
title="Move down"
@mousedown.prevent
@click="moveCriterion(index, 'down')"
>
<ArrowDown class="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-6"
:disabled="disabled"
aria-label="Delete criterion"
title="Delete"
@mousedown.prevent
@click="deleteCriterion(index)"
>
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
</li>
</ol>
<div class="flex min-h-5 items-center justify-between gap-3">
<Button
v-if="editable && modelValue.length > 0"
type="button"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="disabled"
@click="addCriterion(null)"
>
<Plus class="size-3.5" />
Add criterion
</Button>
<span v-else />
<span
v-if="saveState === 'error' || error"
class="text-destructive flex items-center gap-1.5 text-xs"
>
<CircleAlert class="size-3.5" />
{{ error || 'Could not save changes.' }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { CircleAlert } from 'lucide-vue-next';
import type { InlineSaveStatusProps } from '@/types';
withDefaults(defineProps<InlineSaveStatusProps>(), {
error: '',
});
</script>
<template>
<div
v-if="state === 'error' || error"
class="flex items-center gap-1.5 pt-1 text-[11px]"
>
<CircleAlert class="text-destructive size-3" />
<span class="text-destructive">{{ error || 'Could not save.' }}</span>
</div>
</template>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { CircleAlert, Pencil } from 'lucide-vue-next';
import { nextTick, ref } from 'vue';
import type {
InlineTextEditorEmits,
InlineTextEditorProps,
InlineTextEditorValue,
} from '@/types';
const props = withDefaults(defineProps<InlineTextEditorProps>(), {
disabled: false,
error: '',
inputType: 'text',
multiline: false,
saveState: 'idle',
variant: 'body',
});
const emit = defineEmits<InlineTextEditorEmits>();
const isEditing = ref(false);
const originalValue = ref<InlineTextEditorValue>(null);
const skipNextBlur = ref(false);
const editor = ref<HTMLInputElement | HTMLTextAreaElement | null>(null);
const startEditing = (): void => {
if (!props.editable || props.disabled) {
return;
}
originalValue.value = props.value;
isEditing.value = true;
void nextTick(() => {
editor.value?.focus();
if (editor.value instanceof HTMLInputElement) {
editor.value.select();
}
});
};
const updateValue = (event: Event): void => {
const target = event.target;
if (
!(target instanceof HTMLInputElement) &&
!(target instanceof HTMLTextAreaElement)
) {
return;
}
emit('update:value', target.value);
};
const finishEditing = (): void => {
if (skipNextBlur.value) {
skipNextBlur.value = false;
isEditing.value = false;
return;
}
isEditing.value = false;
if (props.value !== originalValue.value || props.error !== '') {
emit('commit');
}
};
const cancelEditing = (): void => {
skipNextBlur.value = true;
emit('update:value', originalValue.value);
editor.value?.blur();
};
const handleKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
cancelEditing();
return;
}
if (
(!props.multiline && event.key === 'Enter') ||
(props.multiline &&
event.key === 'Enter' &&
(event.metaKey || event.ctrlKey))
) {
event.preventDefault();
editor.value?.blur();
}
};
const displayValue = (): string => {
if (props.value === null || props.value === '') {
return props.placeholder;
}
return String(props.value);
};
</script>
<template>
<div class="min-w-0">
<div v-if="isEditing" class="space-y-1.5">
<textarea
v-if="multiline"
ref="editor"
:value="value ?? ''"
rows="5"
:disabled="disabled"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-32 w-full resize-y rounded border px-3 py-2 text-sm leading-6 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
@input="updateValue"
@blur="finishEditing"
@keydown="handleKeydown"
/>
<input
v-else
ref="editor"
:value="value ?? ''"
:type="inputType"
:disabled="disabled"
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 w-full rounded border px-3 py-2 text-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
@input="updateValue"
@blur="finishEditing"
@keydown="handleKeydown"
/>
<p class="text-muted-foreground text-[11px]">
{{
multiline
? 'Cmd/Ctrl+Enter to save · Escape to cancel'
: 'Enter to save · Escape to cancel'
}}
</p>
</div>
<button
v-else-if="editable"
type="button"
class="group/inline hover:bg-primary/5 focus-visible:ring-ring relative -m-1 flex w-[calc(100%+0.5rem)] min-w-0 items-start gap-2 rounded p-1 text-left outline-none transition focus-visible:ring-2"
:disabled="disabled"
@click="startEditing"
>
<span
class="min-w-0 flex-1 whitespace-pre-wrap"
:class="[
variant === 'title'
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
: variant === 'property'
? 'text-sm font-medium'
: 'text-sm leading-6',
value === null || value === ''
? 'text-muted-foreground italic'
: 'text-foreground',
]"
>
{{ displayValue() }}
</span>
<Pencil
class="text-muted-foreground mt-0.5 size-3.5 shrink-0 opacity-0 transition group-hover/inline:opacity-100 group-focus-visible/inline:opacity-100"
/>
</button>
<div v-else>
<p
class="whitespace-pre-wrap"
:class="[
variant === 'title'
? 'text-2xl font-semibold leading-tight tracking-tight sm:text-[1.75rem]'
: variant === 'property'
? 'text-sm font-medium'
: 'text-sm leading-6',
value === null || value === ''
? 'text-muted-foreground italic'
: 'text-foreground',
]"
>
{{ displayValue() }}
</p>
</div>
<div
v-if="saveState === 'error' || error"
class="mt-1 flex items-center gap-1.5 text-[11px]"
>
<CircleAlert class="text-destructive size-3" />
<span class="text-destructive">{{
error || 'Could not save.'
}}</span>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import type { ProjectPageHeaderProps } from '@/types/project-navigation';
defineProps<ProjectPageHeaderProps>();
</script>
<template>
<header
class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge variant="outline" class="rounded px-2.5 py-1">
{{ project.key }}
</Badge>
<span class="text-muted-foreground text-xs font-medium">
{{ context }}
</span>
</div>
<h1 class="mt-2 truncate text-2xl font-bold tracking-tight">
{{ project.name }}
</h1>
<p class="text-muted-foreground mt-1 max-w-3xl text-sm leading-6">
{{ description }}
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2 self-start sm:self-auto">
<slot />
</div>
</header>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { KanbanSquare, ListTodo, Settings2 } from 'lucide-vue-next';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as settingsIndex } from '@/routes/projects/settings';
import type { ProjectTabsProps } from '@/types';
defineProps<ProjectTabsProps>();
</script>
<template>
<nav
aria-label="Project sections"
class="border-outline-variant/70 flex min-w-max items-center gap-1 border-b"
>
<Link
:href="backlogIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'backlog'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<ListTodo class="size-4" />
Backlog
<span
v-if="active === 'backlog'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link
:href="boardIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'board'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<KanbanSquare class="size-4" />
Board
<span
v-if="active === 'board'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
<Link
v-if="canManageProject"
:href="settingsIndex(project.id)"
prefetch
:class="[
'group relative inline-flex h-11 items-center gap-2 px-3 text-sm font-semibold transition-colors',
active === 'settings'
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
]"
>
<Settings2 class="size-4" />
Settings
<span
v-if="active === 'settings'"
class="bg-primary absolute inset-x-2 -bottom-px h-0.5"
/>
</Link>
</nav>
</template>
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { Check, ChevronDown, Search } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type {
PropertySelectDropdownEmits,
PropertySelectDropdownProps,
} from '@/types';
const props = withDefaults(defineProps<PropertySelectDropdownProps>(), {
disabled: false,
searchable: false,
});
const emit = defineEmits<PropertySelectDropdownEmits>();
const maximumVisibleOptions = 12;
const isOpen = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchQuery = ref('');
const normalizedValue = computed(() => String(props.modelValue ?? ''));
const normalizedSearchQuery = computed(() =>
searchQuery.value.trim().toLocaleLowerCase(),
);
const selectedOption = computed(
() =>
props.options.find(
(option) => String(option.value) === normalizedValue.value,
) ?? null,
);
const visibleOptions = computed(() => {
const query = normalizedSearchQuery.value;
return props.options
.filter((option) => {
if (!props.searchable || query === '') {
return true;
}
return [option.label, option.description].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
})
.slice(0, maximumVisibleOptions);
});
watch(isOpen, (open) => {
searchQuery.value = '';
if (open && props.searchable) {
void nextTick(() => searchInput.value?.focus());
}
});
const focusSearch = (event: Event): void => {
if (!props.searchable) {
return;
}
event.preventDefault();
void nextTick(() => searchInput.value?.focus());
};
const handleSearchKeydown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' && event.key !== 'Tab') {
event.stopPropagation();
}
};
const selectOption = (value: number | string): void => {
if (
!props.editable ||
props.disabled ||
String(value) === normalizedValue.value
) {
return;
}
emit('update:modelValue', value);
emit('commit', value);
};
</script>
<template>
<DropdownMenu v-if="editable" v-model:open="isOpen">
<DropdownMenuTrigger as-child>
<button
type="button"
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-60"
:aria-label="`Select ${label}`"
:disabled="disabled"
>
<span class="min-w-0 flex-1 truncate">
{{ selectedOption?.label ?? 'Not set' }}
</span>
<ChevronDown
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
:side-offset="6"
class="w-(--reka-dropdown-menu-trigger-width) min-w-52 rounded p-1"
@open-auto-focus="focusSearch"
>
<div
v-if="searchable"
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
@click.stop
@pointerdown.stop
>
<Search class="text-muted-foreground size-3.5 shrink-0" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
:aria-label="`Search ${label}`"
@keydown="handleSearchKeydown"
/>
</div>
<DropdownMenuItem
v-for="option in visibleOptions"
:key="String(option.value)"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class="
normalizedValue === String(option.value)
? 'bg-primary/[0.055] text-primary'
: ''
"
@select="selectOption(option.value)"
>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">
{{ option.label }}
</span>
<span
v-if="option.description"
class="text-muted-foreground block truncate text-[11px]"
>
{{ option.description }}
</span>
</span>
<Check
v-if="normalizedValue === String(option.value)"
class="text-primary size-4 shrink-0"
/>
</DropdownMenuItem>
<p
v-if="visibleOptions.length === 0"
class="text-muted-foreground px-2 py-4 text-center text-xs"
>
No matching {{ label.toLocaleLowerCase() }} found.
</p>
</DropdownMenuContent>
</DropdownMenu>
<div
v-else
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
>
<span class="truncate">{{ selectedOption?.label ?? 'Not set' }}</span>
</div>
</template>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { Check, ChevronDown, Search, UserRound } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { UserSelectDropdownEmits, UserSelectDropdownProps } from '@/types';
const props = withDefaults(defineProps<UserSelectDropdownProps>(), {
emptyLabel: 'Unassigned',
});
const emit = defineEmits<UserSelectDropdownEmits>();
const maximumVisibleOptions = 12;
const isOpen = ref(false);
const searchInput = ref<HTMLInputElement | null>(null);
const searchQuery = ref('');
const normalizedValue = computed(() => String(props.modelValue ?? ''));
const normalizedSearchQuery = computed(() =>
searchQuery.value.trim().toLocaleLowerCase(),
);
const selectedUser = computed(
() =>
props.users.find((user) => String(user.id) === normalizedValue.value) ??
null,
);
const showEmptyOption = computed(
() =>
normalizedSearchQuery.value === '' ||
props.emptyLabel
.toLocaleLowerCase()
.includes(normalizedSearchQuery.value),
);
const visibleUsers = computed(() => {
const query = normalizedSearchQuery.value;
const maximumVisibleUsers = Math.max(
0,
maximumVisibleOptions - (showEmptyOption.value ? 1 : 0),
);
return props.users
.filter((user) => {
if (query === '') {
return true;
}
return [user.name, user.email].some((value) =>
value?.toLocaleLowerCase().includes(query),
);
})
.slice(0, maximumVisibleUsers);
});
watch(isOpen, (open) => {
searchQuery.value = '';
if (open) {
void nextTick(() => searchInput.value?.focus());
}
});
const focusSearch = (event: Event): void => {
event.preventDefault();
void nextTick(() => searchInput.value?.focus());
};
const handleSearchKeydown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' && event.key !== 'Tab') {
event.stopPropagation();
}
};
const selectUser = (value: string): void => {
if (!props.editable || value === normalizedValue.value) {
return;
}
const nextValue = value === '' ? '' : Number(value);
emit('update:modelValue', nextValue);
emit('commit', nextValue);
};
</script>
<template>
<DropdownMenu v-if="editable" v-model:open="isOpen">
<DropdownMenuTrigger as-child>
<button
type="button"
class="focus-visible:ring-ring/50 data-[state=open]:bg-primary/[0.08] data-[state=open]:text-primary hover:bg-primary/[0.055] hover:text-primary group -mx-1 flex min-h-7 w-[calc(100%+0.5rem)] items-center gap-2 rounded px-1 text-left text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"
:aria-label="`Select ${label}`"
>
<UserAvatar
v-if="selectedUser"
:user="selectedUser"
class="size-6 shrink-0"
/>
<span
v-else
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="min-w-0 flex-1 truncate">
{{ selectedUser?.name ?? emptyLabel }}
</span>
<ChevronDown
class="text-muted-foreground size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
:side-offset="6"
class="w-(--reka-dropdown-menu-trigger-width) min-w-64 rounded p-1"
@open-auto-focus="focusSearch"
>
<div
class="border-outline-variant bg-surface-container-low mb-1 flex items-center gap-2 rounded border px-2.5 py-2"
@click.stop
@pointerdown.stop
>
<Search class="text-muted-foreground size-3.5 shrink-0" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none"
:placeholder="`Search ${label.toLocaleLowerCase()}...`"
:aria-label="`Search ${label}`"
@keydown="handleSearchKeydown"
/>
</div>
<DropdownMenuItem
v-if="showEmptyOption"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class="
normalizedValue === ''
? 'bg-primary/[0.055] text-primary'
: ''
"
@select="selectUser('')"
>
<span
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-7 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="min-w-0 flex-1 truncate font-medium">
{{ emptyLabel }}
</span>
<Check
v-if="normalizedValue === ''"
class="text-primary size-4 shrink-0"
/>
</DropdownMenuItem>
<DropdownMenuItem
v-for="user in visibleUsers"
:key="user.id"
class="hover:bg-primary/[0.04] focus:bg-primary/[0.08] cursor-pointer gap-2.5 rounded px-2 py-2"
:class="
normalizedValue === String(user.id)
? 'bg-primary/[0.055] text-primary'
: ''
"
@select="selectUser(String(user.id))"
>
<UserAvatar :user="user" class="size-7 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium">
{{ user.name }}
</span>
<span
v-if="user.email"
class="text-muted-foreground block truncate text-[11px]"
>
{{ user.email }}
</span>
</span>
<Check
v-if="normalizedValue === String(user.id)"
class="text-primary size-4 shrink-0"
/>
</DropdownMenuItem>
<p
v-if="!showEmptyOption && visibleUsers.length === 0"
class="text-muted-foreground px-2 py-4 text-center text-xs"
>
No matching {{ label.toLocaleLowerCase() }} found.
</p>
</DropdownMenuContent>
</DropdownMenu>
<div
v-else
class="flex min-h-7 min-w-0 items-center gap-2 text-sm font-medium"
>
<UserAvatar
v-if="selectedUser"
:user="selectedUser"
class="size-6 shrink-0"
/>
<span
v-else
class="border-outline-variant bg-surface-container-high text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border"
>
<UserRound class="size-3.5" />
</span>
<span class="truncate">{{ selectedUser?.name ?? emptyLabel }}</span>
</div>
</template>
+20 -19
View File
@@ -1,24 +1,25 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export { default as Alert } from "./Alert.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default:
"border-outline-variant bg-surface-container-lowest text-card-foreground",
destructive:
"border-destructive/35 text-destructive bg-destructive/8 [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
defaultVariants: {
variant: "default",
},
},
)
);
export type AlertVariants = VariantProps<typeof alertVariants>
export type AlertVariants = VariantProps<typeof alertVariants>;
@@ -6,36 +6,30 @@ import {
AvatarImage,
} from "@/components/ui/avatar/index";
import { usePage } from "@inertiajs/vue3";
import { computed, PropType } from "vue";
import { UserModel } from "@/types/models/user-model";
import { computed } from "vue";
import type { UserAvatarProps } from "@/types";
const props = defineProps({
user: {
type: Object as PropType<UserModel>,
},
rounded: {
type: Boolean,
default: false,
},
})
const props = withDefaults(defineProps<UserAvatarProps>(), {
rounded: true,
});
const page = usePage();
const auth = computed(() => page.props.auth);
const user = computed(() => props.user ?? auth.value.user)
const user = computed(() => props.user ?? auth.value.user);
</script>
<template>
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
<AvatarImage
v-if="user.avatar"
:src="user.avatar"
:alt="user.name"
/>
<Avatar
class="border-outline-variant bg-surface-container-high size-8 overflow-hidden border"
:class="rounded ? 'rounded-full' : 'rounded-lg'"
>
<AvatarImage v-if="user.avatar" :src="user.avatar" :alt="user.name" />
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
class="bg-primary/12 text-on-primary-container text-xs font-bold dark:text-primary-fixed"
:class="rounded ? 'rounded-full' : 'rounded-lg'"
>
{{ getInitials(auth.user?.name) }}
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
</template>
+22 -22
View File
@@ -1,26 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue"
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
"inline-flex items-center justify-center rounded-full border px-2.5 py-1 text-[0.7rem] font-semibold leading-none w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-primary/20 bg-primary/12 text-on-primary-container dark:text-primary-fixed [a&]:hover:bg-primary/18",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-outline-variant bg-surface-container-lowest text-on-surface-variant [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
+33 -34
View File
@@ -1,38 +1,37 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue"
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-[0_6px_18px_rgba(0,110,47,0.14)] hover:-translate-y-px hover:bg-primary/90 hover:shadow-[0_8px_22px_rgba(0,110,47,0.2)]",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border border-outline-variant bg-surface-container-lowest shadow-xs hover:border-outline hover:bg-surface-container-low hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
+15 -15
View File
@@ -1,22 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
<div
data-slot="card"
:class="
cn(
'bg-surface-container-lowest text-card-foreground border-outline-variant flex flex-col gap-6 rounded-xl border py-6 shadow-[0_1px_2px_rgba(16,24,40,0.03)] transition-[border-color,box-shadow] hover:border-outline/65 hover:shadow-kanban-card',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -1,53 +1,62 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes["class"];
showCloseButton?: boolean;
}
>(),
{
showCloseButton: true,
},
);
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-surface-container-lowest border-outline-variant data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-2xl duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -1,21 +1,28 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
import type { DialogOverlayProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DialogOverlay } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<
DialogOverlayProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
props.class,
)
"
>
<slot />
</DialogOverlay>
</template>
@@ -1,39 +1,49 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps,
} from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
defineProps<
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
>(),
{
sideOffset: 4,
},
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground border-outline-variant data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border p-1.5 shadow-xl',
props.class,
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
+24 -22
View File
@@ -1,33 +1,35 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
import type { HTMLAttributes } from "vue";
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number | null
class?: HTMLAttributes["class"]
}>()
defaultValue?: string | number;
modelValue?: string | number | null;
class?: HTMLAttributes["class"];
}>();
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
(e: "update:modelValue", payload: string | number): void;
}>();
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-surface-container-lowest h-10 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>
@@ -1,51 +1,60 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
);
const emits = defineEmits<SelectContentEmits>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground border-outline-variant data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-lg border shadow-xl',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
@@ -1,33 +1,40 @@
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import type { SelectTriggerProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
{ size: "default" },
)
defineProps<
SelectTriggerProps & {
class?: HTMLAttributes["class"];
size?: "sm" | "default";
}
>(),
{ size: "default" },
);
const delegatedProps = reactiveOmit(props, "class", "size")
const forwardedProps = useForwardProps(delegatedProps)
const delegatedProps = reactiveOmit(props, "class", "size");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
'border-input bg-surface-container-lowest data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/25 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
@@ -1,62 +1,65 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import SheetOverlay from "./SheetOverlay.vue"
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import SheetOverlay from "./SheetOverlay.vue";
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: "top" | "right" | "bottom" | "left"
class?: HTMLAttributes["class"];
side?: "top" | "right" | "bottom" | "left";
}
defineOptions({
inheritAttrs: false,
})
inheritAttrs: false,
});
const props = withDefaults(defineProps<SheetContentProps>(), {
side: "right",
})
const emits = defineEmits<DialogContentEmits>()
side: "right",
});
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = reactiveOmit(props, "class", "side")
const delegatedProps = reactiveOmit(props, "class", "side");
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right'
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left'
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top'
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom'
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class)"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogPortal>
<SheetOverlay />
<DialogContent
data-slot="sheet-content"
:class="
cn(
'bg-surface-container-lowest data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-2xl transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -1,21 +1,28 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
import type { DialogOverlayProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DialogOverlay } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const props = defineProps<
DialogOverlayProps & { class?: HTMLAttributes["class"] }
>();
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogOverlay
data-slot="sheet-overlay"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
<DialogOverlay
data-slot="sheet-overlay"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
props.class,
)
"
v-bind="delegatedProps"
>
<slot />
</DialogOverlay>
</template>
+54 -51
View File
@@ -1,60 +1,63 @@
import type { VariantProps } from "class-variance-authority"
import type { HTMLAttributes } from "vue"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "vue";
import { cva } from "class-variance-authority";
export interface SidebarProps {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
class?: HTMLAttributes["class"]
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
class?: HTMLAttributes["class"];
}
export { default as Sidebar } from "./Sidebar.vue"
export { default as SidebarContent } from "./SidebarContent.vue"
export { default as SidebarFooter } from "./SidebarFooter.vue"
export { default as SidebarGroup } from "./SidebarGroup.vue"
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue"
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
export { default as SidebarHeader } from "./SidebarHeader.vue"
export { default as SidebarInput } from "./SidebarInput.vue"
export { default as SidebarInset } from "./SidebarInset.vue"
export { default as SidebarMenu } from "./SidebarMenu.vue"
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue"
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue"
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue"
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue"
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue"
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue"
export { default as SidebarProvider } from "./SidebarProvider.vue"
export { default as SidebarRail } from "./SidebarRail.vue"
export { default as SidebarSeparator } from "./SidebarSeparator.vue"
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
export { default as Sidebar } from "./Sidebar.vue";
export { default as SidebarContent } from "./SidebarContent.vue";
export { default as SidebarFooter } from "./SidebarFooter.vue";
export { default as SidebarGroup } from "./SidebarGroup.vue";
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue";
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue";
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue";
export { default as SidebarHeader } from "./SidebarHeader.vue";
export { default as SidebarInput } from "./SidebarInput.vue";
export { default as SidebarInset } from "./SidebarInset.vue";
export { default as SidebarMenu } from "./SidebarMenu.vue";
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue";
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue";
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue";
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue";
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue";
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue";
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue";
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue";
export { default as SidebarProvider } from "./SidebarProvider.vue";
export { default as SidebarRail } from "./SidebarRail.vue";
export { default as SidebarSeparator } from "./SidebarSeparator.vue";
export { default as SidebarTrigger } from "./SidebarTrigger.vue";
export { useSidebar } from "./utils"
export { useSidebar } from "./utils";
export const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding,background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-primary/12 data-[active=true]:font-semibold data-[active=true]:text-primary dark:data-[active=true]:text-primary-fixed data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default:
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
);
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants>
export type SidebarMenuButtonVariants = VariantProps<
typeof sidebarMenuButtonVariants
>;
+11 -10
View File
@@ -1,8 +1,8 @@
import { createContext } from "reka-ui"
import type { ComputedRef, Ref } from 'vue';
import { createContext } from "reka-ui";
import type { ComputedRef, Ref } from "vue";
export type SidebarContext = {
state: ComputedRef<'expanded' | 'collapsed'>;
state: ComputedRef<"expanded" | "collapsed">;
open: Ref<boolean>;
setOpen: (value: boolean) => void;
isMobile: Ref<boolean>;
@@ -11,11 +11,12 @@ export type SidebarContext = {
toggleSidebar: () => void;
};
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = "16rem"
export const SIDEBAR_WIDTH_MOBILE = "18rem"
export const SIDEBAR_WIDTH_ICON = "3rem"
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "15.5rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
export const [useSidebar, provideSidebarContext] =
createContext<SidebarContext>("Sidebar");
+3 -7
View File
@@ -1,18 +1,14 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types';
import type { AppLayoutProps } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
withDefaults(defineProps<AppLayoutProps>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
<slot />
</AppLayout>
</template>
+3 -5
View File
@@ -1,10 +1,8 @@
<script setup lang="ts">
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
import AuthLayout from '@/layouts/auth/AuthCardLayout.vue';
import type { AuthLayoutProps } from '@/types';
defineProps<{
title?: string;
description?: string;
}>();
defineProps<AuthLayoutProps>();
</script>
<template>
@@ -3,21 +3,17 @@ import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
import type { BreadcrumbItem } from '@/types';
import type { AppLayoutProps } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
withDefaults(defineProps<Props>(), {
withDefaults(defineProps<AppLayoutProps>(), {
breadcrumbs: () => [],
});
</script>
<template>
<AppShell variant="sidebar">
<AppSidebar />
<AppContent variant="sidebar" class="overflow-x-hidden">
<AppSidebar :project="project" />
<AppContent variant="sidebar" class="min-w-0 overflow-x-hidden">
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />
</AppContent>
+40 -16
View File
@@ -9,41 +9,65 @@ import {
CardTitle,
} from '@/components/ui/card';
import { home } from '@/routes/index';
import type { AuthLayoutProps } from '@/types';
defineProps<{
title?: string;
description?: string;
}>();
defineProps<AuthLayoutProps>();
</script>
<template>
<div
class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"
class="bg-background relative flex min-h-svh flex-col items-center justify-center overflow-hidden p-5 sm:p-8"
>
<div class="flex w-full max-w-md flex-col gap-6">
<div
class="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden="true"
>
<div
class="bg-primary/12 absolute -left-24 -top-24 size-80 rounded-full blur-3xl"
/>
<div
class="bg-secondary/55 absolute -bottom-36 -right-20 size-[28rem] rounded-full blur-3xl"
/>
<div
class="absolute inset-0 bg-[linear-gradient(to_right,var(--outline-variant)_1px,transparent_1px),linear-gradient(to_bottom,var(--outline-variant)_1px,transparent_1px)] bg-[size:48px_48px] opacity-[0.08]"
/>
</div>
<div class="relative flex w-full max-w-[28rem] flex-col gap-7">
<Link
:href="home()"
class="flex items-center gap-2 self-center font-medium"
class="group flex items-center gap-3 self-center font-semibold"
>
<div class="flex h-9 w-9 items-center justify-center">
<AppLogoIcon
class="size-9 fill-current text-black dark:text-white"
/>
<div
class="bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-lg shadow-[0_10px_28px_rgba(34,197,94,0.22)] transition-transform group-hover:-translate-y-0.5"
>
<AppLogoIcon class="size-5 fill-current" />
</div>
<span class="text-lg tracking-tight">KanbanGit</span>
</Link>
<div class="flex flex-col gap-6">
<Card class="rounded-xl">
<CardHeader class="px-10 pb-0 pt-8 text-center">
<CardTitle class="text-xl">{{ title }}</CardTitle>
<div class="flex flex-col gap-4">
<Card
class="border-outline-variant/80 rounded-xl py-0 shadow-[0_24px_70px_rgba(10,20,35,0.12)] hover:shadow-[0_24px_70px_rgba(10,20,35,0.12)]"
>
<CardHeader
class="px-6 pb-0 pt-7 text-center sm:px-9 sm:pt-9"
>
<CardTitle
class="text-2xl font-semibold tracking-[-0.025em]"
>{{ title }}</CardTitle
>
<CardDescription>
{{ description }}
</CardDescription>
</CardHeader>
<CardContent class="px-10 py-8">
<CardContent class="px-6 pb-7 pt-7 sm:px-9 sm:pb-9">
<slot />
</CardContent>
</Card>
<p class="text-muted-foreground text-center text-xs">
Open source project management, hosted on your terms.
</p>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More