WIP: Redesign app layout chrome #105
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Notifications;
|
namespace App\Actions\Notifications;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
]),
|
||||||
'templateVariables' => $this->templateVariables(),
|
'templateVariables' => $this->templateVariables(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class BuildProjectBacklogPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'canCreateTickets' => $canManageTickets,
|
'canCreateTickets' => $canManageTickets,
|
||||||
'canEditTickets' => $canManageTickets,
|
'canEditTickets' => $canManageTickets,
|
||||||
|
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
'ticketStatusOptions' => TicketStatusResource::collection(
|
'ticketStatusOptions' => TicketStatusResource::collection(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
|||||||
'ticketStatuses',
|
'ticketStatuses',
|
||||||
'tickets' => fn ($query) => $query
|
'tickets' => fn ($query) => $query
|
||||||
->with([
|
->with([
|
||||||
|
'assignee',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
@@ -39,6 +40,7 @@ class BuildProjectBoardPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'canCreateTickets' => $canManageTickets,
|
'canCreateTickets' => $canManageTickets,
|
||||||
'canEditTickets' => $canManageTickets,
|
'canEditTickets' => $canManageTickets,
|
||||||
|
'canManageProject' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use App\Models\Project;
|
|||||||
|
|
||||||
class BuildProjectBoardSettingsPayload
|
class BuildProjectBoardSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
]),
|
||||||
'ticketStatuses' => TicketStatusResource::collection(
|
'ticketStatuses' => TicketStatusResource::collection(
|
||||||
$project->ticketStatuses()
|
$project->ticketStatuses()
|
||||||
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
->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',
|
'fieldValues.userValue',
|
||||||
'parentTicket',
|
'parentTicket',
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'ticketType',
|
||||||
'worklogEntries.actor',
|
'worklogEntries.actor',
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
|
|||||||
|
|
||||||
class BuildProjectTicketMetadataSettingsPayload
|
class BuildProjectTicketMetadataSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'status' => $status,
|
'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(),
|
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->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\TicketReferenceResource;
|
||||||
use App\Http\Resources\TicketStatusResource;
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Http\Resources\TicketTypeResource;
|
use App\Http\Resources\TicketTypeResource;
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
class BuildProjectTicketOptionsPayload
|
class BuildProjectTicketOptionsPayload
|
||||||
|
|||||||
@@ -6,25 +6,80 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CreateProject
|
class CreateProject
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||||
private readonly PermissionService $permissionService,
|
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
|
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);
|
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
$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\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderManager;
|
use App\Actions\Git\GitProviderManager;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\RepositoryConnectionResource;
|
use App\Http\Resources\RepositoryConnectionResource;
|
||||||
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GitProviderManager $gitProviderManager,
|
private readonly GitProviderManager $gitProviderManager,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ class CreateProjectTicket
|
|||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
$ticketStatus = TicketStatus::query()
|
$ticketStatusQuery = TicketStatus::query()
|
||||||
->where('project_id', $project->id)
|
->where(
|
||||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
$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([
|
$ticket = Ticket::query()->create([
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
|
|||||||
@@ -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\Actions\Tickets\MoveProjectTicket;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\MoveProjectTicketRequest;
|
use App\Http\Requests\MoveProjectTicketRequest;
|
||||||
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -42,7 +44,7 @@ class ProjectBoardController extends Controller
|
|||||||
MoveProjectTicketRequest $request,
|
MoveProjectTicketRequest $request,
|
||||||
Project $project,
|
Project $project,
|
||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
): RedirectResponse {
|
): RedirectResponse|JsonResponse {
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
abort_unless(
|
abort_unless(
|
||||||
@@ -56,13 +58,32 @@ class ProjectBoardController extends Controller
|
|||||||
: 'projects.board.index';
|
: 'projects.board.index';
|
||||||
|
|
||||||
$result = $this->moveProjectTicket->handle($project, $ticket, $user, $validated);
|
$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']) {
|
if (! $result['moved']) {
|
||||||
return to_route($redirectRoute, $project)
|
return to_route($redirectRoute, $project)
|
||||||
->with('status', "{$ticket->ticket_key} already belongs to {$result['destination_label']}.");
|
->with('status', $statusMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return to_route($redirectRoute, $project)
|
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\UpdateBoardColumn;
|
||||||
use App\Actions\ProjectBoard\UpdateTicketStatus;
|
use App\Actions\ProjectBoard\UpdateTicketStatus;
|
||||||
use App\Actions\ProjectBoard\UpdateTicketStatusTransitions;
|
use App\Actions\ProjectBoard\UpdateTicketStatusTransitions;
|
||||||
use App\Actions\Projects\BuildProjectBoardSettingsPayload;
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProjectSettingsSection;
|
||||||
use App\Http\Requests\StoreBoardColumnRequest;
|
use App\Http\Requests\StoreBoardColumnRequest;
|
||||||
use App\Http\Requests\StoreTicketStatusRequest;
|
use App\Http\Requests\StoreTicketStatusRequest;
|
||||||
use App\Http\Requests\UpdateBoardColumnRequest;
|
use App\Http\Requests\UpdateBoardColumnRequest;
|
||||||
@@ -22,13 +22,10 @@ use App\Models\Project;
|
|||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProjectBoardSettingsController extends Controller
|
class ProjectBoardSettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildProjectBoardSettingsPayload $buildProjectBoardSettingsPayload,
|
|
||||||
private readonly DeleteBoardColumn $deleteBoardColumn,
|
private readonly DeleteBoardColumn $deleteBoardColumn,
|
||||||
private readonly DeleteTicketStatus $deleteTicketStatus,
|
private readonly DeleteTicketStatus $deleteTicketStatus,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
@@ -39,19 +36,6 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
private readonly UpdateTicketStatusTransitions $updateTicketStatusTransitions,
|
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(
|
public function store(
|
||||||
StoreBoardColumnRequest $request,
|
StoreBoardColumnRequest $request,
|
||||||
Project $project,
|
Project $project,
|
||||||
@@ -62,7 +46,7 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
);
|
);
|
||||||
$boardColumn = $this->storeBoardColumn->handle($project, $request->validated());
|
$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.");
|
->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());
|
$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.");
|
->with('status', "{$boardColumn->fresh()->name} was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +77,7 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
|
|
||||||
$deletedName = $this->deleteBoardColumn->handle($project, $boardColumn);
|
$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.");
|
->with('status', "{$deletedName} was removed from the board workflow.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +91,7 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
);
|
);
|
||||||
$ticketStatus = $this->storeTicketStatus->handle($project, $request->validated());
|
$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)}.");
|
->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());
|
$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.");
|
->with('status', "{$ticketStatus->fresh()->name} was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +122,7 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
|
|
||||||
$result = $this->deleteTicketStatus->handle($project, $ticketStatus);
|
$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']}.");
|
->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());
|
$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.");
|
->with('status', "{$ticketStatus->name} move targets were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,4 +145,12 @@ class ProjectBoardSettingsController extends Controller
|
|||||||
{
|
{
|
||||||
return $ticketStatus->boardColumn?->name ?? 'Backlog';
|
return $ticketStatus->boardColumn?->name ?? 'Backlog';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function settingsRedirect(Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
return to_route('projects.settings.index', [
|
||||||
|
'project' => $project,
|
||||||
|
'section' => ProjectSettingsSection::Board->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||||
use App\Actions\Projects\CreateProject;
|
use App\Actions\Projects\CreateProject;
|
||||||
use App\Actions\Projects\DeleteProject;
|
use App\Actions\Projects\DeleteProject;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\Projects\ListVisibleProjects;
|
use App\Actions\Projects\ListVisibleProjects;
|
||||||
|
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProjectSettingsSection;
|
||||||
use App\Http\Requests\DeleteProjectRequest;
|
use App\Http\Requests\DeleteProjectRequest;
|
||||||
|
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||||
use App\Http\Requests\StoreProjectRequest;
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||||
use App\Http\Resources\PermissionOptionResource;
|
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\TeamResource;
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
use App\Http\Resources\UserResource;
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -32,6 +36,7 @@ class ProjectController extends Controller
|
|||||||
private readonly DeleteProject $deleteProject,
|
private readonly DeleteProject $deleteProject,
|
||||||
private readonly ListVisibleProjects $listVisibleProjects,
|
private readonly ListVisibleProjects $listVisibleProjects,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -51,17 +56,33 @@ class ProjectController extends Controller
|
|||||||
403,
|
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
|
public function store(StoreProjectRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$project = $this->createProject->handle($request->validated(), $request->user());
|
$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();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -70,10 +91,7 @@ class ProjectController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/Show', [
|
return to_route('projects.board.index', $project);
|
||||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function backlog(Request $request, Project $project): Response
|
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', [
|
$validated = $request->validated();
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
$template = ProjectTemplate::query()
|
||||||
'status' => session('status'),
|
->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();
|
$request->validated();
|
||||||
abort_unless(
|
|
||||||
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Inertia::render('projects/Access', [
|
$template = $project->projectTemplate;
|
||||||
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
|
|
||||||
'permissionOptions' => PermissionOptionResource::collection(
|
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||||
$this->permissionService->permissionOptions(User::class, Project::class),
|
|
||||||
)->resolve(),
|
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
|
||||||
'status' => session('status'),
|
return to_route('projects.settings.index', [
|
||||||
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
|
'project' => $project,
|
||||||
'teams' => TeamResource::collection(
|
'section' => ProjectSettingsSection::General->value,
|
||||||
Team::query()
|
])
|
||||||
->with('users')
|
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
)->resolve(),
|
|
||||||
'users' => UserResource::collection(
|
|
||||||
User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
)->resolve(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUserPermissions(
|
public function updateUserPermissions(
|
||||||
@@ -145,7 +158,10 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($user, $project, $permissions);
|
$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.");
|
->with('status', "{$user->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +180,10 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$this->permissionService->sync($team, $project, $permissions);
|
$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.");
|
->with('status', "{$team->name} permissions were updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,41 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Notifications\BuildProjectNotificationSettingsPayload;
|
|
||||||
use App\Actions\Notifications\DeleteProjectNotificationAction;
|
use App\Actions\Notifications\DeleteProjectNotificationAction;
|
||||||
use App\Actions\Notifications\StoreProjectNotificationAction;
|
use App\Actions\Notifications\StoreProjectNotificationAction;
|
||||||
use App\Actions\Notifications\UpdateProjectNotificationAction;
|
use App\Actions\Notifications\UpdateProjectNotificationAction;
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProjectSettingsSection;
|
||||||
use App\Http\Requests\StoreProjectNotificationActionRequest;
|
use App\Http\Requests\StoreProjectNotificationActionRequest;
|
||||||
use App\Http\Requests\UpdateProjectNotificationActionRequest;
|
use App\Http\Requests\UpdateProjectNotificationActionRequest;
|
||||||
use App\Models\NotificationAction;
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProjectNotificationSettingsController extends Controller
|
class ProjectNotificationSettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildProjectNotificationSettingsPayload $buildProjectNotificationSettingsPayload,
|
|
||||||
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
|
private readonly DeleteProjectNotificationAction $deleteProjectNotificationAction,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
|
private readonly StoreProjectNotificationAction $storeProjectNotificationAction,
|
||||||
private readonly UpdateProjectNotificationAction $updateProjectNotificationAction,
|
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(
|
public function store(
|
||||||
StoreProjectNotificationActionRequest $request,
|
StoreProjectNotificationActionRequest $request,
|
||||||
Project $project,
|
Project $project,
|
||||||
@@ -45,7 +32,7 @@ class ProjectNotificationSettingsController extends Controller
|
|||||||
|
|
||||||
$notificationAction = $this->storeProjectNotificationAction->handle($project, $request->validated());
|
$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.");
|
->with('status', "{$notificationAction->name} notification was added.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +49,7 @@ class ProjectNotificationSettingsController extends Controller
|
|||||||
$request->validated(),
|
$request->validated(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return to_route('projects.settings.notifications', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$notificationAction->name} notification was updated.");
|
->with('status', "{$notificationAction->name} notification was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +67,7 @@ class ProjectNotificationSettingsController extends Controller
|
|||||||
|
|
||||||
$this->deleteProjectNotificationAction->handle($project, $notificationAction);
|
$this->deleteProjectNotificationAction->handle($project, $notificationAction);
|
||||||
|
|
||||||
return to_route('projects.settings.notifications', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$deletedName} notification was deleted.");
|
->with('status', "{$deletedName} notification was deleted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,4 +78,12 @@ class ProjectNotificationSettingsController extends Controller
|
|||||||
403,
|
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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Repositories\BuildProjectRepositorySettingsPayload;
|
|
||||||
use App\Actions\Repositories\DeleteProjectRepositoryConnection;
|
use App\Actions\Repositories\DeleteProjectRepositoryConnection;
|
||||||
use App\Actions\Repositories\StoreProjectRepositoryConnection;
|
use App\Actions\Repositories\StoreProjectRepositoryConnection;
|
||||||
use App\Actions\Repositories\UpdateProjectRepositoryConnection;
|
use App\Actions\Repositories\UpdateProjectRepositoryConnection;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProjectSettingsSection;
|
||||||
use App\Http\Requests\StoreRepositoryConnectionRequest;
|
use App\Http\Requests\StoreRepositoryConnectionRequest;
|
||||||
use App\Http\Requests\UpdateRepositoryConnectionRequest;
|
use App\Http\Requests\UpdateRepositoryConnectionRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProjectRepositorySettingsController extends Controller
|
class ProjectRepositorySettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildProjectRepositorySettingsPayload $buildProjectRepositorySettingsPayload,
|
|
||||||
private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection,
|
private readonly DeleteProjectRepositoryConnection $deleteProjectRepositoryConnection,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection,
|
private readonly StoreProjectRepositoryConnection $storeProjectRepositoryConnection,
|
||||||
private readonly UpdateProjectRepositoryConnection $updateProjectRepositoryConnection,
|
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(
|
public function store(
|
||||||
StoreRepositoryConnectionRequest $request,
|
StoreRepositoryConnectionRequest $request,
|
||||||
Project $project,
|
Project $project,
|
||||||
@@ -53,7 +36,7 @@ class ProjectRepositorySettingsController extends Controller
|
|||||||
|
|
||||||
$repositoryConnection = $this->storeProjectRepositoryConnection->handle($project, $user, $request->validated());
|
$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}.");
|
->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +59,7 @@ class ProjectRepositorySettingsController extends Controller
|
|||||||
$request->validated(),
|
$request->validated(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return to_route('projects.settings.repositories', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
|
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +75,15 @@ class ProjectRepositorySettingsController extends Controller
|
|||||||
|
|
||||||
$repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection);
|
$repositoryLabel = $this->deleteProjectRepositoryConnection->handle($repositoryConnection);
|
||||||
|
|
||||||
return to_route('projects.settings.repositories', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$repositoryLabel} was removed from the 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\BuildProjectTicketDetailPayload;
|
||||||
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
use App\Actions\Projects\BuildProjectTicketOptionsPayload;
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
|
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\StoreProjectTicketRequest;
|
use App\Http\Requests\StoreProjectTicketRequest;
|
||||||
@@ -40,11 +41,18 @@ class ProjectTicketController extends Controller
|
|||||||
|
|
||||||
return Inertia::render(
|
return Inertia::render(
|
||||||
'projects/TicketCreate',
|
'projects/TicketCreate',
|
||||||
$this->buildProjectTicketOptionsPayload->handle(
|
[
|
||||||
$project,
|
...$this->buildProjectTicketOptionsPayload->handle(
|
||||||
Arr::get($request->query(), 'source') === 'board' ? 'board' : 'backlog',
|
$project,
|
||||||
session('status'),
|
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()) {
|
if ($request->expectsJson()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModelsWithIds(
|
||||||
|
$ticket->acceptanceCriteria()->get(),
|
||||||
|
),
|
||||||
'message' => $statusMessage,
|
'message' => $statusMessage,
|
||||||
'updated' => $wasUpdated,
|
'updated' => $wasUpdated,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Projects\BuildProjectTicketMetadataSettingsPayload;
|
|
||||||
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority;
|
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketPriority;
|
||||||
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType;
|
use App\Actions\ProjectTicketMetadata\DeleteProjectTicketType;
|
||||||
use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition;
|
use App\Actions\ProjectTicketMetadata\DeleteTicketFieldDefinition;
|
||||||
@@ -18,6 +17,7 @@ use App\Actions\ProjectTicketMetadata\UpdateProjectTicketType;
|
|||||||
use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition;
|
use App\Actions\ProjectTicketMetadata\UpdateTicketFieldDefinition;
|
||||||
use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType;
|
use App\Actions\ProjectTicketMetadata\UpdateTicketLinkType;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\ProjectSettingsSection;
|
||||||
use App\Http\Requests\DestroyProjectTicketTypeRequest;
|
use App\Http\Requests\DestroyProjectTicketTypeRequest;
|
||||||
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
||||||
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
||||||
@@ -35,13 +35,10 @@ use App\Models\TicketPriority;
|
|||||||
use App\Models\TicketType;
|
use App\Models\TicketType;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProjectTicketMetadataSettingsController extends Controller
|
class ProjectTicketMetadataSettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BuildProjectTicketMetadataSettingsPayload $buildProjectTicketMetadataSettingsPayload,
|
|
||||||
private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority,
|
private readonly DeleteProjectTicketPriority $deleteProjectTicketPriority,
|
||||||
private readonly DeleteProjectTicketType $deleteProjectTicketType,
|
private readonly DeleteProjectTicketType $deleteProjectTicketType,
|
||||||
private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition,
|
private readonly DeleteTicketFieldDefinition $deleteTicketFieldDefinition,
|
||||||
@@ -58,23 +55,13 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
private readonly UpdateTicketLinkType $updateTicketLinkType,
|
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
|
public function update(UpdateProjectTicketMetadataSettings $request, Project $project): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->abortUnlessCanManage($request, $project);
|
$this->abortUnlessCanManage($request, $project);
|
||||||
|
|
||||||
$this->updateProjectTicketMetadataSettings->handle($project, $request->validated());
|
$this->updateProjectTicketMetadataSettings->handle($project, $request->validated());
|
||||||
|
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', 'Settings have been updated.');
|
->with('status', 'Settings have been updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +71,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketType = $this->storeProjectTicketType->handle($project, $request->validated());
|
$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.");
|
->with('status', "{$ticketType->name} type was added.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +84,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketType = $this->updateProjectTicketType->handle($project, $ticketType, $request->validated());
|
$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.");
|
->with('status', "{$ticketType->fresh()->name} type was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +103,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (! $result['deleted']) {
|
if (! $result['deleted']) {
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('error', true)
|
->with('error', true)
|
||||||
->with('status', $result['message']);
|
->with('status', $result['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', $result['message']);
|
->with('status', $result['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +118,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketPriority = $this->storeProjectTicketPriority->handle($project, $request->validated());
|
$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.");
|
->with('status', "{$ticketPriority->name} priority was added.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +131,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketPriority = $this->updateProjectTicketPriority->handle($project, $ticketPriority, $request->validated());
|
$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.");
|
->with('status', "{$ticketPriority->fresh()->name} priority was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +145,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
$result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority);
|
$result = $this->deleteProjectTicketPriority->handle($project, $ticketPriority);
|
||||||
|
|
||||||
if (! $result['deleted']) {
|
if (! $result['deleted']) {
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('error', true)
|
->with('error', true)
|
||||||
->with('status', $result['message']);
|
->with('status', $result['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', $result['message']);
|
->with('status', $result['message']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +162,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketFieldDefinition = $this->storeTicketFieldDefinition->handle($project, $request->validated());
|
$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.");
|
->with('status', "{$ticketFieldDefinition->label} field was added.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +175,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketFieldDefinition = $this->updateTicketFieldDefinition->handle($project, $ticketFieldDefinition, $request->validated());
|
$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.");
|
->with('status', "{$ticketFieldDefinition->fresh()->label} field was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +188,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition);
|
$deletedLabel = $this->deleteTicketFieldDefinition->handle($project, $ticketFieldDefinition);
|
||||||
|
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$deletedLabel} field was removed.");
|
->with('status', "{$deletedLabel} field was removed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +198,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketLinkType = $this->storeTicketLinkType->handle($project, $request->validated());
|
$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.");
|
->with('status', "{$ticketLinkType->label} link type was added.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +212,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$ticketLinkType = $this->updateTicketLinkType->handle($project, $ticketLinkType, $request->validated());
|
$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.");
|
->with('status', "{$ticketLinkType->fresh()->label} link type was updated.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +225,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
|
|
||||||
$deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType);
|
$deletedLabel = $this->deleteTicketLinkType->handle($project, $ticketLinkType);
|
||||||
|
|
||||||
return to_route('projects.settings.metadata', $project)
|
return $this->settingsRedirect($project)
|
||||||
->with('status', "{$deletedLabel} link type was removed.");
|
->with('status', "{$deletedLabel} link type was removed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,4 +236,12 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
403,
|
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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'replacement_ticket_type_id' => [
|
'replacement_ticket_type_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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'])],
|
'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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
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\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
|
|||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$key = Arr::get($this->all(), 'key');
|
$key = Arr::get($this->all(), 'key');
|
||||||
|
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
|
||||||
|
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
|
||||||
|
|
||||||
if ($key !== null) {
|
if ($key !== null) {
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'key' => strtoupper(trim((string) $key)),
|
'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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
|
||||||
'key' => [
|
'key' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
|
|||||||
Rule::unique('projects', 'key'),
|
Rule::unique('projects', 'key'),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreProjectTicketBranchRequest extends FormRequest
|
class StoreProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
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 [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'linked_ticket_id' => [
|
'linked_ticket_id' => [
|
||||||
@@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
'ticket_link_type_id' => [
|
'ticket_link_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'board_column_id' => [
|
'board_column_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'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'],
|
'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 */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'field_values.*.ticket_field_definition_id' => [
|
'field_values.*.ticket_field_definition_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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_number' => ['nullable', 'numeric'],
|
||||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
@@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'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'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
|
|
||||||
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
||||||
$requestedTicketType = TicketType::query()
|
$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);
|
->find($requestedTicketTypeId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||||
'allowed_ticket_status_ids.*' => [
|
'allowed_ticket_status_ids.*' => [
|
||||||
'integer',
|
'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;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ValidateProjectTicketBranchRequest extends FormRequest
|
class ValidateProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
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 [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'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),
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class ProjectResource extends JsonResource
|
class ProjectResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -14,10 +16,32 @@ class ProjectResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$this->resource->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'key' => $this->key,
|
'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_author' => $this->show_author,
|
||||||
'show_story_points' => $this->show_story_points,
|
'show_story_points' => $this->show_story_points,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\BoardColumnFactory;
|
use Database\Factories\BoardColumnFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class BoardColumn extends Model
|
class BoardColumn extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BoardColumnFactory> */
|
/** @use HasFactory<BoardColumnFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $boardColumn): void {
|
||||||
|
$boardColumn->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\NotificationActionFactory;
|
use Database\Factories\NotificationActionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'message_template',
|
'message_template',
|
||||||
'name',
|
'name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
])]
|
])]
|
||||||
#[Hidden(['delivery_url'])]
|
#[Hidden(['delivery_url'])]
|
||||||
class NotificationAction extends Model
|
class NotificationAction extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<NotificationActionFactory> */
|
/** @use HasFactory<NotificationActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @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
|
public function scopeEnabled(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('enabled', true);
|
return $query->where('enabled', true);
|
||||||
|
|||||||
+14
-1
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Str;
|
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
|
class Project extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ProjectFactory> */
|
/** @use HasFactory<ProjectFactory> */
|
||||||
@@ -44,6 +46,17 @@ class Project extends Model
|
|||||||
return $this->morphMany(PermissionAssignment::class, 'scope');
|
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
|
public function boardColumns(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BoardColumn::class)
|
return $this->hasMany(BoardColumn::class)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Actions\Git\Data\GitRepository;
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderKey;
|
use App\Actions\Git\GitProviderKey;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\RepositoryConnectionFactory;
|
use Database\Factories\RepositoryConnectionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'base_url',
|
'base_url',
|
||||||
'owner_name',
|
'owner_name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
'provider',
|
'provider',
|
||||||
'provider_repository_id',
|
'provider_repository_id',
|
||||||
'repository_full_name',
|
'repository_full_name',
|
||||||
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @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
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketFieldDefinitionFactory;
|
use Database\Factories\TicketFieldDefinitionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketFieldDefinition extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
|
|||||||
|
|
||||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketFieldDefinition): void {
|
||||||
|
$ticketFieldDefinition->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketLinkTypeFactory;
|
use Database\Factories\TicketLinkTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketLinkType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketLinkType): void {
|
||||||
|
$ticketLinkType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function counterpartLabel(): string
|
public function counterpartLabel(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketPriorityFactory;
|
use Database\Factories\TicketPriorityFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketPriority extends Model
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketPriority): void {
|
||||||
|
$ticketPriority->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketStatusFactory;
|
use Database\Factories\TicketStatusFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
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
|
class TicketStatus extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketStatusFactory> */
|
/** @use HasFactory<TicketStatusFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::saving(function (self $ticketStatus): 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);
|
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketTypeFactory;
|
use Database\Factories\TicketTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketTypeFactory> */
|
/** @use HasFactory<TicketTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketType): void {
|
||||||
|
$ticketType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
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
@@ -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 '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@@ -9,13 +9,69 @@
|
|||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
Inter, Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
'Noto Color Emoji';
|
"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-sm: 0.25rem;
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: 0.25rem;
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--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-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -59,6 +115,54 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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,
|
body,
|
||||||
html {
|
html {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
Inter, "Instrument Sans", ui-sans-serif, system-ui, sans-serif,
|
||||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
|
||||||
'Noto Color Emoji';
|
"Noto Color Emoji";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: hsl(0 0% 100%);
|
--background: #f7f9fb;
|
||||||
--foreground: hsl(0 0% 3.9%);
|
--foreground: #191c1e;
|
||||||
--card: hsl(0 0% 100%);
|
--card: #ffffff;
|
||||||
--card-foreground: hsl(0 0% 3.9%);
|
--card-foreground: #191c1e;
|
||||||
--popover: hsl(0 0% 100%);
|
--popover: #ffffff;
|
||||||
--popover-foreground: hsl(0 0% 3.9%);
|
--popover-foreground: #191c1e;
|
||||||
--primary: hsl(0 0% 9%);
|
--primary: #006e2f;
|
||||||
--primary-foreground: hsl(0 0% 98%);
|
--primary-foreground: #ffffff;
|
||||||
--secondary: hsl(0 0% 92.1%);
|
--secondary: #d0e1fb;
|
||||||
--secondary-foreground: hsl(0 0% 9%);
|
--secondary-foreground: #0b1c30;
|
||||||
--muted: hsl(0 0% 96.1%);
|
--muted: #f2f4f6;
|
||||||
--muted-foreground: hsl(0 0% 45.1%);
|
--muted-foreground: #3d4a3d;
|
||||||
--accent: hsl(0 0% 96.1%);
|
--accent: #e6e8ea;
|
||||||
--accent-foreground: hsl(0 0% 9%);
|
--accent-foreground: #191c1e;
|
||||||
--destructive: hsl(0 84.2% 60.2%);
|
--destructive: #ba1a1a;
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: #ffffff;
|
||||||
--border: hsl(0 0% 92.8%);
|
--border: #bccbb9;
|
||||||
--input: hsl(0 0% 89.8%);
|
--input: #bccbb9;
|
||||||
--ring: hsl(0 0% 3.9%);
|
--ring: #006e2f;
|
||||||
--chart-1: hsl(12 76% 61%);
|
--chart-1: #22c55e;
|
||||||
--chart-2: hsl(173 58% 39%);
|
--chart-2: #3b82f6;
|
||||||
--chart-3: hsl(197 37% 24%);
|
--chart-3: #f59e0b;
|
||||||
--chart-4: hsl(43 74% 66%);
|
--chart-4: #ef4444;
|
||||||
--chart-5: hsl(27 87% 67%);
|
--chart-5: #505f76;
|
||||||
--radius: 0.5rem;
|
--radius: 0.25rem;
|
||||||
--sidebar-background: hsl(0 0% 98%);
|
--surface: #f7f9fb;
|
||||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
--surface-dim: #d8dadc;
|
||||||
--sidebar-primary: hsl(0 0% 10%);
|
--surface-bright: #f7f9fb;
|
||||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
--surface-container-lowest: #ffffff;
|
||||||
--sidebar-accent: hsl(0 0% 94%);
|
--surface-container-low: #f2f4f6;
|
||||||
--sidebar-accent-foreground: hsl(0 0% 30%);
|
--surface-container: #eceef0;
|
||||||
--sidebar-border: hsl(0 0% 91%);
|
--surface-container-high: #e6e8ea;
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--surface-container-highest: #e0e3e5;
|
||||||
--sidebar: hsl(0 0% 98%);
|
--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 {
|
.dark {
|
||||||
--background: hsl(0 0% 3.9%);
|
--background: #0b1326;
|
||||||
--foreground: hsl(0 0% 98%);
|
--foreground: #e5eaf5;
|
||||||
--card: hsl(0 0% 3.9%);
|
--card: #131b2e;
|
||||||
--card-foreground: hsl(0 0% 98%);
|
--card-foreground: #e5eaf5;
|
||||||
--popover: hsl(0 0% 3.9%);
|
--popover: #171f33;
|
||||||
--popover-foreground: hsl(0 0% 98%);
|
--popover-foreground: #e5eaf5;
|
||||||
--primary: hsl(0 0% 98%);
|
--primary: #4be277;
|
||||||
--primary-foreground: hsl(0 0% 9%);
|
--primary-foreground: #002109;
|
||||||
--secondary: hsl(0 0% 14.9%);
|
--secondary: #222a3d;
|
||||||
--secondary-foreground: hsl(0 0% 98%);
|
--secondary-foreground: #e5eaf5;
|
||||||
--muted: hsl(0 0% 16.08%);
|
--muted: #171f33;
|
||||||
--muted-foreground: hsl(0 0% 63.9%);
|
--muted-foreground: #aeb8cd;
|
||||||
--accent: hsl(0 0% 14.9%);
|
--accent: #222a3d;
|
||||||
--accent-foreground: hsl(0 0% 98%);
|
--accent-foreground: #f3f6fc;
|
||||||
--destructive: hsl(0 84% 60%);
|
--destructive: #ffb4ab;
|
||||||
--destructive-foreground: hsl(0 0% 98%);
|
--destructive-foreground: #690005;
|
||||||
--border: hsl(0 0% 14.9%);
|
--border: #3b4358;
|
||||||
--input: hsl(0 0% 14.9%);
|
--input: #4a5268;
|
||||||
--ring: hsl(0 0% 83.1%);
|
--ring: #4be277;
|
||||||
--chart-1: hsl(220 70% 50%);
|
--chart-1: #4be277;
|
||||||
--chart-2: hsl(160 60% 45%);
|
--chart-2: #60a5fa;
|
||||||
--chart-3: hsl(30 80% 55%);
|
--chart-3: #fbbf24;
|
||||||
--chart-4: hsl(280 65% 60%);
|
--chart-4: #f87171;
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-5: #b7c8e1;
|
||||||
--sidebar-background: hsl(0 0% 7%);
|
--surface: #0b1326;
|
||||||
--sidebar-foreground: hsl(0 0% 95.9%);
|
--surface-dim: #060e20;
|
||||||
--sidebar-primary: hsl(360, 100%, 100%);
|
--surface-bright: #2d3449;
|
||||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
--surface-container-lowest: #060e20;
|
||||||
--sidebar-accent: hsl(0 0% 15.9%);
|
--surface-container-low: #131b2e;
|
||||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
--surface-container: #171f33;
|
||||||
--sidebar-border: hsl(0 0% 15.9%);
|
--surface-container-high: #222a3d;
|
||||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
--surface-container-highest: #2d3449;
|
||||||
--sidebar: hsl(240 5.9% 10%);
|
--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 {
|
@layer base {
|
||||||
@@ -167,6 +359,74 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
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
@@ -20,7 +20,7 @@ createInertiaApp({
|
|||||||
.mount(el);
|
.mount(el);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: '#4B5563',
|
color: '#22c55e',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { SidebarInset } from '@/components/ui/sidebar';
|
import { SidebarInset } from '@/components/ui/sidebar';
|
||||||
import type { AppVariant } from '@/types';
|
import type { AppContentProps } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
const props = withDefaults(defineProps<AppContentProps>(), {
|
||||||
variant?: AppVariant;
|
|
||||||
class?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
variant: 'sidebar',
|
variant: 'sidebar',
|
||||||
});
|
});
|
||||||
const className = computed(() => props.class);
|
const className = computed(() => props.class);
|
||||||
@@ -20,7 +15,7 @@ const className = computed(() => props.class);
|
|||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
<main
|
<main
|
||||||
v-else
|
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"
|
:class="className"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
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 { computed } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -11,12 +17,6 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuList,
|
|
||||||
navigationMenuTriggerStyle,
|
|
||||||
} from '@/components/ui/navigation-menu';
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -24,246 +24,184 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import {
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { dashboard } from '@/routes';
|
||||||
import { dashboard } from '@/routes/index';
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import { index as projectsIndex } from '@/routes/projects';
|
||||||
|
import { index as teamsIndex } from '@/routes/teams';
|
||||||
|
import type { AppHeaderProps, NavItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
const props = withDefaults(defineProps<AppHeaderProps>(), {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
breadcrumbs: () => [],
|
breadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth);
|
||||||
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
|
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||||
|
|
||||||
const activeItemStyles =
|
const mainNavItems = computed<NavItem[]>(() => {
|
||||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
const items: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
href: dashboard(),
|
||||||
|
icon: LayoutGrid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Projects',
|
||||||
|
href: projectsIndex(),
|
||||||
|
icon: Folder,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
if (page.props.auth.permissions.projectCreate) {
|
||||||
{
|
items.push({
|
||||||
title: 'Dashboard',
|
title: 'Project templates',
|
||||||
href: dashboard(),
|
href: projectTemplatesIndex(),
|
||||||
icon: LayoutGrid,
|
icon: Layers3,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
const rightNavItems: NavItem[] = [
|
if (page.props.auth.permissions.globalAdmin) {
|
||||||
{
|
items.push({
|
||||||
title: 'Repository',
|
title: 'Users',
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
href: adminUsers(),
|
||||||
icon: Folder,
|
icon: Shield,
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
title: 'Documentation',
|
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
if (page.props.auth.permissions.canManageTeams) {
|
||||||
icon: BookOpen,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<header
|
||||||
<div class="border-sidebar-border/80 border-b">
|
class="border-outline-variant/80 bg-surface/90 shadow-kanban-nav sticky top-0 z-50 border-b backdrop-blur-md"
|
||||||
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
>
|
||||||
<!-- Mobile Menu -->
|
<div
|
||||||
<div class="lg:hidden">
|
class="sm:px-container-margin flex h-16 w-full items-center gap-3 px-4"
|
||||||
<Sheet>
|
>
|
||||||
<SheetTrigger :as-child="true">
|
<Sheet>
|
||||||
<Button
|
<SheetTrigger :as-child="true">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
class="mr-2 h-9 w-9"
|
size="icon"
|
||||||
>
|
class="text-on-surface-variant hover:bg-surface-container-high hover:text-on-surface size-9 rounded-lg lg:hidden"
|
||||||
<Menu class="h-5 w-5" />
|
>
|
||||||
</Button>
|
<Menu class="size-5" />
|
||||||
</SheetTrigger>
|
<span class="sr-only">Open navigation</span>
|
||||||
<SheetContent side="left" class="w-[300px] p-6">
|
</Button>
|
||||||
<SheetTitle class="sr-only"
|
</SheetTrigger>
|
||||||
>Navigation menu</SheetTitle
|
<SheetContent
|
||||||
>
|
side="left"
|
||||||
<SheetHeader class="flex justify-start text-left">
|
class="border-outline-variant bg-surface w-[320px] p-0"
|
||||||
<AppLogoIcon
|
>
|
||||||
class="size-6 fill-current text-black dark:text-white"
|
<SheetTitle class="sr-only">Navigation menu</SheetTitle>
|
||||||
/>
|
<SheetHeader class="border-outline-variant border-b p-4">
|
||||||
</SheetHeader>
|
<Link
|
||||||
<div
|
:href="dashboard()"
|
||||||
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
|
class="flex items-center gap-3"
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
<NavigationMenuItem
|
<AppLogo />
|
||||||
v-for="(item, index) in mainNavItems"
|
</Link>
|
||||||
:key="index"
|
</SheetHeader>
|
||||||
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>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center space-x-2">
|
<div class="flex flex-col gap-5 p-4">
|
||||||
<div class="relative flex items-center space-x-1">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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
|
<UserAvatar rounded />
|
||||||
class="size-5 opacity-80 group-hover:opacity-100"
|
<span class="sr-only">Open user menu</span>
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
<div class="hidden space-x-1 lg:flex">
|
<DropdownMenuContent
|
||||||
<template
|
align="end"
|
||||||
v-for="item in rightNavItems"
|
class="border-outline-variant bg-popover w-56"
|
||||||
:key="item.title"
|
>
|
||||||
>
|
<UserMenuContent :user="auth.user" />
|
||||||
<TooltipProvider :delay-duration="0">
|
</DropdownMenuContent>
|
||||||
<Tooltip>
|
</DropdownMenu>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="props.breadcrumbs.length > 1"
|
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
|
<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" />
|
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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>
|
||||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||||
<span class="mb-0.5 truncate font-semibold leading-tight"
|
<span class="text-primary truncate text-sm font-semibold leading-tight">
|
||||||
>Laravel Starter Kit</span
|
KanbanGit
|
||||||
>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ defineProps<Props>();
|
|||||||
<template>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 40 42"
|
viewBox="0 0 32 32"
|
||||||
:class="className"
|
:class="className"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<rect x="4" y="5" width="7" height="22" rx="2" />
|
||||||
fill="currentColor"
|
<rect x="13" y="5" width="7" height="14" rx="2" opacity="0.82" />
|
||||||
fill-rule="evenodd"
|
<rect x="22" y="5" width="6" height="18" rx="2" opacity="0.64" />
|
||||||
clip-rule="evenodd"
|
<path d="M14 24h7v3h-7z" opacity="0.82" />
|
||||||
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"
|
<path d="M22 26h6v1h-6z" opacity="0.64" />
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePage } from '@inertiajs/vue3';
|
import { usePage } from '@inertiajs/vue3';
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
import type { AppVariant } from '@/types';
|
import type { AppShellProps } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<AppShellProps>(), {
|
||||||
variant?: AppVariant;
|
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
variant: 'sidebar',
|
variant: 'sidebar',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,7 +11,10 @@ const isOpen = usePage().props.sidebarOpen;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<SidebarProvider v-else :default-open="isOpen">
|
<SidebarProvider v-else :default-open="isOpen">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
CirclePlus,
|
||||||
Folder,
|
Folder,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Layers3,
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
@@ -24,9 +27,15 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
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 { index as teamsIndex } from '@/routes/teams';
|
||||||
import type { NavItem } from '@/types';
|
import type { AppSidebarProps, NavItem } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<AppSidebarProps>();
|
||||||
|
|
||||||
const page = usePage();
|
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) {
|
if (page.props.auth.permissions.globalAdmin) {
|
||||||
items.push({
|
items.push({
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
@@ -66,24 +83,24 @@ const mainNavItems = computed<NavItem[]>(() => {
|
|||||||
const footerNavItems: NavItem[] = [
|
const footerNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Repository',
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
href: 'https://git.twyco.de/OpenSource/KanbanGit',
|
||||||
icon: FolderGit2,
|
icon: FolderGit2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Documentation',
|
title: 'Documentation',
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
href: 'https://git.twyco.de/OpenSource/KanbanGit/src/branch/main/README.md',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
<Sidebar collapsible="icon" variant="sidebar" class="border-sidebar-border">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" as-child>
|
<SidebarMenuButton size="lg" as-child>
|
||||||
<Link :href="dashboard()">
|
<Link :href="project ? projectsIndex() : dashboard()">
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -92,7 +109,54 @@ const footerNavItems: NavItem[] = [
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<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>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
import Breadcrumbs from '@/components/Breadcrumbs.vue';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { AppSidebarHeaderProps } from '@/types';
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(defineProps<AppSidebarHeaderProps>(), {
|
||||||
defineProps<{
|
breadcrumbs: () => [],
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
});
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
breadcrumbs: () => [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<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">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<SidebarTrigger class="-ml-1" />
|
<SidebarTrigger
|
||||||
|
class="text-muted-foreground hover:text-foreground -ml-1"
|
||||||
|
/>
|
||||||
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
<template v-if="breadcrumbs && breadcrumbs.length > 0">
|
||||||
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
<Breadcrumbs :breadcrumbs="breadcrumbs" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ const tabs = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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
|
<button
|
||||||
v-for="{ value, Icon, label } in tabs"
|
v-for="{ value, Icon, label } in tabs"
|
||||||
:key="value"
|
:key="value"
|
||||||
@click="updateAppearance(value)"
|
@click="updateAppearance(value)"
|
||||||
:class="[
|
: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
|
appearance === value
|
||||||
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
|
? 'bg-surface-container-lowest text-foreground shadow-xs ring-outline-variant ring-1'
|
||||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
: 'text-muted-foreground hover:bg-surface-container-high hover:text-foreground',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
<component :is="Icon" class="-ml-1 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
type Props = {
|
import type { HeadingProps } from '@/types';
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
variant?: 'default' | 'small';
|
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<HeadingProps>(), {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
|
<header :class="variant === 'small' ? '' : 'space-y-1.5'">
|
||||||
<h2
|
<h2
|
||||||
:class="
|
:class="
|
||||||
variant === 'small'
|
variant === 'small'
|
||||||
? 'mb-0.5 text-base font-medium'
|
? 'mb-0.5 text-base font-semibold tracking-tight'
|
||||||
: 'text-xl font-semibold tracking-tight'
|
: 'text-2xl font-semibold tracking-[-0.025em] sm:text-[1.75rem]'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</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 }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ defineProps<Props>();
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
<SidebarMenuButton
|
<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
|
as-child
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -8,23 +8,25 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavMainProps } from '@/types';
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(defineProps<NavMainProps>(), {
|
||||||
items: NavItem[];
|
label: 'Workspace',
|
||||||
}>();
|
});
|
||||||
|
|
||||||
const { isCurrentUrl } = useCurrentUrl();
|
const { isCurrentOrParentUrl } = useCurrentUrl();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SidebarGroup class="px-2 py-0">
|
<SidebarGroup class="px-2 py-0">
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>{{ label }}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem v-for="item in items" :key="item.title">
|
<SidebarMenuItem v-for="item in items" :key="item.title">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
as-child
|
as-child
|
||||||
:is-active="isCurrentUrl(item.href)"
|
:is-active="
|
||||||
|
item.isActive ?? isCurrentOrParentUrl(item.href)
|
||||||
|
"
|
||||||
:tooltip="item.title"
|
:tooltip="item.title"
|
||||||
>
|
>
|
||||||
<Link :href="item.href">
|
<Link :href="item.href">
|
||||||
|
|||||||
@@ -1,28 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<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 UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
|
import type { UserInfoProps } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<UserInfoProps>(), {
|
||||||
user: User;
|
|
||||||
showEmail?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
showEmail: false,
|
showEmail: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getInitials } = useInitials();
|
|
||||||
|
|
||||||
// Compute whether we should show the avatar image
|
|
||||||
const showAvatar = computed(
|
|
||||||
() => props.user.avatar && props.user.avatar !== '',
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UserAvatar />
|
<UserAvatar :user="user" />
|
||||||
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-medium">{{ user.name }}</span>
|
<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>
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export { default as Alert } from "./Alert.vue"
|
export { default as Alert } from "./Alert.vue";
|
||||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
export { default as AlertDescription } from "./AlertDescription.vue";
|
||||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
export { default as AlertTitle } from "./AlertTitle.vue";
|
||||||
|
|
||||||
export const alertVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default:
|
||||||
destructive:
|
"border-outline-variant bg-surface-container-lowest text-card-foreground",
|
||||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
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,
|
AvatarImage,
|
||||||
} from "@/components/ui/avatar/index";
|
} from "@/components/ui/avatar/index";
|
||||||
import { usePage } from "@inertiajs/vue3";
|
import { usePage } from "@inertiajs/vue3";
|
||||||
import { computed, PropType } from "vue";
|
import { computed } from "vue";
|
||||||
import { UserModel } from "@/types/models/user-model";
|
import type { UserAvatarProps } from "@/types";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = withDefaults(defineProps<UserAvatarProps>(), {
|
||||||
user: {
|
rounded: true,
|
||||||
type: Object as PropType<UserModel>,
|
});
|
||||||
},
|
|
||||||
rounded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const auth = computed(() => page.props.auth);
|
const auth = computed(() => page.props.auth);
|
||||||
|
|
||||||
const user = computed(() => props.user ?? auth.value.user)
|
const user = computed(() => props.user ?? auth.value.user);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Avatar class="size-8 overflow-hidden" :class="rounded ? 'rounded-full' : 'rounded-lg'">
|
<Avatar
|
||||||
<AvatarImage
|
class="border-outline-variant bg-surface-container-high size-8 overflow-hidden border"
|
||||||
v-if="user.avatar"
|
:class="rounded ? 'rounded-full' : 'rounded-lg'"
|
||||||
:src="user.avatar"
|
>
|
||||||
:alt="user.name"
|
<AvatarImage v-if="user.avatar" :src="user.avatar" :alt="user.name" />
|
||||||
/>
|
|
||||||
<AvatarFallback
|
<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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } 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(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-primary/20 bg-primary/12 text-on-primary-container dark:text-primary-fixed [a&]:hover:bg-primary/18",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
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",
|
"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:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"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>
|
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import { cva } 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(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
"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:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
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",
|
"border border-outline-variant bg-surface-container-lowest shadow-xs hover:border-outline hover:bg-surface-container-low hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
},
|
||||||
},
|
size: {
|
||||||
size: {
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
"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",
|
||||||
"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",
|
||||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
icon: "size-9",
|
||||||
"icon": "size-9",
|
"icon-sm": "size-8",
|
||||||
"icon-sm": "size-8",
|
"icon-lg": "size-10",
|
||||||
"icon-lg": "size-10",
|
},
|
||||||
},
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
'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,
|
props.class,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,53 +1,62 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import DialogOverlay from "./DialogOverlay.vue"
|
import DialogOverlay from "./DialogOverlay.vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
const props = withDefaults(
|
||||||
showCloseButton: true,
|
defineProps<
|
||||||
})
|
DialogContentProps & {
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
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',
|
'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,
|
props.class,
|
||||||
)"
|
)
|
||||||
>
|
"
|
||||||
<slot />
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<DialogClose
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
data-slot="dialog-close"
|
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"
|
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 />
|
<X />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogOverlayProps } from "reka-ui"
|
import type { DialogOverlayProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { DialogOverlay } from "reka-ui"
|
import { DialogOverlay } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
v-bind="delegatedProps"
|
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)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'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',
|
||||||
</DialogOverlay>
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
import type {
|
||||||
import type { HTMLAttributes } from "vue"
|
DropdownMenuContentEmits,
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
DropdownMenuContentProps,
|
||||||
|
} from "reka-ui";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
|
defineProps<
|
||||||
{
|
DropdownMenuContentProps & { class?: HTMLAttributes["class"] }
|
||||||
sideOffset: 4,
|
>(),
|
||||||
},
|
{
|
||||||
)
|
sideOffset: 4,
|
||||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
},
|
||||||
|
);
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
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)"
|
:class="
|
||||||
>
|
cn(
|
||||||
<slot />
|
'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',
|
||||||
</DropdownMenuContent>
|
props.class,
|
||||||
</DropdownMenuPortal>
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { useVModel } from "@vueuse/core"
|
import { useVModel } from "@vueuse/core";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
defaultValue?: string | number
|
defaultValue?: string | number;
|
||||||
modelValue?: string | number | null
|
modelValue?: string | number | null;
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: "update:modelValue", payload: string | number): void
|
(e: "update:modelValue", payload: string | number): void;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const modelValue = useVModel(props, "modelValue", emits, {
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
passive: true,
|
passive: true,
|
||||||
defaultValue: props.defaultValue,
|
defaultValue: props.defaultValue,
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
'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',
|
||||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
'focus-visible:border-ring focus-visible:ring-ring/25 focus-visible:ring-[3px]',
|
||||||
props.class,
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
)"
|
props.class,
|
||||||
>
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,51 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectPortal,
|
SelectPortal,
|
||||||
SelectViewport,
|
SelectViewport,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||||
{
|
{
|
||||||
position: "popper",
|
position: "popper",
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
const emits = defineEmits<SelectContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectPortal>
|
<SelectPortal>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
position === 'popper'
|
'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',
|
||||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
position === 'popper' &&
|
||||||
props.class,
|
'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')">
|
<SelectScrollUpButton />
|
||||||
<slot />
|
<SelectViewport
|
||||||
</SelectViewport>
|
:class="
|
||||||
<SelectScrollDownButton />
|
cn(
|
||||||
</SelectContent>
|
'p-1',
|
||||||
</SelectPortal>
|
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>
|
</template>
|
||||||
|
|||||||
@@ -1,33 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SelectTriggerProps } from "reka-ui"
|
import type { SelectTriggerProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { ChevronDown } from "lucide-vue-next"
|
import { ChevronDown } from "lucide-vue-next";
|
||||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
defineProps<
|
||||||
{ size: "default" },
|
SelectTriggerProps & {
|
||||||
)
|
class?: HTMLAttributes["class"];
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ size: "default" },
|
||||||
|
);
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||||
const forwardedProps = useForwardProps(delegatedProps)
|
const forwardedProps = useForwardProps(delegatedProps);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
:data-size="size"
|
:data-size="size"
|
||||||
v-bind="forwardedProps"
|
v-bind="forwardedProps"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
props.class,
|
'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" />
|
<slot />
|
||||||
</SelectIcon>
|
<SelectIcon as-child>
|
||||||
</SelectTrigger>
|
<ChevronDown class="size-4 opacity-50" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,62 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
import type { DialogContentEmits, DialogContentProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { X } from "lucide-vue-next"
|
import { X } from "lucide-vue-next";
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
useForwardPropsEmits,
|
useForwardPropsEmits,
|
||||||
} from "reka-ui"
|
} from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
import SheetOverlay from "./SheetOverlay.vue"
|
import SheetOverlay from "./SheetOverlay.vue";
|
||||||
|
|
||||||
interface SheetContentProps extends DialogContentProps {
|
interface SheetContentProps extends DialogContentProps {
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
});
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
side: "right",
|
side: "right",
|
||||||
})
|
});
|
||||||
const emits = defineEmits<DialogContentEmits>()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogContent
|
<DialogContent
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
:class="cn(
|
:class="
|
||||||
'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',
|
cn(
|
||||||
side === 'right'
|
'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',
|
||||||
&& '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 === 'right' &&
|
||||||
side === 'left'
|
'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',
|
||||||
&& '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 === 'left' &&
|
||||||
side === 'top'
|
'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',
|
||||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
side === 'top' &&
|
||||||
side === 'bottom'
|
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
side === 'bottom' &&
|
||||||
props.class)"
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
v-bind="{ ...$attrs, ...forwarded }"
|
props.class,
|
||||||
>
|
)
|
||||||
<slot />
|
"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
<DialogClose
|
<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"
|
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" />
|
<X class="size-4" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DialogOverlayProps } from "reka-ui"
|
import type { DialogOverlayProps } from "reka-ui";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
import { DialogOverlay } from "reka-ui"
|
import { DialogOverlay } from "reka-ui";
|
||||||
import { cn } from "@/lib/utils"
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
data-slot="sheet-overlay"
|
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)"
|
:class="
|
||||||
v-bind="delegatedProps"
|
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',
|
||||||
<slot />
|
props.class,
|
||||||
</DialogOverlay>
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,60 +1,63 @@
|
|||||||
import type { VariantProps } from "class-variance-authority"
|
import type { VariantProps } from "class-variance-authority";
|
||||||
import type { HTMLAttributes } from "vue"
|
import type { HTMLAttributes } from "vue";
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
side?: "left" | "right"
|
side?: "left" | "right";
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { default as Sidebar } from "./Sidebar.vue"
|
export { default as Sidebar } from "./Sidebar.vue";
|
||||||
export { default as SidebarContent } from "./SidebarContent.vue"
|
export { default as SidebarContent } from "./SidebarContent.vue";
|
||||||
export { default as SidebarFooter } from "./SidebarFooter.vue"
|
export { default as SidebarFooter } from "./SidebarFooter.vue";
|
||||||
export { default as SidebarGroup } from "./SidebarGroup.vue"
|
export { default as SidebarGroup } from "./SidebarGroup.vue";
|
||||||
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue"
|
export { default as SidebarGroupAction } from "./SidebarGroupAction.vue";
|
||||||
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
|
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue";
|
||||||
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
|
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue";
|
||||||
export { default as SidebarHeader } from "./SidebarHeader.vue"
|
export { default as SidebarHeader } from "./SidebarHeader.vue";
|
||||||
export { default as SidebarInput } from "./SidebarInput.vue"
|
export { default as SidebarInput } from "./SidebarInput.vue";
|
||||||
export { default as SidebarInset } from "./SidebarInset.vue"
|
export { default as SidebarInset } from "./SidebarInset.vue";
|
||||||
export { default as SidebarMenu } from "./SidebarMenu.vue"
|
export { default as SidebarMenu } from "./SidebarMenu.vue";
|
||||||
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue"
|
export { default as SidebarMenuAction } from "./SidebarMenuAction.vue";
|
||||||
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue"
|
export { default as SidebarMenuBadge } from "./SidebarMenuBadge.vue";
|
||||||
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
|
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue";
|
||||||
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
|
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue";
|
||||||
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue"
|
export { default as SidebarMenuSkeleton } from "./SidebarMenuSkeleton.vue";
|
||||||
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue"
|
export { default as SidebarMenuSub } from "./SidebarMenuSub.vue";
|
||||||
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue"
|
export { default as SidebarMenuSubButton } from "./SidebarMenuSubButton.vue";
|
||||||
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue"
|
export { default as SidebarMenuSubItem } from "./SidebarMenuSubItem.vue";
|
||||||
export { default as SidebarProvider } from "./SidebarProvider.vue"
|
export { default as SidebarProvider } from "./SidebarProvider.vue";
|
||||||
export { default as SidebarRail } from "./SidebarRail.vue"
|
export { default as SidebarRail } from "./SidebarRail.vue";
|
||||||
export { default as SidebarSeparator } from "./SidebarSeparator.vue"
|
export { default as SidebarSeparator } from "./SidebarSeparator.vue";
|
||||||
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
|
export { default as SidebarTrigger } from "./SidebarTrigger.vue";
|
||||||
|
|
||||||
export { useSidebar } from "./utils"
|
export { useSidebar } from "./utils";
|
||||||
|
|
||||||
export const sidebarMenuButtonVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
default:
|
||||||
outline:
|
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
"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))]",
|
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",
|
size: {
|
||||||
sm: "h-7 text-xs",
|
default: "h-8 text-sm",
|
||||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
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
|
||||||
|
>;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createContext } from "reka-ui"
|
import { createContext } from "reka-ui";
|
||||||
import type { ComputedRef, Ref } from 'vue';
|
import type { ComputedRef, Ref } from "vue";
|
||||||
|
|
||||||
export type SidebarContext = {
|
export type SidebarContext = {
|
||||||
state: ComputedRef<'expanded' | 'collapsed'>;
|
state: ComputedRef<"expanded" | "collapsed">;
|
||||||
open: Ref<boolean>;
|
open: Ref<boolean>;
|
||||||
setOpen: (value: boolean) => void;
|
setOpen: (value: boolean) => void;
|
||||||
isMobile: Ref<boolean>;
|
isMobile: Ref<boolean>;
|
||||||
@@ -11,11 +11,12 @@ export type SidebarContext = {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
export const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
export const SIDEBAR_WIDTH = "16rem"
|
export const SIDEBAR_WIDTH = "15.5rem";
|
||||||
export const SIDEBAR_WIDTH_MOBILE = "18rem"
|
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
export const SIDEBAR_WIDTH_ICON = "3rem"
|
export const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
export const [useSidebar, provideSidebarContext] = createContext<SidebarContext>("Sidebar")
|
export const [useSidebar, provideSidebarContext] =
|
||||||
|
createContext<SidebarContext>("Sidebar");
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { AppLayoutProps } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<AppLayoutProps>(), {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
breadcrumbs: () => [],
|
breadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs" :project="project">
|
||||||
<slot />
|
<slot />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AuthLayout from '@/layouts/auth/AuthSimpleLayout.vue';
|
import AuthLayout from '@/layouts/auth/AuthCardLayout.vue';
|
||||||
|
import type { AuthLayoutProps } from '@/types';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<AuthLayoutProps>();
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -3,21 +3,17 @@ import AppContent from '@/components/AppContent.vue';
|
|||||||
import AppShell from '@/components/AppShell.vue';
|
import AppShell from '@/components/AppShell.vue';
|
||||||
import AppSidebar from '@/components/AppSidebar.vue';
|
import AppSidebar from '@/components/AppSidebar.vue';
|
||||||
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
import AppSidebarHeader from '@/components/AppSidebarHeader.vue';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { AppLayoutProps } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
withDefaults(defineProps<AppLayoutProps>(), {
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
breadcrumbs: () => [],
|
breadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppShell variant="sidebar">
|
<AppShell variant="sidebar">
|
||||||
<AppSidebar />
|
<AppSidebar :project="project" />
|
||||||
<AppContent variant="sidebar" class="overflow-x-hidden">
|
<AppContent variant="sidebar" class="min-w-0 overflow-x-hidden">
|
||||||
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
|
||||||
<slot />
|
<slot />
|
||||||
</AppContent>
|
</AppContent>
|
||||||
|
|||||||
@@ -9,41 +9,65 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { home } from '@/routes/index';
|
import { home } from '@/routes/index';
|
||||||
|
import type { AuthLayoutProps } from '@/types';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<AuthLayoutProps>();
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}>();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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
|
<Link
|
||||||
:href="home()"
|
: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">
|
<div
|
||||||
<AppLogoIcon
|
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"
|
||||||
class="size-9 fill-current text-black dark:text-white"
|
>
|
||||||
/>
|
<AppLogoIcon class="size-5 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-lg tracking-tight">KanbanGit</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-4">
|
||||||
<Card class="rounded-xl">
|
<Card
|
||||||
<CardHeader class="px-10 pb-0 pt-8 text-center">
|
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)]"
|
||||||
<CardTitle class="text-xl">{{ title }}</CardTitle>
|
>
|
||||||
|
<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>
|
<CardDescription>
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="px-10 py-8">
|
<CardContent class="px-6 pb-7 pt-7 sm:px-9 sm:pb-9">
|
||||||
<slot />
|
<slot />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<p class="text-muted-foreground text-center text-xs">
|
||||||
|
Open source project management, hosted on your terms.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user