Compare commits
100
Commits
v0.1.0
...
508c39dd2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508c39dd2d | ||
|
|
1be2d06ff4 | ||
|
|
0f62445c3e | ||
|
|
0a2807a28c | ||
|
|
8a9113a7af | ||
|
|
0a974cfd49 | ||
|
|
538b17b9b8 | ||
|
|
b2789b4670 | ||
|
|
7c6981dc0a | ||
|
|
b8409a702c | ||
|
|
2acbdb4760 | ||
|
|
9faf02e2c4 | ||
|
|
0941745883 | ||
|
|
5a10c85e61 | ||
|
|
fb4faa6070 | ||
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d | ||
|
|
ba7e174e82 | ||
|
|
d646f8b537 | ||
|
|
509d7abf82 | ||
|
|
5cc1840592 | ||
|
|
11b06d9b8c | ||
|
|
b76b4adaec | ||
|
|
c8fa76e08a | ||
|
|
87ae19f972 | ||
|
|
a97451edfa | ||
|
|
17b3e02bd8 | ||
|
|
d58fbd21ad | ||
|
|
002de1b293 | ||
|
|
707ebe6e48 | ||
|
|
4e29fc4526 | ||
|
|
fab1e9c7a8 | ||
|
|
ccd199a695 | ||
|
|
f7b50e8715 | ||
|
|
5147c8ed8d | ||
|
|
3914fd5432 | ||
|
|
4d6da2c5f5 | ||
|
|
313e2d2396 | ||
|
|
810d1850ae | ||
|
|
cd7fae3841 | ||
|
|
32e3496248 | ||
|
|
0e78be0eae | ||
|
|
17caabbb3e | ||
|
|
3f4945c20d | ||
|
|
65bf75ed77 | ||
|
|
802294d6a2 | ||
|
|
5e52e4fe78 | ||
|
|
f4a8bdd134 | ||
|
|
850ae337ca | ||
|
|
4adda6e53b | ||
|
|
de6f4e5b2f | ||
|
|
2e2ddd5b74 | ||
|
|
c58196293f | ||
|
|
8657d75682 | ||
|
|
71d9d1b63e | ||
|
|
68995a9f4c | ||
|
|
3e998a483e | ||
|
|
ceffe4f4a4 | ||
|
|
ce01b8ec55 | ||
|
|
bd774e20f6 | ||
|
|
d9cb8d0903 | ||
|
|
19e8ba89bf | ||
|
|
642e49d7b0 | ||
|
|
1d8fff1d51 | ||
|
|
bd7be4e41e | ||
|
|
9895fc36db | ||
|
|
fed6a282a4 | ||
|
|
db8855a056 | ||
|
|
04b0d5e316 | ||
|
|
49e887753c | ||
|
|
b8a1d65122 | ||
|
|
831014f7fe | ||
|
|
fef82cb88f | ||
|
|
40f9b473fc | ||
|
|
5abfae2736 |
+4
-3
@@ -33,9 +33,6 @@
|
||||
vendor
|
||||
**/.DS_Store
|
||||
.editorconfig
|
||||
.env.example
|
||||
.env.mariadb
|
||||
.env.mariadb.example
|
||||
.env.production
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -46,3 +43,7 @@ storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
|
||||
# Native build artifacts are independent of the Laravel image.
|
||||
native/src-tauri/target
|
||||
native/src-tauri/gen
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
MARIADB_DATABASE=kanbangit
|
||||
MARIADB_USER=kanbangit
|
||||
MARIADB_PASSWORD=
|
||||
MARIADB_ROOT_PASSWORD=
|
||||
@@ -2,7 +2,13 @@ name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,9 +17,19 @@ env:
|
||||
REGISTRY: git.twyco.de
|
||||
|
||||
jobs:
|
||||
test_and_check_app:
|
||||
name: test_and_check_app
|
||||
preconditions:
|
||||
if: >
|
||||
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Conditions met
|
||||
run: |
|
||||
echo "Conditions for CI/CD are met. Proceeding with the workflow."
|
||||
|
||||
test_and_check_app:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- preconditions
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -27,6 +43,7 @@ jobs:
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
needs:
|
||||
- preconditions
|
||||
- test_and_check_app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -28,3 +28,8 @@ yarn-error.log
|
||||
/.vscode
|
||||
/.zed
|
||||
.codex/config.toml
|
||||
|
||||
/native/node_modules
|
||||
/native/src-tauri/target
|
||||
/native/src-tauri/gen/*
|
||||
!/native/src-tauri/gen/android/
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# Working Agreements
|
||||
|
||||
## Missing programs and SDKs
|
||||
|
||||
If implementation or verification requires a missing program, SDK, runtime, toolchain, or platform component, do not download or install a temporary copy in `/tmp` or another temporary directory. Do not use an ad hoc toolchain as a workaround. Tell the user which prerequisites are missing, ask them to install them permanently and confirm when installation is complete, and wait for that confirmation before continuing dependent work. This rule applies to every implementation task in this project.
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
|
||||
+5
-3
@@ -55,12 +55,14 @@ COPY --from=node_binary /usr/local/ /usr/local/
|
||||
COPY . /var/www
|
||||
|
||||
RUN composer install --prefer-dist --no-interaction --no-progress \
|
||||
&& npm ci \
|
||||
&& npm run build
|
||||
&& corepack enable pnpm \
|
||||
&& pnpm install --frozen-lockfile \
|
||||
&& pnpm run build
|
||||
|
||||
FROM build AS test
|
||||
|
||||
RUN touch .env \
|
||||
RUN cp .env.example .env \
|
||||
&& php artisan key:generate\
|
||||
&& composer audit --locked --no-interaction \
|
||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Enums\DiscordAllowedMentionType;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||
]),
|
||||
'templateVariables' => $this->templateVariables(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
||||
*/
|
||||
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
||||
*/
|
||||
@@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
|
||||
'tickets' => TicketResource::collection(
|
||||
Ticket::query()
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
|
||||
'ticketStatuses',
|
||||
'tickets' => fn ($query) => $query
|
||||
->with([
|
||||
'assignee',
|
||||
'ticketPriority',
|
||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -9,6 +9,10 @@ use App\Models\Project;
|
||||
|
||||
class BuildProjectBoardSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||
]),
|
||||
'ticketStatuses' => TicketStatusResource::collection(
|
||||
$project->ticketStatuses()
|
||||
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
||||
|
||||
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
|
||||
|
||||
class BuildProjectTicketMetadataSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -23,8 +27,15 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'error' => $error,
|
||||
'status' => $status,
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'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()->withCount('ticketFieldValues')->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||
];
|
||||
|
||||
@@ -6,25 +6,85 @@ use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateProject
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{key: string, name: string} $attributes
|
||||
* @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array<int, string>|null} $attributes
|
||||
*/
|
||||
public function handle(array $attributes, User $actor): Project
|
||||
{
|
||||
$project = Project::query()->create($attributes);
|
||||
return DB::transaction(function () use ($attributes, $actor): Project {
|
||||
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
|
||||
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
|
||||
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
|
||||
$baseTemplate = null;
|
||||
$sourceTemplate = null;
|
||||
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||
$sourceTemplate = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail((int) $sourceTemplateId);
|
||||
|
||||
return $project;
|
||||
$project->forceFill([
|
||||
'project_template_id' => $sourceTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
} else {
|
||||
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $baseTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
}
|
||||
|
||||
$project = $project->fresh() ?? $project;
|
||||
|
||||
$this->projectTemplateOverrideManager->withoutRecording(
|
||||
fn () => $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,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
||||
|
||||
public const COPY_BOARD_COLUMNS = 'board_columns';
|
||||
|
||||
public const COPY_TICKET_STATUSES = 'ticket_statuses';
|
||||
|
||||
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
|
||||
|
||||
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';
|
||||
|
||||
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function copyableAreas(): array
|
||||
{
|
||||
return [
|
||||
self::COPY_PROJECT_SETTINGS,
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
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<string> */
|
||||
public static function acceptedCopyAreas(): array
|
||||
{
|
||||
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string, description: string}> */
|
||||
public static function copyAreaOptions(): array
|
||||
{
|
||||
return [
|
||||
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
|
||||
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
|
||||
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
|
||||
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
|
||||
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
|
||||
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
|
||||
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
|
||||
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
|
||||
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
|
||||
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return list<string> */
|
||||
public function normalizeCopyAreas(array $copyAreas): array
|
||||
{
|
||||
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
|
||||
$copyAreas = [
|
||||
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalizeConfiguration(array $configuration): array
|
||||
{
|
||||
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
|
||||
|
||||
if (is_array($workflow)) {
|
||||
$statuses = $workflow['ticket_statuses'] ?? [];
|
||||
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
|
||||
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
|
||||
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
|
||||
$statuses,
|
||||
);
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
|
||||
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
|
||||
$statuses,
|
||||
);
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
public function export(Project $sourceProject, array $copyAreas): array
|
||||
{
|
||||
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||
|
||||
if ($selectedAreas === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceProject->load([
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
);
|
||||
|
||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => [
|
||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||
)->all();
|
||||
}
|
||||
|
||||
$simpleAreas = [
|
||||
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
|
||||
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
|
||||
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
|
||||
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
|
||||
'provider' => $item->provider->value, 'base_url' => $item->base_url,
|
||||
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
|
||||
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
|
||||
'repository_url' => $item->repository_url,
|
||||
],
|
||||
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
|
||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||
'filters' => $item->filters ?? [],
|
||||
],
|
||||
];
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
|
||||
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
|
||||
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
|
||||
];
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
}
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = [];
|
||||
|
||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->boardColumns()->get(),
|
||||
$configuration[self::COPY_BOARD_COLUMNS],
|
||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||
$columns = $targetProject->boardColumns()->get()->values();
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||
$columnIndex = $item['board_column_index'] ?? null;
|
||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||
unset($item['board_column_index']);
|
||||
|
||||
return $item;
|
||||
})->all();
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->ticketStatuses()->get(),
|
||||
$items,
|
||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
function (Model $status) use ($statusReplacements): bool {
|
||||
if (! $status->tickets()->exists()) {
|
||||
return true;
|
||||
}
|
||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacementId === null) {
|
||||
return false;
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||
$status = $statuses->get($index);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
if (! isset($configuration[$area])) {
|
||||
continue;
|
||||
}
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->{$relation}()->get(),
|
||||
$configuration[$area],
|
||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
$canDelete,
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($targetProject->configurationRelations() as $relation) {
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
});
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
}
|
||||
|
||||
return $blockedAreas;
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas */
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
private static int $suppressionDepth = 0;
|
||||
|
||||
public function mark(Project $project, string $area): void
|
||||
{
|
||||
if (self::$suppressionDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overrides = array_values(array_unique([
|
||||
...($project->template_overrides ?? []),
|
||||
$area,
|
||||
]));
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
{
|
||||
self::$suppressionDepth++;
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
self::$suppressionDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
public function isRecording(): bool
|
||||
{
|
||||
return self::$suppressionDepth === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(Project $project, string $area): void
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$project->projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
|
||||
$project->projectTemplate->forceFill([
|
||||
'configuration_snapshot' => array_replace(
|
||||
$snapshot,
|
||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||
),
|
||||
])->saveQuietly();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?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);
|
||||
|
||||
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateConfiguration = collect($normalizedTemplateConfiguration)
|
||||
->only($selectedAreas)
|
||||
->all();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||
$persistedOverrides = $projectTemplate->is_published
|
||||
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
|
||||
: [];
|
||||
|
||||
$overriddenAreas = [];
|
||||
$inheritedAreas = [];
|
||||
|
||||
foreach ($selectedAreas as $area) {
|
||||
$label = $this->labelForArea($area);
|
||||
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||
|
||||
if (! $hasTemplateValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($area, $persistedOverrides, true)) {
|
||||
$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),
|
||||
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
|
||||
'templateConfiguration' => $templateConfiguration,
|
||||
'templateId' => $projectTemplate->id,
|
||||
'templateName' => $projectTemplate->name,
|
||||
'templateSlug' => $projectTemplate->slug,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $templateConfiguration
|
||||
* @param list<string> $persistedOverrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
|
||||
{
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
if (! is_array($templateStatuses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||
)->all();
|
||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => [
|
||||
'id' => $status->id,
|
||||
'name' => $status->name,
|
||||
'ticketCount' => $status->tickets_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $removedStatuses === [] ? [] : [
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||
'removedStatuses' => $removedStatuses,
|
||||
'replacementOptions' => $replacementOptions,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function labelForArea(string $area): string
|
||||
{
|
||||
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||
if ($option['value'] === $area) {
|
||||
return $option['label'];
|
||||
}
|
||||
}
|
||||
|
||||
return $area;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{name: string, copy_areas?: array<int, string>} $attributes
|
||||
*/
|
||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||
{
|
||||
$projectTemplate->loadMissing('sourceProject');
|
||||
|
||||
$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 = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
$replacementConfiguration = [];
|
||||
|
||||
foreach ($affectedAreas as $area) {
|
||||
if (in_array($area, $overrides, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $nextConfiguration)) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $previousConfiguration)
|
||||
&& array_key_exists($area, $currentConfiguration)
|
||||
&& $currentConfiguration[$area] !== []) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementConfiguration[$area] = $nextConfiguration[$area];
|
||||
}
|
||||
|
||||
if ($replacementConfiguration !== []) {
|
||||
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?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,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$localAreas = array_values(array_diff(
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
array_keys($configuration),
|
||||
));
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => $localAreas,
|
||||
])->saveQuietly();
|
||||
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
if ($template === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
$statusReplacements,
|
||||
);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
|
||||
|
||||
use App\Actions\Git\Data\GitRepository;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\RepositoryConnectionResource;
|
||||
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class CreateProjectTicket
|
||||
public function __construct(
|
||||
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
|
||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,15 @@ class CreateProjectTicket
|
||||
* text: string
|
||||
* }>,
|
||||
* assignee_user_id?: int|null,
|
||||
* author_user_id?: int|null,
|
||||
* blocked_comment?: string|null,
|
||||
* description: string|null,
|
||||
* field_values?: array<int, array{
|
||||
* ticket_field_definition_id: int,
|
||||
* value_number?: float|int|string|null,
|
||||
* value_user_id?: int|string|null
|
||||
* }>,
|
||||
* is_blocked?: bool,
|
||||
* parent_ticket_id?: int|null,
|
||||
* story_points?: int|null,
|
||||
* ticket_priority_id: int,
|
||||
@@ -47,14 +56,17 @@ class CreateProjectTicket
|
||||
$nextSequenceNumber = (int) Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->max('sequence_number') + 1;
|
||||
$ticketStatus = TicketStatus::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||
|
||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||
'author_user_id' => $actor?->id,
|
||||
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
|
||||
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
|
||||
'description' => Arr::get($attributes, 'description'),
|
||||
'is_blocked' => $isBlocked,
|
||||
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => $nextSequenceNumber,
|
||||
@@ -68,6 +80,17 @@ class CreateProjectTicket
|
||||
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
|
||||
|
||||
$fieldValues = Arr::get($attributes, 'field_values', []);
|
||||
|
||||
if ($fieldValues !== []) {
|
||||
$this->syncProjectTicketFieldValues->handle(
|
||||
$ticket,
|
||||
$project->ticketFieldDefinitions()->get()->keyBy('id'),
|
||||
collect(),
|
||||
$fieldValues,
|
||||
);
|
||||
}
|
||||
|
||||
$occurredAt = CarbonImmutable::now();
|
||||
|
||||
if ($actor !== null) {
|
||||
|
||||
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
||||
->where('assignee_user_id', $assignee->id)
|
||||
->whereIn('project_id', $visibleProjectIds)
|
||||
->with([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
'ticketType',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||
use App\Actions\Projects\CreateProject;
|
||||
use App\Actions\Projects\DeleteProject;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ListVisibleProjects;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\DeleteProjectRequest;
|
||||
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||
use App\Http\Resources\PermissionOptionResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\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\Http\RedirectResponse;
|
||||
@@ -30,8 +37,11 @@ class ProjectController extends Controller
|
||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||
private readonly CreateProject $createProject,
|
||||
private readonly DeleteProject $deleteProject,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -51,7 +61,23 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/Create');
|
||||
$sourceTemplates = ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$selectedTemplateId = $request->integer('template');
|
||||
|
||||
return Inertia::render('projects/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'sourceProjects' => ProjectResource::collection(
|
||||
$this->listVisibleProjects->handle($request->user()),
|
||||
)->resolve(),
|
||||
'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(),
|
||||
'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId)
|
||||
? $selectedTemplateId
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreProjectRequest $request): RedirectResponse
|
||||
@@ -73,6 +99,7 @@ class ProjectController extends Controller
|
||||
return Inertia::render('projects/Show', [
|
||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -95,10 +122,58 @@ class ProjectController extends Controller
|
||||
{
|
||||
return Inertia::render('projects/settings/General', [
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||
ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'status' => session('status'),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
}
|
||||
|
||||
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$template = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||
|
||||
$this->updateProjectTemplateLink->switch($project, $template);
|
||||
|
||||
return to_route('projects.settings.general', $project)
|
||||
->with('status', "{$project->name} now uses the {$template->name} template.");
|
||||
}
|
||||
|
||||
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
);
|
||||
|
||||
$status = Arr::has($validated, 'areas')
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
return back()->with('status', $status);
|
||||
}
|
||||
|
||||
public function access(Request $request, Project $project): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ class HandleAppearance
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
$appearance = $request->cookie('appearance');
|
||||
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
|
||||
|
||||
$request->attributes->set('appearance', $appearance);
|
||||
View::share('appearance', $appearance);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'appearance' => $request->attributes->get('appearance', 'system'),
|
||||
'localization' => [
|
||||
'locale' => app()->getLocale(),
|
||||
'timeZone' => config('app.timezone'),
|
||||
],
|
||||
'auth' => [
|
||||
'permissions' => [
|
||||
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
|
||||
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
|
||||
'user' => $user,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'navigation' => fn (): array => [
|
||||
'project' => $this->projectNavigation($request),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
|
||||
*/
|
||||
private function projectNavigation(Request $request): ?array
|
||||
{
|
||||
$project = $request->route('project');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $project instanceof Project || $user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$permissions = $this->permissionService->effectivePermissions($user, $project);
|
||||
|
||||
if (! in_array(Permission::ProjectRead, $permissions, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $project->id,
|
||||
'name' => $project->name,
|
||||
'key' => $project->key,
|
||||
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
|
||||
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ class DestroyProjectTicketTypeRequest extends FormRequest
|
||||
'replacement_ticket_type_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
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 [
|
||||
'areas' => ['sometimes', 'array', 'min:1'],
|
||||
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||
'confirmation_key' => [
|
||||
'required_without:areas',
|
||||
'string',
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateStatuses = Arr::get(
|
||||
$configuration,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
[],
|
||||
);
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||
$validator->errors()->add(
|
||||
"status_replacements.{$status->id}",
|
||||
"Choose a replacement status for {$status->name}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.',
|
||||
'areas.*.in' => 'Choose a valid template area to reset.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$key = Arr::get($this->all(), 'key');
|
||||
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
|
||||
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
|
||||
|
||||
if ($key !== null) {
|
||||
$this->merge([
|
||||
'key' => strtoupper(trim((string) $key)),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch',
|
||||
'copy_areas' => is_array($copyAreas)
|
||||
? array_values($copyAreas)
|
||||
: ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
|
||||
'key' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
|
||||
Rule::unique('projects', 'key'),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'source_project_id' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'),
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('projects', 'id'),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = Project::query()->find($value);
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) {
|
||||
$fail('Select a project you can access as the duplication source.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'source_template_id' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
|
||||
],
|
||||
'copy_areas' => ['nullable', 'array'],
|
||||
'copy_areas.*' => [
|
||||
'string',
|
||||
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
@@ -22,9 +24,18 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
||||
'repository_connection_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +66,23 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
'ticket_priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
|
||||
@@ -38,7 +38,9 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
'ticket_link_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_link_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class StoreTicketStatusRequest extends FormRequest
|
||||
'board_column_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('board_columns', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'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,48 @@
|
||||
<?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::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'field_values.*.ticket_field_definition_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_field_definitions', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
@@ -81,12 +83,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'ticket_priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
|
||||
@@ -34,7 +34,9 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||
'allowed_ticket_status_ids.*' => [
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
@@ -22,9 +24,18 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
||||
'repository_connection_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ProjectResource extends JsonResource
|
||||
{
|
||||
@@ -14,10 +16,33 @@ class ProjectResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$this->resource->loadMissing('projectTemplate');
|
||||
|
||||
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'key' => $this->key,
|
||||
'project_template_id' => $this->project_template_id,
|
||||
'project_template' => $this->projectTemplate === null
|
||||
? null
|
||||
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
|
||||
'template_state' => $templateState === null
|
||||
? null
|
||||
: [
|
||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
||||
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
||||
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
|
||||
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
||||
'template_id' => Arr::get($templateState, 'templateId'),
|
||||
'template_name' => Arr::get($templateState, 'templateName'),
|
||||
'template_slug' => Arr::get($templateState, 'templateSlug'),
|
||||
],
|
||||
'show_author' => $this->show_author,
|
||||
'show_story_points' => $this->show_story_points,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
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) {
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
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' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
||||
'label' => $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||
'position' => $this->position,
|
||||
'project_id' => $this->project_id,
|
||||
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
@@ -15,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
|
||||
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected array $tools = [
|
||||
@@ -25,6 +27,8 @@ class KanbanGitServer extends Server
|
||||
UpdateTicketDescription::class,
|
||||
UpdateTicketAssignee::class,
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -120,6 +120,24 @@ class KanbanGitMcp
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function projectForWrite(User $user, int $projectId): Project
|
||||
{
|
||||
$project = Project::query()->find($projectId);
|
||||
|
||||
if ($project === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'project_id' => 'Project was not found.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->ensureCanWrite($user, $project);
|
||||
|
||||
return $project;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
* @return Collection<int, TicketStatus>|Response
|
||||
@@ -189,10 +207,12 @@ class KanbanGitMcp
|
||||
public function ticketPayload(Ticket $ticket): array
|
||||
{
|
||||
$ticket->load([
|
||||
'acceptanceCriteria',
|
||||
'assignee',
|
||||
'author',
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'fieldValues.userValue',
|
||||
'parentTicket',
|
||||
'project',
|
||||
'ticketPriority',
|
||||
'ticketStatus.boardColumn',
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[Name('create_ticket')]
|
||||
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
private readonly PermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$projectInput = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
|
||||
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
|
||||
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
|
||||
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
|
||||
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
'is_blocked' => ['sometimes', 'boolean'],
|
||||
'blocked_comment' => ['nullable', 'string'],
|
||||
'acceptance_criteria' => ['nullable', 'array', 'list'],
|
||||
'acceptance_criteria.*' => ['array:text,checked,indent'],
|
||||
'acceptance_criteria.*.text' => ['required', 'string'],
|
||||
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
|
||||
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
|
||||
'field_values' => ['sometimes', 'array', 'list'],
|
||||
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
|
||||
'field_values.*.ticket_field_definition_id' => [
|
||||
'required', 'integer', 'distinct',
|
||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
]);
|
||||
|
||||
$this->validateTicketConstraints($project, $user, $validated);
|
||||
|
||||
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||
'meta' => [
|
||||
'message' => "{$ticket->ticket_key} was created.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
private function validateTicketConstraints(Project $project, User $user, array $validated): void
|
||||
{
|
||||
$errors = [];
|
||||
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
|
||||
|
||||
if ($parentTicketId !== null) {
|
||||
$parentTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->with('ticketType')
|
||||
->find($parentTicketId);
|
||||
|
||||
if (! $parentTicket?->ticketType?->is_parent) {
|
||||
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
|
||||
}
|
||||
}
|
||||
|
||||
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
|
||||
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
|
||||
}
|
||||
|
||||
if (
|
||||
Arr::exists($validated, 'author_user_id')
|
||||
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
|
||||
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
|
||||
) {
|
||||
$errors['author_user_id'] = 'Only project admins can change the author.';
|
||||
}
|
||||
|
||||
$fieldValues = Arr::get($validated, 'field_values', []);
|
||||
|
||||
if ($fieldValues !== []) {
|
||||
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||
|
||||
foreach ($fieldValues as $index => $fieldValue) {
|
||||
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
|
||||
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||
|
||||
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
|
||||
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Project ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description.'),
|
||||
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
|
||||
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
|
||||
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
|
||||
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
|
||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
|
||||
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
|
||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
|
||||
'acceptance_criteria' => $schema->array()->nullable()
|
||||
->items($schema->object([
|
||||
'text' => $schema->string()->description('Checklist item text.')->required(),
|
||||
'checked' => $schema->boolean()->default(false),
|
||||
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
|
||||
'field_values' => $schema->array()
|
||||
->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
|
||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
|
||||
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
|
||||
])->withoutAdditionalProperties())
|
||||
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('list_ticket_types')]
|
||||
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
|
||||
#[IsReadOnly]
|
||||
class ListTicketTypes extends Tool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
|
||||
|
||||
return Response::structured([
|
||||
'data' => [
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Type>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()
|
||||
->description('Project ID. Requires permission to view this project.')
|
||||
->required(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\BoardColumnFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -10,12 +11,21 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $boardColumn): void {
|
||||
$boardColumn->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -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\NotificationEventType;
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\NotificationActionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'message_template',
|
||||
'name',
|
||||
'project_id',
|
||||
'project_template_id',
|
||||
])]
|
||||
#[Hidden(['delivery_url'])]
|
||||
class NotificationAction extends Model
|
||||
@@ -30,6 +32,8 @@ class NotificationAction extends Model
|
||||
/** @use HasFactory<NotificationActionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -45,6 +49,13 @@ class NotificationAction extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $notificationAction): void {
|
||||
$notificationAction->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
|
||||
+30
-1
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
|
||||
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
|
||||
class Project extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
@@ -22,6 +24,7 @@ class Project extends Model
|
||||
return [
|
||||
'show_author' => 'boolean',
|
||||
'show_story_points' => 'boolean',
|
||||
'template_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,6 +47,17 @@ class Project extends Model
|
||||
return $this->morphMany(PermissionAssignment::class, 'scope');
|
||||
}
|
||||
|
||||
public function projectTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
|
||||
}
|
||||
|
||||
public function baseTemplate(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProjectTemplate::class)
|
||||
->where('is_published', false);
|
||||
}
|
||||
|
||||
public function boardColumns(): HasMany
|
||||
{
|
||||
return $this->hasMany(BoardColumn::class)
|
||||
@@ -95,6 +109,21 @@ class Project extends Model
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function configurationRelations(): array
|
||||
{
|
||||
return [
|
||||
'boardColumns',
|
||||
'notificationActions',
|
||||
'repositoryConnections',
|
||||
'ticketFieldDefinitions',
|
||||
'ticketLinkTypes',
|
||||
'ticketPriorities',
|
||||
'ticketStatuses',
|
||||
'ticketTypes',
|
||||
];
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::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\GitProviderKey;
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\RepositoryConnectionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
'project_template_id',
|
||||
'provider',
|
||||
'provider_repository_id',
|
||||
'repository_full_name',
|
||||
@@ -25,6 +27,8 @@ class RepositoryConnection extends Model
|
||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -35,6 +39,13 @@ class RepositoryConnection extends Model
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $repositoryConnection): void {
|
||||
$repositoryConnection->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketFieldDefinitionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketFieldDefinition extends Model
|
||||
{
|
||||
/**
|
||||
@@ -23,6 +24,15 @@ class TicketFieldDefinition extends Model
|
||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketFieldDefinition): void {
|
||||
$ticketFieldDefinition->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketLinkTypeFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketLinkType): void {
|
||||
$ticketLinkType->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function counterpartLabel(): string
|
||||
{
|
||||
return $this->counterpart_label ?: $this->label;
|
||||
|
||||
@@ -2,20 +2,36 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketPriorityFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketPriority): void {
|
||||
$ticketPriority->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketStatusFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -11,15 +12,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketStatus): void {
|
||||
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
|
||||
$ticketStatus->project_template_id = BoardColumn::query()
|
||||
->whereKey($ticketStatus->board_column_id)
|
||||
->value('project_template_id');
|
||||
}
|
||||
|
||||
$ticketStatus->syncProjectTemplateOwnership();
|
||||
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketTypeFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketType): void {
|
||||
$ticketType->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectConfigurationObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
public function saved(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
{
|
||||
if (! $this->projectTemplateOverrideManager->isRecording()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = $model->project()->first();
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$area = match (true) {
|
||||
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
|
||||
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($area === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshProjectBaseTemplate->handle($project, $area);
|
||||
$this->projectTemplateOverrideManager->mark($project, $area);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
|
||||
use App\Actions\Notifications\TicketStatusChanged;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\Team;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use App\Observers\ProjectConfigurationObserver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
foreach ([
|
||||
BoardColumn::class,
|
||||
NotificationAction::class,
|
||||
RepositoryConnection::class,
|
||||
TicketFieldDefinition::class,
|
||||
TicketLinkType::class,
|
||||
TicketPriority::class,
|
||||
TicketStatus::class,
|
||||
TicketType::class,
|
||||
] as $model) {
|
||||
$model::observe(ProjectConfigurationObserver::class);
|
||||
}
|
||||
|
||||
if ($this->app->environment() !== 'local') {
|
||||
URL::forceScheme('https');
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
$exceptions->dontFlash(['api_token']);
|
||||
})->create();
|
||||
|
||||
+6
-6
@@ -47,15 +47,15 @@
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
"pnpm install",
|
||||
"pnpm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"dev:ssr": [
|
||||
"npm run build:ssr",
|
||||
"pnpm run build:ssr",
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
||||
],
|
||||
@@ -67,9 +67,9 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm audit --audit-level=high",
|
||||
"npm run lint:check",
|
||||
"npm run types:check",
|
||||
"pnpm audit --audit-level=high",
|
||||
"pnpm run lint:check",
|
||||
"pnpm run types:check",
|
||||
"@test"
|
||||
],
|
||||
"test": [
|
||||
|
||||
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
|
||||
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
'template_overrides' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,120 @@
|
||||
<?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',
|
||||
'project_templates.is_published as template_is_published',
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
|
||||
if ($projectTemplateId === null || (
|
||||
! (bool) $project->template_is_published
|
||||
&& (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 && ! (bool) $project->template_is_published) {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->json('template_overrides')->default('[]')->after('project_template_id');
|
||||
});
|
||||
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||
$template->forceFill([
|
||||
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
),
|
||||
])->saveQuietly();
|
||||
});
|
||||
|
||||
Project::query()
|
||||
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
|
||||
->with('projectTemplate')
|
||||
->eachById(function (Project $project) use ($configuration): void {
|
||||
$templateConfiguration = $configuration->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(array_unique([
|
||||
...array_keys($templateConfiguration),
|
||||
...DuplicateProjectConfiguration::copyableAreas(),
|
||||
]));
|
||||
$currentConfiguration = $configuration->export($project, $areas);
|
||||
$overrides = collect($areas)
|
||||
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|
||||
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('template_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
+4
-1
@@ -25,8 +25,11 @@ const prettierOptions = {
|
||||
const eslintConfig = [
|
||||
{
|
||||
ignores: [
|
||||
'bootstrap/ssr/**',
|
||||
'**/dist/**',
|
||||
'node_modules/**',
|
||||
'**/node_modules/**',
|
||||
'native/src-tauri/target/**',
|
||||
'native/src-tauri/gen/**',
|
||||
'.github/**',
|
||||
'**/*.yml',
|
||||
'**/*.yaml',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:macos": "tauri build --debug --bundles app",
|
||||
"test": "node --test tests/*.test.js && cargo test --manifest-path src-tauri/Cargo.toml --locked",
|
||||
"check": "cargo fmt --manifest-path src-tauri/Cargo.toml --check && cargo clippy --manifest-path src-tauri/Cargo.toml --locked --all-targets --all-features -- -D warnings",
|
||||
"smoke:macos": "cargo test --manifest-path src-tauri/Cargo.toml --locked --features smoke-test,custom-protocol --test macos_smoke",
|
||||
"dev:android": "tauri android dev",
|
||||
"build:android": "tauri android build --debug --apk --target aarch64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
}
|
||||
}
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
'@tauri-apps/cli':
|
||||
specifier: 2.11.4
|
||||
version: 2.11.4
|
||||
|
||||
packages:
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||
resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||
resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||
resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||
resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||
resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||
resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||
resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.11.4':
|
||||
resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-riscv64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.11.4':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.11.4
|
||||
'@tauri-apps/cli-darwin-x64': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.11.4
|
||||
'@tauri-apps/cli-linux-riscv64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.11.4
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.11.4
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.4
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.4
|
||||
@@ -0,0 +1 @@
|
||||
minimumReleaseAge: 10080
|
||||
Generated
+4509
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.0"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "kanbangit_native"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
smoke-test = []
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
[[test]]
|
||||
name = "macos_smoke"
|
||||
harness = false
|
||||
required-features = ["smoke-test"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,26 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
/app/tauri.build.gradle.kts
|
||||
/app/src/main/jniLibs
|
||||
/app/src/main/java/de/kanbangit/desktop/generated
|
||||
|
||||
.kotlin
|
||||
/app/tauri.properties
|
||||
@@ -0,0 +1,6 @@
|
||||
/src/main/**/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
@@ -0,0 +1,76 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
buildToolsVersion = "36.0.0"
|
||||
namespace = "de.kanbangit.desktop"
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "de.kanbangit.desktop"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// Tauri exits the process when its last activity is destroyed; report results first.
|
||||
testInstrumentationRunnerArguments["waitForActivitiesToComplete"] = "false"
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging {
|
||||
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlatformTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
|
||||
private fun evaluate(view: WebView, script: String): String {
|
||||
val result = AtomicReference<String>()
|
||||
val latch = CountDownLatch(1)
|
||||
instrumentation.runOnMainSync { view.evaluateJavascript(script) { result.set(it); latch.countDown() } }
|
||||
assertTrue("JavaScript callback timed out", latch.await(10, TimeUnit.SECONDS))
|
||||
return result.get()
|
||||
}
|
||||
|
||||
private fun waitFor(condition: () -> Boolean) {
|
||||
val end = System.currentTimeMillis() + 25000
|
||||
while (System.currentTimeMillis() < end) {
|
||||
if (condition()) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
fail("Platform condition timed out")
|
||||
}
|
||||
|
||||
private fun tapCenter(view: View) {
|
||||
val position = IntArray(2)
|
||||
instrumentation.runOnMainSync {
|
||||
view.getLocationOnScreen(position)
|
||||
position[0] += view.width / 2
|
||||
position[1] += view.height / 2
|
||||
}
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
for (action in listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP)) {
|
||||
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, position[0].toFloat(), position[1].toFloat(), 0)
|
||||
event.source = InputDevice.SOURCE_TOUCHSCREEN
|
||||
try {
|
||||
instrumentation.sendPointerSync(event)
|
||||
} finally {
|
||||
event.recycle()
|
||||
}
|
||||
}
|
||||
instrumentation.waitForIdleSync()
|
||||
}
|
||||
|
||||
@Test fun workspacePreservesFormsAndIsolatesServers() {
|
||||
instrumentation.targetContext.getSharedPreferences("workspace", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
val scenario = ActivityScenario.launch(MainActivity::class.java)
|
||||
Thread.sleep(1500)
|
||||
scenario.onActivity { it.openWorkspace("https://kanbangit.de/") }
|
||||
val remote = AtomicReference<WebView>()
|
||||
waitFor {
|
||||
var visible = false
|
||||
scenario.onActivity { remote.set(it.workspace); visible = it.workspace?.visibility == View.VISIBLE }
|
||||
visible && remote.get() != null && evaluate(remote.get(), "document.readyState === 'complete' && document.querySelector('#app')?.innerText.trim().length > 100") == "true"
|
||||
}
|
||||
val view = remote.get()
|
||||
assertEquals("true", evaluate(view, "typeof window.nativeLauncher === 'undefined' && typeof window.__TAURI_INTERNALS__ === 'undefined'"))
|
||||
assertEquals("true", evaluate(view, """
|
||||
(() => {
|
||||
const input = document.createElement('input');
|
||||
input.id = 'kan53-input';
|
||||
input.value = 'retained form';
|
||||
document.body.append(input);
|
||||
const button = document.createElement('button');
|
||||
button.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;z-index:2147483647';
|
||||
button.textContent = 'Navigate';
|
||||
button.onclick = () => {
|
||||
history.pushState({}, '', '/kan53-history');
|
||||
button.remove();
|
||||
};
|
||||
document.body.append(button);
|
||||
return true;
|
||||
})()
|
||||
""".trimIndent()))
|
||||
tapCenter(view)
|
||||
waitFor { evaluate(view, "location.pathname === '/kan53-history'") == "true" }
|
||||
waitFor {
|
||||
var canGoBack = false
|
||||
scenario.onActivity { canGoBack = view.canGoBack() }
|
||||
canGoBack
|
||||
}
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
waitFor { evaluate(view, "location.pathname !== '/kan53-history'") == "true" }
|
||||
assertEquals("\"retained form\"", evaluate(view, "document.querySelector('#kan53-input').value"))
|
||||
evaluate(view, "document.cookie = 'kan53_fixture=secret; Path=/; Secure; SameSite=Lax'; localStorage.setItem('kan53_fixture', 'secret')")
|
||||
scenario.onActivity { it.openWorkspace("https://127.0.0.1:9/") }
|
||||
waitFor {
|
||||
val cookie = AtomicReference<String>()
|
||||
instrumentation.runOnMainSync { cookie.set(CookieManager.getInstance().getCookie("https://kanbangit.de/")) }
|
||||
cookie.get()?.contains("kan53_fixture") != true
|
||||
}
|
||||
scenario.onActivity { assertNotSame(view, it.workspace) }
|
||||
// Let instrumentation finish the process after reporting, rather than exiting via Tauri.
|
||||
scenario.moveToState(Lifecycle.State.CREATED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@drawable/kanbangit"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.kanbangit_native"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,306 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val preferences by lazy { getSharedPreferences("workspace", MODE_PRIVATE) }
|
||||
private lateinit var launcher: WebView
|
||||
private lateinit var root: LinearLayout
|
||||
private lateinit var content: FrameLayout
|
||||
private lateinit var settings: Button
|
||||
internal var workspace: WebView? = null
|
||||
private set
|
||||
private var server = NavigationPolicy.DEFAULT_SERVER
|
||||
private var phase = "idle"
|
||||
private var ready = false
|
||||
private var revision = 0
|
||||
private var launcherVisible = true
|
||||
private var keyboardVisible = false
|
||||
private var initialized = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
when (NavigationPolicy.backAction(keyboardVisible, launcherVisible, ready, workspace?.canGoBack() == true)) {
|
||||
"keyboard" -> WindowCompat.getInsetsController(window, window.decorView).hide(WindowInsetsCompat.Type.ime())
|
||||
"resume" -> showWorkspace()
|
||||
"history" -> workspace?.goBack()
|
||||
"settings" -> showLauncher()
|
||||
else -> moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
launcher = webView
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
WebViewCompat.addWebMessageListener(launcher, "nativeLauncher", setOf(NavigationPolicy.LAUNCHER_ORIGIN)) {
|
||||
_, message, origin, mainFrame, _ ->
|
||||
if (mainFrame && origin.toString().trimEnd('/') == NavigationPolicy.LAUNCHER_ORIGIN) {
|
||||
launcher.post { handleAction(message.data.orEmpty()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
launcher.post { attachShell() }
|
||||
}
|
||||
|
||||
private fun attachShell() {
|
||||
if (::root.isInitialized) return
|
||||
(launcher.parent as? ViewGroup)?.removeView(launcher)
|
||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
settings = Button(this).apply {
|
||||
text = getString(R.string.server_settings)
|
||||
isAllCaps = false
|
||||
visibility = View.GONE
|
||||
setOnClickListener { showLauncher() }
|
||||
}
|
||||
root.addView(settings, LinearLayout.LayoutParams(-1, -2))
|
||||
content = FrameLayout(this)
|
||||
content.addView(launcher, FrameLayout.LayoutParams(-1, -1))
|
||||
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
setContentView(root)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
keyboardVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
|
||||
view.setPadding(safe.left, safe.top, safe.right, maxOf(safe.bottom, keyboard.bottom))
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
||||
content.removeAllViews()
|
||||
content.addView(TextView(this).apply { text = getString(R.string.update_webview) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAction(value: String) {
|
||||
attachShell()
|
||||
val url = Uri.parse(value)
|
||||
if (url.scheme != "https" || url.host != "tauri.localhost" || url.port != -1 || url.userInfo != null) return
|
||||
when (url.path) {
|
||||
"/action/ready" -> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
val saved = preferences.getString("server", null)
|
||||
server = saved?.let(NavigationPolicy::installation) ?: NavigationPolicy.DEFAULT_SERVER
|
||||
updateLauncher()
|
||||
if (saved != null) openWorkspace(server)
|
||||
}
|
||||
"/action/open" -> openWorkspace(url.getQueryParameter("server").orEmpty())
|
||||
"/action/resume" -> if (ready) showWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun openWorkspace(input: String) {
|
||||
val address = NavigationPolicy.installation(input)
|
||||
if (address == null) {
|
||||
phase = "invalidServer"
|
||||
showLauncher()
|
||||
return
|
||||
}
|
||||
if (phase == "connecting") return
|
||||
if (address == server && ready) {
|
||||
if (phase == "connectionError") {
|
||||
phase = "connecting"
|
||||
workspace?.let { startTimeout(it); it.reload() }
|
||||
updateLauncher()
|
||||
return
|
||||
}
|
||||
showWorkspace()
|
||||
return
|
||||
}
|
||||
val changed = address != preferences.getString("server", null)
|
||||
val clearSession = changed || preferences.getBoolean("clearing", false)
|
||||
if (!preferences.edit().putString("server", address).putBoolean("clearing", clearSession).commit()) {
|
||||
phase = "storageError"
|
||||
showLauncher()
|
||||
return
|
||||
}
|
||||
server = address
|
||||
ready = false
|
||||
phase = "connecting"
|
||||
revision++
|
||||
workspace?.let { old ->
|
||||
content.removeView(old)
|
||||
old.stopLoading()
|
||||
if (clearSession) old.clearCache(true)
|
||||
old.destroy()
|
||||
}
|
||||
workspace = null
|
||||
updateLauncher()
|
||||
if (clearSession) {
|
||||
WebStorage.getInstance().deleteAllData()
|
||||
CookieManager.getInstance().removeAllCookies {
|
||||
CookieManager.getInstance().flush()
|
||||
if (!preferences.edit().putBoolean("clearing", false).commit()) {
|
||||
phase = "storageError"
|
||||
showLauncher()
|
||||
} else if (!isDestroyed) {
|
||||
createWorkspace()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspace()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun createWorkspace() {
|
||||
val remote = WebView(this)
|
||||
workspace = remote
|
||||
remote.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(true)
|
||||
}
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(remote, false)
|
||||
remote.webChromeClient = WebChromeClient()
|
||||
remote.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
|
||||
!NavigationPolicy.allows(server, request.url.toString())
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
|
||||
if (!NavigationPolicy.allows(server, url)) {
|
||||
view.stopLoading()
|
||||
fail(view)
|
||||
return
|
||||
}
|
||||
phase = "connecting"
|
||||
startTimeout(view)
|
||||
updateLauncher()
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
if (workspace !== view || phase != "connecting" || !NavigationPolicy.allows(server, url)) return
|
||||
revision++
|
||||
phase = "connected"
|
||||
val firstLoad = !ready
|
||||
ready = true
|
||||
CookieManager.getInstance().flush()
|
||||
updateLauncher()
|
||||
if (firstLoad) showWorkspace()
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||
if (request.isForMainFrame) fail(view)
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
|
||||
if (request.isForMainFrame && response.statusCode >= 500) fail(view)
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(view: WebView, sslHandler: SslErrorHandler, error: SslError) {
|
||||
sslHandler.cancel()
|
||||
fail(view)
|
||||
}
|
||||
}
|
||||
remote.visibility = View.GONE
|
||||
content.addView(remote, 0, FrameLayout.LayoutParams(-1, -1))
|
||||
startTimeout(remote)
|
||||
remote.loadUrl(server)
|
||||
}
|
||||
|
||||
private fun startTimeout(view: WebView) {
|
||||
val current = ++revision
|
||||
handler.postDelayed({
|
||||
if (current == revision && phase == "connecting" && workspace === view) {
|
||||
view.stopLoading()
|
||||
fail(view)
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
private fun fail(view: WebView) {
|
||||
if (workspace !== view) return
|
||||
revision++
|
||||
phase = "connectionError"
|
||||
showLauncher()
|
||||
}
|
||||
|
||||
private fun updateLauncher() {
|
||||
val query = Uri.Builder().appendQueryParameter("server", server)
|
||||
.appendQueryParameter("phase", phase).appendQueryParameter("ready", ready.toString())
|
||||
.appendQueryParameter("platform", "android").build().encodedQuery.orEmpty()
|
||||
launcher.evaluateJavascript("window.updateLauncher?.(${JSONObject.quote(query)})", null)
|
||||
}
|
||||
|
||||
private fun showLauncher() {
|
||||
updateLauncher()
|
||||
launcherVisible = true
|
||||
launcher.visibility = View.VISIBLE
|
||||
workspace?.visibility = View.GONE
|
||||
settings.visibility = View.GONE
|
||||
launcher.requestFocus()
|
||||
}
|
||||
|
||||
private fun showWorkspace() {
|
||||
launcherVisible = false
|
||||
launcher.visibility = View.GONE
|
||||
workspace?.visibility = View.VISIBLE
|
||||
settings.visibility = View.VISIBLE
|
||||
workspace?.requestFocus()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
workspace?.onPause()
|
||||
CookieManager.getInstance().flush()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
workspace?.onResume()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
enableEdgeToEdge()
|
||||
if (::root.isInitialized) ViewCompat.requestApplyInsets(root)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
workspace?.destroy()
|
||||
workspace = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
object NavigationPolicy {
|
||||
const val DEFAULT_SERVER = "https://kanbangit.de/"
|
||||
const val LAUNCHER_ORIGIN = "https://tauri.localhost"
|
||||
|
||||
fun installation(input: String): String? = runCatching {
|
||||
val trimmed = input.trim()
|
||||
require(trimmed.none { it.isISOControl() || it == '\\' })
|
||||
val uri = URI(trimmed)
|
||||
require(uri.scheme.equals("https", ignoreCase = true))
|
||||
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null)
|
||||
require(uri.rawQuery == null && uri.rawFragment == null)
|
||||
require(uri.port == -1 || uri.port in 1..65535)
|
||||
require(!uri.host.equals("tauri.localhost", ignoreCase = true))
|
||||
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
||||
val path = uri.rawPath.orEmpty().let { if (it.endsWith('/')) it else "$it/" }
|
||||
"https://${uri.host.lowercase(Locale.ROOT)}$port$path"
|
||||
}.getOrNull()
|
||||
|
||||
fun allows(server: String, destination: String): Boolean = runCatching {
|
||||
val base = URI(server)
|
||||
val target = URI(destination)
|
||||
target.scheme.equals("https", ignoreCase = true) &&
|
||||
target.host.equals(base.host, ignoreCase = true) &&
|
||||
(if (target.port == -1) 443 else target.port) ==
|
||||
(if (base.port == -1) 443 else base.port) &&
|
||||
target.rawUserInfo == null && !destination.contains('\\')
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun backAction(keyboard: Boolean, launcher: Boolean, ready: Boolean, history: Boolean): String = when {
|
||||
keyboard -> "keyboard"
|
||||
launcher && ready -> "resume"
|
||||
!launcher && history -> "history"
|
||||
!launcher -> "settings"
|
||||
else -> "background"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="64" android:viewportHeight="64">
|
||||
<path android:fillColor="#22c55e" android:pathData="M12,0 H52 Q64,0 64,12 V52 Q64,64 52,64 H12 Q0,64 0,52 V12 Q0,0 12,0 Z" />
|
||||
<path android:fillColor="@android:color/transparent" android:strokeColor="#004b1e"
|
||||
android:strokeWidth="3" android:strokeLineCap="round" android:strokeLineJoin="round"
|
||||
android:pathData="M19,16 H45 Q48,16 48,19 V45 Q48,48 45,48 H19 Q16,48 16,45 V19 Q16,16 19,16 Z M27,16 V48 M37,16 V48" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">"KanbanGit"</string>
|
||||
<string name="main_activity_title">"KanbanGit"</string>
|
||||
<string name="server_settings">Server settings</string>
|
||||
<string name="update_webview">Update Android System WebView to connect to your workspace.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.kanbangit_native" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.kanbangit.desktop
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationPolicyTest {
|
||||
@Test fun normalizesAddressesAndRejectsCredentialsAndUnsafeSchemes() {
|
||||
assertEquals("https://example.com/kanban/", NavigationPolicy.installation(" HTTPS://Example.com:443/kanban "))
|
||||
assertEquals("https://[::1]:8443/", NavigationPolicy.installation("https://[::1]:8443"))
|
||||
for (value in listOf("http://example.com", "https:example.com", "https://user:pass@example.com", "https://example.com/?token=x", "https://example.com/#x", "https://exa\nmple.com", "https://example.com\\other", "https://example.com:99999", "https://tauri.localhost/", "file:///etc/passwd", "")) {
|
||||
assertNull(value, NavigationPolicy.installation(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun allowsLoginTwoFactorAndHistoryOnlyOnTheSelectedOrigin() {
|
||||
val server = "https://example.com:8443/app/"
|
||||
for (path in listOf("/login", "/two-factor-challenge", "/projects/1?ticket=53#details")) {
|
||||
assertTrue(NavigationPolicy.allows(server, "https://example.com:8443$path"))
|
||||
}
|
||||
for (value in listOf("https://example.com/", "http://example.com:8443/", "https://other.example.com:8443/", "https://user@example.com:8443/", "https://tauri.localhost/action/open", "javascript:alert(1)", "intent://example.com", "file:///etc/passwd")) {
|
||||
assertFalse(value, NavigationPolicy.allows(server, value))
|
||||
}
|
||||
assertTrue(NavigationPolicy.allows("https://example.com/", "https://example.com:443/login"))
|
||||
}
|
||||
|
||||
@Test fun backClosesKeyboardBeforeNavigatingAndPreservesTheWorkspace() {
|
||||
assertEquals("keyboard", NavigationPolicy.backAction(true, false, true, true))
|
||||
assertEquals("resume", NavigationPolicy.backAction(false, true, true, true))
|
||||
assertEquals("history", NavigationPolicy.backAction(false, false, true, true))
|
||||
assertEquals("settings", NavigationPolicy.backAction(false, false, true, false))
|
||||
assertEquals("background", NavigationPolicy.backAction(false, true, false, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """pnpm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user