Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1291a0fcad | ||
|
|
5769d84526 | ||
|
|
99264c7bc8 | ||
|
|
1b448e363c | ||
|
|
65e9df6d6d | ||
|
|
7d7b723f69 | ||
|
|
7a656de7ce | ||
|
|
811dbf7fa4 | ||
|
|
5567eea06f | ||
|
|
ca1f243234 | ||
|
|
9ed8d0c6cc | ||
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 | ||
|
|
752ba2cd38 | ||
|
|
1cf7524a7b |
@@ -1,78 +1,18 @@
|
||||
name: "Lint and Test APP"
|
||||
description: "Run Pint and the Pest test suite"
|
||||
name: test_and_check_app
|
||||
description: Build the test image and run all application checks
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||
with:
|
||||
php-version: "8.5"
|
||||
extensions: mbstring, pdo_sqlite
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Verify Composer lockfile
|
||||
- name: Test and check app
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer_cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Prepare environment file
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
shell: bash
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
shell: bash
|
||||
|
||||
- name: Generate Wayfinder routes and actions
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Run independent application checks
|
||||
run: |
|
||||
pnpm exec concurrently \
|
||||
--names frontend,backend \
|
||||
--prefix-colors cyan,magenta \
|
||||
--kill-others-on-fail \
|
||||
"composer run ci:frontend" \
|
||||
"composer run ci:backend"
|
||||
shell: bash
|
||||
|
||||
- name: Run Laravel tests
|
||||
run: composer run ci:test
|
||||
shell: bash
|
||||
docker buildx build \
|
||||
--file Dockerfile \
|
||||
--target test \
|
||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||
--progress plain \
|
||||
--load \
|
||||
.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Working Agreements
|
||||
|
||||
## Native app versions and build numbers
|
||||
|
||||
For every delivered change under `native/`, increment the app version and bundle/build number for the affected platforms in the same change before committing. Do this for fixes and follow-up changes too; do not wait for a separate release or a user reminder.
|
||||
|
||||
- macOS changes require a higher macOS app version and `bundle.macOS.bundleVersion`.
|
||||
- iOS changes require a higher iOS app version and `bundle.iOS.bundleVersion`, including the matching Xcode `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in both `project.yml` and `project.pbxproj`.
|
||||
- Shared changes require a higher general native app version and higher bundle/build numbers for every affected platform. Android changes also require a higher `bundle.android.versionCode`; this metadata rule does not imply Android build support.
|
||||
- The app version is currently shared: keep `native/package.json`, `native/src-tauri/tauri.conf.json`, the native package entries in `Cargo.toml` and `Cargo.lock`, and the Xcode marketing versions consistent. Use a patch increment unless the requested release calls for a minor or major increment. Never decrease or reuse an existing build number.
|
||||
- Update the native release notes and run the existing version consistency test. One coordinated increment per delivered change is sufficient; intermediate edits and test reruns do not need additional increments.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -23,6 +24,7 @@ trait ManagesTicketStatusPositions
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'ticket_statuses');
|
||||
}
|
||||
|
||||
private function statusContextLabel(TicketStatus $ticketStatus): string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -50,5 +51,9 @@ trait ReordersBoardColumns
|
||||
'position' => $index + 1,
|
||||
])->saveQuietly();
|
||||
});
|
||||
$project = $columns->first()?->project;
|
||||
if ($project !== null) {
|
||||
app(RefreshProjectBaseTemplate::class)->handle($project, 'board_columns');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Actions\ProjectBoard;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
@@ -20,22 +22,25 @@ class UpdateTicketStatusTransitions
|
||||
*/
|
||||
public function handle(TicketStatus $ticketStatus, array $validated): TicketStatus
|
||||
{
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
return DB::transaction(function () use ($ticketStatus, $validated): TicketStatus {
|
||||
Project::query()->whereKey($ticketStatus->project_id)->lockForUpdate()->firstOrFail();
|
||||
$allowedTicketStatusIds = collect(Arr::get($validated, 'allowed_ticket_status_ids', []))
|
||||
->map(fn (int|string $ticketStatusId): int => (int) $ticketStatusId)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$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();
|
||||
return $ticketStatus->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
@@ -20,19 +21,22 @@ class UpdateProjectTicketMetadataSettings
|
||||
*/
|
||||
public function handle(Project $project, array $validated): Project
|
||||
{
|
||||
$project->update([
|
||||
'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 DB::transaction(function () use ($project, $validated): Project {
|
||||
Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->update([
|
||||
'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();
|
||||
return $project->fresh();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class CreateProject
|
||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||
$sourceTemplate = ProjectTemplate::query()
|
||||
->published()
|
||||
->lockForUpdate()
|
||||
->findOrFail((int) $sourceTemplateId);
|
||||
|
||||
$project->forceFill([
|
||||
@@ -72,6 +73,7 @@ class CreateProject
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh() ?? $project,
|
||||
$sourceTemplate->configuration_snapshot ?? [],
|
||||
adoptExisting: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,18 @@ class CreateProjectBaseTemplate
|
||||
{
|
||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||
|
||||
$slug = strtolower($project->key).'-base-template';
|
||||
$suffix = 1;
|
||||
$candidate = $slug;
|
||||
while (ProjectTemplate::query()->where('slug', $candidate)->exists()) {
|
||||
$candidate = $slug.'-'.++$suffix;
|
||||
}
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
'name' => "{$project->name} base template",
|
||||
'slug' => strtolower($project->key).'-base-template',
|
||||
'slug' => $candidate,
|
||||
'is_published' => false,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||
|
||||
@@ -5,28 +5,61 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $attributes */
|
||||
public function empty(array $attributes): ProjectTemplate
|
||||
{
|
||||
$areas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'slug' => Arr::get($attributes, 'slug'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $areas,
|
||||
'configuration_snapshot' => collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $this->projectConfigurationIdentity->defaults()),
|
||||
))->only($areas)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 DB::transaction(function () use ($sourceProject, $attributes): ProjectTemplate {
|
||||
$sourceProject = Project::query()->whereKey($sourceProject->id)->lockForUpdate()->firstOrFail();
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', []));
|
||||
$template = $sourceProject->projectTemplate;
|
||||
if ($template === null || $template->is_published || $template->project_id !== $sourceProject->id) {
|
||||
$template = new ProjectTemplate;
|
||||
$template->slug = Arr::get($attributes, 'slug');
|
||||
}
|
||||
$template->forceFill([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'is_published' => true,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
])->save();
|
||||
$sourceProject->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => array_values(array_diff(DuplicateProjectConfiguration::copyableAreas(), $copyAreas)),
|
||||
])->saveQuietly();
|
||||
foreach ($sourceProject->configurationRelations() as $relation) {
|
||||
$sourceProject->{$relation}()->update(['project_template_id' => $template->id]);
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
return $template;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProject
|
||||
{
|
||||
@@ -10,7 +11,13 @@ class DeleteProject
|
||||
{
|
||||
$projectName = $project->name;
|
||||
|
||||
$project->delete();
|
||||
DB::transaction(function () use ($project): void {
|
||||
$baseTemplate = $project->baseTemplate()->first();
|
||||
|
||||
$project->ticketStatuses()->delete();
|
||||
$project->delete();
|
||||
$baseTemplate?->delete();
|
||||
});
|
||||
|
||||
return $projectName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$projects = $template->linkedProjects()->orderBy('id')->lockForUpdate()->get();
|
||||
$template->delete();
|
||||
foreach ($projects as $project) {
|
||||
$base = $project->baseTemplate()->first() ?? $this->createProjectBaseTemplate->handle($project);
|
||||
$base->forceFill(['configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, DuplicateProjectConfiguration::copyableAreas())])->saveQuietly();
|
||||
$project->forceFill(['project_template_id' => $base->id, 'template_overrides' => []])->saveQuietly();
|
||||
foreach ($project->configurationRelations() as $relation) {
|
||||
$project->{$relation}()->update(['project_template_id' => $base->id]);
|
||||
}
|
||||
}
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Actions\Notifications\NormalizeNotificationFilters;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
@@ -32,6 +36,7 @@ class DuplicateProjectConfiguration
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly ProjectConfigurationIdentity $projectConfigurationIdentity,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -108,7 +113,7 @@ class DuplicateProjectConfiguration
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
return $this->projectConfigurationIdentity->normalize($configuration);
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
@@ -133,23 +138,24 @@ class DuplicateProjectConfiguration
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
$columnKeys = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column): array => [$column->id => $column->configuration_key],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
$statusKeys = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status): array => [$status->id => $status->configuration_key],
|
||||
);
|
||||
|
||||
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],
|
||||
static fn ($column): array => ['configuration_key' => $column->configuration_key, '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),
|
||||
'configuration_key' => $status->configuration_key,
|
||||
'board_column_key' => $status->board_column_id === null ? null : $columnKeys->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
@@ -158,9 +164,12 @@ class DuplicateProjectConfiguration
|
||||
|
||||
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(),
|
||||
static fn ($status): array => [
|
||||
'status_key' => $status->configuration_key,
|
||||
'target_keys' => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?string => $statusKeys->get($id),
|
||||
)->filter()->sort()->values()->all(),
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
@@ -179,7 +188,7 @@ class DuplicateProjectConfiguration
|
||||
'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 ?? [],
|
||||
'filters' => $this->mapNotificationFilters($item->filters ?? [], $statusKeys->all()),
|
||||
],
|
||||
];
|
||||
$relations = [
|
||||
@@ -190,7 +199,9 @@ class DuplicateProjectConfiguration
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map(
|
||||
static fn ($item): array => ['configuration_key' => $item->configuration_key, ...$mapper($item)],
|
||||
)->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,95 +211,124 @@ class DuplicateProjectConfiguration
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
* @return list<string> Areas which retain referenced project configuration.
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = [], bool $adoptExisting = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): array {
|
||||
$targetProject = Project::query()->whereKey($targetProject->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
return $this->applyLocked($targetProject, $configuration, $statusReplacements, $adoptExisting);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @param array<int|string, int|string> $statusReplacements @return list<string> */
|
||||
private function applyLocked(Project $targetProject, array $configuration, array $statusReplacements, bool $adoptExisting): array
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements, $adoptExisting): 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(),
|
||||
$currentStatuses = $targetProject->ticketStatuses()->withCount('tickets')->get();
|
||||
$nextStatuses = collect($configuration[self::COPY_TICKET_STATUSES] ?? [])->keyBy('configuration_key');
|
||||
$columnKeys = array_column($configuration[self::COPY_BOARD_COLUMNS], 'configuration_key');
|
||||
$statusesCanChange = isset($configuration[self::COPY_TICKET_STATUSES]) && $currentStatuses->every(
|
||||
static fn ($status): bool => $nextStatuses->has($status->configuration_key)
|
||||
|| $status->tickets_count === 0 || array_key_exists($status->id, $statusReplacements),
|
||||
);
|
||||
if ($blocked) {
|
||||
$canDeleteColumn = static fn (Model $column): bool => $currentStatuses->where('board_column_id', $column->id)->every(
|
||||
static function ($status) use ($column, $statusesCanChange, $nextStatuses, $columnKeys): bool {
|
||||
if (! $statusesCanChange) {
|
||||
return false;
|
||||
}
|
||||
$next = $nextStatuses->get($status->configuration_key);
|
||||
$nextColumn = $next['board_column_key'] ?? null;
|
||||
|
||||
return $next === null || ($nextColumn !== $column->configuration_key
|
||||
&& ($nextColumn === null || in_array($nextColumn, $columnKeys, true)));
|
||||
},
|
||||
);
|
||||
if ($this->syncModels($targetProject, self::COPY_BOARD_COLUMNS, $configuration[self::COPY_BOARD_COLUMNS],
|
||||
$canDeleteColumn, $adoptExisting)) {
|
||||
$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']);
|
||||
$columns = $targetProject->boardColumns()->get()->keyBy('configuration_key');
|
||||
$existingStatuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns, $existingStatuses): array {
|
||||
$columnKey = $item['board_column_key'] ?? null;
|
||||
$item['board_column_id'] = $columnKey === null
|
||||
? null
|
||||
: ($columns->get($columnKey)?->id ?? $existingStatuses->get($item['configuration_key'])?->board_column_id);
|
||||
$item['slug'] = Str::slug($item['name']);
|
||||
unset($item['board_column_key']);
|
||||
|
||||
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) {
|
||||
$templateKeys = array_column($items, 'configuration_key');
|
||||
$canDelete = static fn (Model $status): bool => ! $status->tickets()->exists()
|
||||
|| array_key_exists($status->id, $statusReplacements);
|
||||
$beforeDelete = function (Model $status) use ($statusReplacements, $targetProject, $templateKeys): void {
|
||||
$replacement = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacement === null) {
|
||||
return;
|
||||
}
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)
|
||||
->where('configuration_key', (string) $replacement)->first();
|
||||
if ($replacementStatus === null && is_numeric($replacement)) {
|
||||
$replacementStatus = $targetProject->ticketStatuses()->whereIn('configuration_key', $templateKeys)->find($replacement);
|
||||
}
|
||||
if ($replacementStatus === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'status_replacements.'.$status->id => 'Choose a replacement from the current template.',
|
||||
]);
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementStatus->id]);
|
||||
$this->transferStatusTransitions($targetProject, $status, $replacementStatus, $statusReplacements, $templateKeys);
|
||||
};
|
||||
if ($this->syncModels($targetProject, self::COPY_TICKET_STATUSES, $items, $canDelete, $adoptExisting, $beforeDelete)) {
|
||||
$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);
|
||||
$statuses = $targetProject->ticketStatuses()->get()->keyBy('configuration_key');
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $transition) {
|
||||
$status = $statuses->get($transition['status_key']);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
$status->allowedTargetStatuses()->sync(collect($transition['target_keys'])->map(
|
||||
static fn (string $key): ?int => $statuses->get($key)?->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],
|
||||
$guards = [
|
||||
self::COPY_TICKET_TYPES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_PRIORITIES => static fn (Model $item): bool => ! $item->tickets()->exists(),
|
||||
self::COPY_TICKET_FIELDS => static fn (Model $item): bool => ! $item->ticketFieldValues()->exists(),
|
||||
self::COPY_TICKET_LINK_TYPES => static fn (Model $item): bool => ! $item->ticketLinks()->exists(),
|
||||
self::COPY_REPOSITORY_CONNECTIONS => null,
|
||||
self::COPY_NOTIFICATION_ACTIONS => null,
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
foreach ($guards as $area => $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) {
|
||||
$items = $configuration[$area];
|
||||
if ($area === self::COPY_NOTIFICATION_ACTIONS) {
|
||||
$statusIds = $targetProject->ticketStatuses()->pluck('id', 'configuration_key')->all();
|
||||
$items = array_map(fn (array $item): array => [
|
||||
...$item, 'filters' => $this->mapNotificationFilters($item['filters'] ?? [], $statusIds),
|
||||
], $items);
|
||||
}
|
||||
if ($this->syncModels($targetProject, $area, $items, $canDelete, $adoptExisting)) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
@@ -297,9 +337,8 @@ class DuplicateProjectConfiguration
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
return $blockedAreas;
|
||||
});
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
}
|
||||
@@ -310,30 +349,113 @@ class DuplicateProjectConfiguration
|
||||
/** @param list<string> $copyAreas */
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas), adoptExisting: true);
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $filters @param array<int|string, int|string> $mapping @return list<array<string, mixed>> */
|
||||
public function mapNotificationFilters(array $filters, array $mapping): array
|
||||
{
|
||||
return array_map(static function (array $filter) use ($mapping): array {
|
||||
if (! in_array($filter['attribute'], ['status.from.id', 'status.to.id'], true)) {
|
||||
return $filter;
|
||||
}
|
||||
$map = static fn (string $value): string => (string) ($mapping[$value] ?? $value);
|
||||
$filter['value'] = is_array($filter['value'])
|
||||
? array_map($map, $filter['value'])
|
||||
: ($filter['value'] === null ? null : $map($filter['value']));
|
||||
|
||||
return $filter;
|
||||
}, app(NormalizeNotificationFilters::class)->handle($filters));
|
||||
}
|
||||
|
||||
/** @param array<int|string, int|string> $replacements @param list<string> $templateKeys */
|
||||
private function transferStatusTransitions(Project $project, TicketStatus $removed, TicketStatus $replacement, array $replacements, array $templateKeys): void
|
||||
{
|
||||
$statuses = $project->ticketStatuses()->get();
|
||||
$targets = $statuses->whereIn('configuration_key', $templateKeys);
|
||||
$mapping = [];
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true)) {
|
||||
$mapping[$status->id] = $status->id;
|
||||
|
||||
continue;
|
||||
}
|
||||
$target = $replacements[$status->id] ?? null;
|
||||
if (is_scalar($target)) {
|
||||
$mapping[$status->id] = $targets->firstWhere('configuration_key', (string) $target)?->id
|
||||
?? (is_numeric($target) ? $targets->firstWhere('id', (int) $target)?->id : null);
|
||||
}
|
||||
}
|
||||
$mapping[$removed->id] = $replacement->id;
|
||||
$edges = DB::table('ticket_status_transitions')->where('from_ticket_status_id', $removed->id)
|
||||
->orWhere('to_ticket_status_id', $removed->id)->get();
|
||||
foreach ($edges as $edge) {
|
||||
$from = $mapping[$edge->from_ticket_status_id] ?? null;
|
||||
$to = $mapping[$edge->to_ticket_status_id] ?? null;
|
||||
if ($from !== null && $to !== null && $from !== $to) {
|
||||
$statuses->firstWhere('id', $from)->allowedTargetStatuses()->syncWithoutDetaching([$to]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
private function syncModels(Project $project, string $area, array $items, ?callable $canDelete, bool $adoptExisting, ?callable $beforeDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
$relation = ProjectConfigurationIdentity::relations()[$area];
|
||||
$models = $project->{$relation}()->get();
|
||||
$byKey = $models->keyBy('configuration_key');
|
||||
$matched = [];
|
||||
$assignments = [];
|
||||
foreach ($items as $item) {
|
||||
$model = $byKey->get($item['configuration_key']);
|
||||
if ($model === null && $adoptExisting) {
|
||||
$candidates = $models->filter(fn (Model $candidate): bool => ! in_array($candidate->id, $matched, true)
|
||||
&& $this->projectConfigurationIdentity->signature($area, $candidate->attributesToArray()) === $this->projectConfigurationIdentity->signature($area, $item));
|
||||
$model = $candidates->count() === 1 ? $candidates->first() : null;
|
||||
}
|
||||
if ($model !== null) {
|
||||
$matched[] = $model->id;
|
||||
if ($area === self::COPY_TICKET_FIELDS && $model->field_type !== $item['field_type'] && $model->ticketFieldValues()->exists()) {
|
||||
return true;
|
||||
}
|
||||
if ($area === self::COPY_TICKET_TYPES && $model->is_parent && ! ($item['is_parent'] ?? false)
|
||||
&& $model->tickets()->whereHas('childTickets')->exists()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
$assignments[] = [$model, $item];
|
||||
}
|
||||
$removed = $models->reject(static fn (Model $model): bool => in_array($model->id, $matched, true));
|
||||
foreach ($removed as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
if ($area === self::COPY_REPOSITORY_CONNECTIONS) {
|
||||
foreach ($models as $model) {
|
||||
$model->forceFill(['provider_repository_id' => 'template-sync-'.Str::uuid()])->saveQuietly();
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
if ($models->isNotEmpty() && $models->first()->getAttribute('position') !== null) {
|
||||
$offset = max((int) $models->max('position'), (int) collect($items)->max('position')) + $models->count() + count($items) + 1;
|
||||
foreach ($models->values() as $index => $model) {
|
||||
$model->forceFill(['position' => $offset + $index])->saveQuietly();
|
||||
}
|
||||
}
|
||||
foreach ($assignments as [$model, $item]) {
|
||||
$model === null ? $project->{$relation}()->create($item) : $model->forceFill($item)->saveQuietly();
|
||||
}
|
||||
foreach ($removed as $model) {
|
||||
if ($beforeDelete !== null) {
|
||||
$beforeDelete($model);
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
class ProjectConfigurationIdentity
|
||||
{
|
||||
/** @return array<string, string> */
|
||||
public static function relations(): array
|
||||
{
|
||||
return [
|
||||
'board_columns' => 'boardColumns', 'ticket_statuses' => 'ticketStatuses',
|
||||
'ticket_types' => 'ticketTypes', 'ticket_priorities' => 'ticketPriorities',
|
||||
'ticket_fields' => 'ticketFieldDefinitions', 'ticket_link_types' => 'ticketLinkTypes',
|
||||
'repository_connections' => 'repositoryConnections', 'notification_actions' => 'notificationActions',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalize(array $configuration): array
|
||||
{
|
||||
foreach (self::relations() as $area => $relation) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($configuration[$area] as $index => &$item) {
|
||||
$item['configuration_key'] ??= (string) Uuid::uuid5(
|
||||
Uuid::NAMESPACE_URL, 'kanbangit:'.$area.':'.$index.':'.$this->signature($area, $item),
|
||||
);
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
$columns = array_column($configuration['board_columns'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['ticket_statuses'] ?? [] as $index => $status) {
|
||||
if (! array_key_exists('board_column_key', $status)) {
|
||||
$columnIndex = $status['board_column_index'] ?? null;
|
||||
$status['board_column_key'] = $columnIndex === null ? null : ($columns[$columnIndex] ?? null);
|
||||
}
|
||||
unset($status['board_column_index']);
|
||||
$configuration['ticket_statuses'][$index] = $status;
|
||||
}
|
||||
|
||||
$statuses = array_column($configuration['ticket_statuses'] ?? [], 'configuration_key');
|
||||
foreach ($configuration['status_transitions'] ?? [] as $index => $transition) {
|
||||
if (array_key_exists('status_key', $transition)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($statuses[$index])) {
|
||||
continue;
|
||||
}
|
||||
$configuration['status_transitions'][$index] = [
|
||||
'status_key' => $statuses[$index],
|
||||
'target_keys' => array_values(array_filter(array_map(
|
||||
static fn (int $target): ?string => $statuses[$target] ?? null, $transition,
|
||||
))),
|
||||
];
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $item */
|
||||
public function signature(string $area, array $item): string
|
||||
{
|
||||
return $area === 'repository_connections'
|
||||
? ($item['provider'] ?? '').':'.($item['base_url'] ?? '').':'.($item['repository_full_name'] ?? '')
|
||||
: (string) ($item['name'] ?? $item['label'] ?? '');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function defaults(): array
|
||||
{
|
||||
$configuration = array_fill_keys(DuplicateProjectConfiguration::copyableAreas(), []);
|
||||
$configuration['project_settings'] = ['show_author' => true, 'show_story_points' => true];
|
||||
$configuration['ticket_statuses'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Backlog',
|
||||
'position' => 1, 'board_column_key' => null,
|
||||
]];
|
||||
$configuration['ticket_types'] = [
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Task', 'position' => 1, 'is_parent' => false],
|
||||
['configuration_key' => (string) Str::uuid(), 'name' => 'Release', 'position' => 2, 'is_parent' => true],
|
||||
];
|
||||
$configuration['ticket_priorities'] = [[
|
||||
'configuration_key' => (string) Str::uuid(), 'name' => 'Medium', 'position' => 1,
|
||||
]];
|
||||
$configuration['status_transitions'] = [[
|
||||
'status_key' => $configuration['ticket_statuses'][0]['configuration_key'], 'target_keys' => [],
|
||||
]];
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
@@ -14,18 +15,16 @@ class ProjectTemplateOverrideManager
|
||||
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();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->load('projectTemplate');
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
$project->forceFill(['template_overrides' => array_values(array_unique([
|
||||
...($project->template_overrides ?? []), $area,
|
||||
]))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +32,10 @@ class ProjectTemplateOverrideManager
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
DB::transaction(function () use ($project, $areas): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$project->forceFill(['template_overrides' => array_values(array_diff($project->template_overrides ?? [], $areas))])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
@@ -12,21 +14,17 @@ class RefreshProjectBaseTemplate
|
||||
|
||||
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();
|
||||
DB::transaction(function () use ($project, $area): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$template = ProjectTemplate::query()->find($project->project_template_id);
|
||||
if ($template === null || $template->is_published) {
|
||||
return;
|
||||
}
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$template->forceFill(['configuration_snapshot' => array_replace(
|
||||
$snapshot, $this->duplicateProjectConfiguration->export($project, [$area]),
|
||||
)])->saveQuietly();
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,9 @@ class ResolveProjectTemplateState
|
||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||
|
||||
return [
|
||||
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
||||
'effectiveConfiguration' => $currentConfiguration,
|
||||
'localConfiguration' => array_diff_key($currentConfiguration, $templateConfiguration),
|
||||
'localAreas' => collect(array_diff(array_keys($currentConfiguration), array_keys($templateConfiguration)))->map(fn (string $area): array => ['label' => $this->labelForArea($area), 'value' => $area])->values()->all(),
|
||||
'inheritedAreas' => $inheritedAreas,
|
||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||
@@ -96,24 +98,20 @@ class ResolveProjectTemplateState
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? null;
|
||||
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],
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$templateKeys = array_column($templateStatuses, 'configuration_key');
|
||||
$replacementOptions = collect($templateStatuses)->map(
|
||||
static fn (array $status): array => ['id' => $status['configuration_key'], '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();
|
||||
$removedStatuses = $statuses
|
||||
->filter(static fn ($status): bool => ! in_array($status->configuration_key, $templateKeys, true) && $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 => [
|
||||
|
||||
@@ -19,37 +19,34 @@ class UpdateProjectTemplate
|
||||
*/
|
||||
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();
|
||||
|
||||
return DB::transaction(function () use ($projectTemplate, $attributes): ProjectTemplate {
|
||||
$projectTemplate = ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration($projectTemplate->configuration_snapshot ?? []);
|
||||
$copyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas(Arr::get($attributes, 'copy_areas', $projectTemplate->copy_areas ?? []));
|
||||
$nextConfiguration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
Arr::get($attributes, 'configuration_snapshot', $previousConfiguration),
|
||||
))->only($copyAreas)->all();
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()->all();
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'name' => Arr::get($attributes, 'name'),
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
->chunkById(100, function ($linkedProjects) use ($projectTemplate, $affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$linkedProject = Project::query()
|
||||
->whereKey($linkedProject->id)
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->lockForUpdate()->first();
|
||||
if ($linkedProject === null) {
|
||||
continue;
|
||||
}
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
$replacementConfiguration = [];
|
||||
@@ -81,8 +78,8 @@ class UpdateProjectTemplate
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Actions\Projects;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class UpdateProjectTemplateLink
|
||||
{
|
||||
@@ -16,6 +17,7 @@ class UpdateProjectTemplateLink
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
@@ -42,19 +44,27 @@ class UpdateProjectTemplateLink
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): array
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
if ($template === null) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
return DB::transaction(function () use ($project, $template, $areas, $statusReplacements): array {
|
||||
$template = ProjectTemplate::query()->whereKey($template->id)->lockForUpdate()->firstOrFail();
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
if ($project->project_template_id !== $template->id) {
|
||||
throw ValidationException::withMessages(['areas' => 'The linked template changed. Reload this project before resetting.']);
|
||||
}
|
||||
$baseline = $this->duplicateProjectConfiguration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$configuration = collect($baseline)->only($areas)->all();
|
||||
if (in_array('ticket_statuses', $areas, true)
|
||||
&& ! in_array('status_transitions', $project->template_overrides ?? [], true)
|
||||
&& isset($baseline['status_transitions'])) {
|
||||
$configuration['status_transitions'] = $baseline['status_transitions'];
|
||||
}
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
@@ -64,6 +74,8 @@ class UpdateProjectTemplateLink
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
|
||||
return $blockedAreas;
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,21 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class AcceptanceCriteriaChecklistMapper
|
||||
/**
|
||||
* @param array<int, array{id?: int, checked: bool, indent: int, text: string}> $items
|
||||
*/
|
||||
public static function syncTicket(Ticket $ticket, array $items): void
|
||||
public static function syncTicket(Ticket $ticket, array $items, bool $reuseUnidentifiedCriteria = true): void
|
||||
{
|
||||
$normalized = self::normalize($items);
|
||||
$existingCriteria = $ticket->acceptanceCriteria()->get()->keyBy('id');
|
||||
@@ -73,7 +73,8 @@ class AcceptanceCriteriaChecklistMapper
|
||||
: $existingCriteria->get($criterionId);
|
||||
|
||||
while (
|
||||
$criterion === null
|
||||
$reuseUnidentifiedCriteria
|
||||
&& $criterion === null
|
||||
&& $fallbackIndex < $fallbackCriteria->count()
|
||||
) {
|
||||
/** @var AcceptanceCriterion $candidate */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Tickets\Support;
|
||||
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Extension\Table\Table;
|
||||
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||
use League\CommonMark\Parser\MarkdownParser;
|
||||
use League\Uri\Uri;
|
||||
|
||||
class TicketDescriptionFormat
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public static function contract(): array
|
||||
{
|
||||
return once(fn (): array => json_decode(file_get_contents(resource_path('js/lib/ticket-description-format.json')), true, flags: JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function instructions(): string
|
||||
{
|
||||
$contract = self::contract();
|
||||
|
||||
return implode(' ', array_map(fn (string $key): string => $contract[$key], ['format', 'supported', 'unsupported', 'links', 'criteria']))
|
||||
." Valid description example (Markdown string):\n".$contract['example'];
|
||||
}
|
||||
|
||||
public static function criteriaInstructions(): string
|
||||
{
|
||||
return self::contract()['criteria'];
|
||||
}
|
||||
|
||||
public static function safeLink(string $value): bool
|
||||
{
|
||||
$contract = self::contract();
|
||||
$decoded = rawurldecode($value);
|
||||
if (! mb_check_encoding($decoded, 'UTF-8') || preg_match('~'.$contract['unsafe_link_characters'].'~u', $decoded)
|
||||
|| ! preg_match('~'.$contract['link_prefix'].'~i', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (str_starts_with($value, '/') || str_starts_with($value, '#')) {
|
||||
return true;
|
||||
}
|
||||
$url = Uri::tryNew($decoded);
|
||||
|
||||
return $url !== null && ($url->getScheme() === 'mailto' || (filled($url->getHost()) && ($url->getPort() ?? 0) <= 65535));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function unsupportedContent(string $value): array
|
||||
{
|
||||
$environment = new Environment;
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
$document = (new MarkdownParser($environment))->parse($value);
|
||||
$unsupported = [];
|
||||
foreach ($document->iterator() as $node) {
|
||||
$type = match (true) {
|
||||
$node instanceof HtmlBlock, $node instanceof HtmlInline => 'html',
|
||||
$node instanceof Table => 'table',
|
||||
$node instanceof Image => 'image',
|
||||
$node instanceof TaskListItemMarker => 'task',
|
||||
$node instanceof Link && ! self::safeLink($node->getUrl()) => 'unsafe_link',
|
||||
default => null,
|
||||
};
|
||||
if ($type !== null) {
|
||||
$unsupported[] = self::contract()['unsupported_tokens'][$type];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($unsupported));
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class UpdateProjectTicket
|
||||
Ticket $ticket,
|
||||
User $actor,
|
||||
array $validated,
|
||||
bool $replaceCriteriaWithIds = false,
|
||||
): bool {
|
||||
$fieldValueAttributes = Arr::get($validated, 'field_values', []);
|
||||
$nextAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::normalize(
|
||||
@@ -58,7 +59,7 @@ class UpdateProjectTicket
|
||||
);
|
||||
$ticketAttributes = $this->buildTicketAttributes($ticket, $validated);
|
||||
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $ticket, $ticketAttributes): bool {
|
||||
return DB::transaction(function () use ($actor, $fieldValueAttributes, $nextAcceptanceCriteria, $replaceCriteriaWithIds, $ticket, $ticketAttributes): bool {
|
||||
if (! $ticketAttributes['is_blocked']) {
|
||||
$ticketAttributes['blocked_comment'] = null;
|
||||
}
|
||||
@@ -91,7 +92,9 @@ class UpdateProjectTicket
|
||||
/** @var Collection<int, TicketFieldValue> $existingFieldValues */
|
||||
$existingFieldValues = $ticket->fieldValues
|
||||
->keyBy('ticket_field_definition_id');
|
||||
$currentAcceptanceCriteria = AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
$currentAcceptanceCriteria = $replaceCriteriaWithIds
|
||||
? AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria)
|
||||
: AcceptanceCriteriaChecklistMapper::fromModels($ticket->acceptanceCriteria);
|
||||
|
||||
$changes = [];
|
||||
$summaryLabels = [];
|
||||
@@ -209,7 +212,7 @@ class UpdateProjectTicket
|
||||
}
|
||||
|
||||
if ($currentAcceptanceCriteria !== $nextAcceptanceCriteria) {
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria);
|
||||
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $nextAcceptanceCriteria, ! $replaceCriteriaWithIds);
|
||||
}
|
||||
|
||||
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||
|
||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
'message' => "{$label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -161,7 +161,7 @@ class ProjectController extends Controller
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$blockedAreas = $this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
@@ -171,6 +171,10 @@ class ProjectController extends Controller
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
if ($blockedAreas !== []) {
|
||||
$status = 'Some settings remain overridden because tickets or other configuration still use them.';
|
||||
}
|
||||
|
||||
return back()->with('status', $status);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Projects\CreateProjectTemplate;
|
||||
use App\Actions\Projects\DeleteProjectTemplate;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Actions\Projects\UpdateProjectTemplate;
|
||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreStandaloneProjectTemplateRequest;
|
||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
@@ -20,6 +23,7 @@ class ProjectTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||
private readonly DeleteProjectTemplate $deleteProjectTemplate,
|
||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||
) {}
|
||||
|
||||
@@ -38,6 +42,22 @@ class ProjectTemplateController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function createEmpty(ProjectConfigurationIdentity $identity): Response
|
||||
{
|
||||
return Inertia::render('project-templates/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'project' => null,
|
||||
'initialConfiguration' => $identity->defaults(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeEmpty(StoreStandaloneProjectTemplateRequest $request): RedirectResponse
|
||||
{
|
||||
$template = $this->createProjectTemplate->empty($request->validated());
|
||||
|
||||
return to_route('project-templates.edit', $template)->with('status', "Template {$template->name} was created.");
|
||||
}
|
||||
|
||||
public function edit(ProjectTemplate $projectTemplate): Response
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
@@ -81,7 +101,7 @@ class ProjectTemplateController extends Controller
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$templateName = $projectTemplate->name;
|
||||
$projectTemplate->delete();
|
||||
$this->deleteProjectTemplate->handle($projectTemplate);
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$templateName} was deleted.");
|
||||
|
||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
->with('status', "{$label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
||||
|
||||
if ($request->routeIs([
|
||||
'logout',
|
||||
'native-app.edit',
|
||||
'password.force-change.edit',
|
||||
'user-password.update',
|
||||
])) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Concerns;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Enums\NotificationFilterAttribute;
|
||||
use App\Enums\NotificationFilterOperator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
trait ValidatesProjectTemplateConfiguration
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
protected function configurationRules(): array
|
||||
{
|
||||
$rules = ['configuration_snapshot' => ['sometimes', 'array:'.implode(',', DuplicateProjectConfiguration::copyableAreas())]];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$path = 'configuration_snapshot.'.$area;
|
||||
$rules[$path] = ['sometimes', 'array', 'max:200'];
|
||||
$rules[$path.'.*.configuration_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
}
|
||||
$rules['configuration_snapshot.project_settings'] = ['sometimes', 'array:show_author,show_story_points'];
|
||||
foreach (['show_author', 'show_story_points'] as $field) {
|
||||
$rules['configuration_snapshot.project_settings.'.$field] = ['required_with:configuration_snapshot.project_settings', 'boolean'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.name"] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
foreach (['board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities', 'ticket_fields', 'ticket_link_types'] as $area) {
|
||||
$rules["configuration_snapshot.{$area}.*.position"] = ['required', 'integer', 'min:1', 'max:10000'];
|
||||
if ($area !== 'ticket_statuses') {
|
||||
$rules["configuration_snapshot.{$area}.*.position"][] = 'distinct:strict';
|
||||
}
|
||||
}
|
||||
$fields = [
|
||||
'board_columns' => 'configuration_key,name,position',
|
||||
'ticket_statuses' => 'configuration_key,name,position,board_column_key',
|
||||
'ticket_types' => 'configuration_key,name,position,is_parent',
|
||||
'ticket_priorities' => 'configuration_key,name,position',
|
||||
'ticket_fields' => 'configuration_key,label,position,field_type',
|
||||
'ticket_link_types' => 'configuration_key,label,counterpart_label,position',
|
||||
'repository_connections' => 'configuration_key,provider,base_url,provider_repository_id,owner_name,repository_name,repository_full_name,repository_url',
|
||||
'notification_actions' => 'configuration_key,name,enabled,event_type,channel,channel_configuration,message_template,delivery_url,filters',
|
||||
];
|
||||
foreach ($fields as $area => $allowed) {
|
||||
$rules["configuration_snapshot.{$area}.*"] = ['array:'.$allowed];
|
||||
}
|
||||
$rules['configuration_snapshot.ticket_statuses.*.board_column_key'] = ['nullable', 'uuid'];
|
||||
$rules['configuration_snapshot.ticket_types.*.is_parent'] = ['required', 'boolean'];
|
||||
$rules['configuration_snapshot.ticket_fields.*.field_type'] = ['required', Rule::in(['number', 'user'])];
|
||||
$rules['configuration_snapshot.ticket_fields.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.ticket_link_types.*.counterpart_label'] = ['required', 'string', 'max:255'];
|
||||
$rules['configuration_snapshot.status_transitions'] = ['sometimes', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*'] = ['array:status_key,target_keys'];
|
||||
$rules['configuration_snapshot.status_transitions.*.status_key'] = ['required', 'uuid', 'distinct:strict'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys'] = ['present', 'array', 'max:200'];
|
||||
$rules['configuration_snapshot.status_transitions.*.target_keys.*'] = ['uuid'];
|
||||
foreach (['provider_repository_id', 'owner_name', 'repository_name', 'repository_full_name'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'string', 'max:255'];
|
||||
}
|
||||
$rules['configuration_snapshot.repository_connections.*.provider'] = ['required', Rule::in(['gitea'])];
|
||||
foreach (['base_url', 'repository_url'] as $field) {
|
||||
$rules['configuration_snapshot.repository_connections.*.'.$field] = ['required', 'url:http,https', 'max:255'];
|
||||
}
|
||||
$prefix = 'configuration_snapshot.notification_actions.*.';
|
||||
$rules[$prefix.'name'] = ['required', 'string', 'max:255'];
|
||||
$rules[$prefix.'enabled'] = ['required', 'boolean'];
|
||||
$rules[$prefix.'event_type'] = ['required', Rule::in(NotificationEventType::values())];
|
||||
$rules[$prefix.'channel'] = ['required', Rule::in(NotificationChannel::values())];
|
||||
$rules[$prefix.'delivery_url'] = ['required', 'url:http,https', 'max:2048'];
|
||||
$rules[$prefix.'message_template'] = ['nullable', 'string', 'max:2000'];
|
||||
$rules[$prefix.'channel_configuration'] = ['sometimes', 'array:allowed_mentions'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions'] = ['sometimes', 'array'];
|
||||
$rules[$prefix.'channel_configuration.allowed_mentions.*'] = [Rule::in(['everyone', 'roles', 'users'])];
|
||||
$rules[$prefix.'filters'] = ['sometimes', 'array', 'max:25'];
|
||||
$rules[$prefix.'filters.*'] = ['array:attribute,operator,value'];
|
||||
$rules[$prefix.'filters.*.attribute'] = ['required', Rule::in(NotificationFilterAttribute::values())];
|
||||
$rules[$prefix.'filters.*.operator'] = ['required', Rule::in(NotificationFilterOperator::values())];
|
||||
$rules[$prefix.'filters.*.value'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
$validated = $validator->validated();
|
||||
$configuration = Arr::get($validated, 'configuration_snapshot', []);
|
||||
$areas = Arr::get($validated, 'copy_areas', []);
|
||||
$columns = array_column(Arr::get($configuration, 'board_columns', []), 'configuration_key');
|
||||
$statuses = array_column(Arr::get($configuration, 'ticket_statuses', []), 'configuration_key');
|
||||
foreach (Arr::get($configuration, 'ticket_statuses', []) as $index => $status) {
|
||||
$key = Arr::get($status, 'board_column_key');
|
||||
if ($key !== null && in_array('board_columns', $areas, true) && ! in_array($key, $columns, true)) {
|
||||
$validator->errors()->add("configuration_snapshot.ticket_statuses.{$index}.board_column_key", 'Choose a column from this template.');
|
||||
}
|
||||
}
|
||||
foreach (Arr::get($configuration, 'status_transitions', []) as $index => $transition) {
|
||||
if (! in_array(Arr::get($transition, 'status_key'), $statuses, true)
|
||||
|| array_diff(Arr::get($transition, 'target_keys', []), $statuses) !== []) {
|
||||
$validator->errors()->add("configuration_snapshot.status_transitions.{$index}", 'Choose statuses from this template.');
|
||||
}
|
||||
if (in_array('status_transitions', $areas, true) && ! in_array('ticket_statuses', $areas, true)
|
||||
&& Arr::get($transition, 'target_keys', []) !== []) {
|
||||
$validator->errors()->add('copy_areas', 'Include ticket statuses when defining template transitions.');
|
||||
}
|
||||
}
|
||||
$repositoryIdentifiers = [];
|
||||
foreach (Arr::get($configuration, 'repository_connections', []) as $index => $connection) {
|
||||
$identifier = serialize([$connection['provider'], $connection['base_url'], $connection['provider_repository_id']]);
|
||||
if (isset($repositoryIdentifiers[$identifier])) {
|
||||
$validator->errors()->add("configuration_snapshot.repository_connections.{$index}.provider_repository_id", 'This repository is already included in the template.');
|
||||
}
|
||||
$repositoryIdentifiers[$identifier] = true;
|
||||
}
|
||||
foreach (Arr::get($configuration, 'notification_actions', []) as $index => $action) {
|
||||
if (Arr::get($action, 'channel') === 'discord' && blank(Arr::get($action, 'message_template'))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.message_template", 'A Discord message is required.');
|
||||
}
|
||||
foreach (Arr::get($action, 'filters', []) as $filterIndex => $filter) {
|
||||
$operator = Arr::get($filter, 'operator');
|
||||
$value = Arr::get($filter, 'value');
|
||||
$valid = is_string($value) || $value === null || (is_array($value) && collect($value)->every(static fn ($item): bool => is_string($item)));
|
||||
if (! $valid || (in_array($operator, ['equals', 'not_equals', 'in'], true) && blank($value))
|
||||
|| (in_array($operator, ['exists', 'not_exists'], true) && filled($value))) {
|
||||
$validator->errors()->add("configuration_snapshot.notification_actions.{$index}.filters.{$filterIndex}.value", 'Choose a value that matches the filter operator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('access-admin') ?? false;
|
||||
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,11 +34,7 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
'status_replacements.*' => ['required'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,40 +50,34 @@ class ResetProjectTemplateRequest extends FormRequest
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration($project->projectTemplate?->configuration_snapshot ?? []);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
foreach ($areas as $area) {
|
||||
if (! array_key_exists($area, $configuration)) {
|
||||
$validator->errors()->add('areas', 'This template does not define the selected settings area.');
|
||||
}
|
||||
}
|
||||
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');
|
||||
$templateKeys = array_column($configuration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [], 'configuration_key');
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get();
|
||||
$allowedIds = $statuses->whereIn('configuration_key', $templateKeys)->pluck('id')->map(static fn (int $id): string => (string) $id)->all();
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
foreach ($replacements as $id => $replacement) {
|
||||
if ($statuses->firstWhere('id', (int) $id) === null || ! is_scalar($replacement)
|
||||
|| ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$id}", 'Choose a replacement from this template for a status in this project.');
|
||||
}
|
||||
}
|
||||
foreach ($statuses as $status) {
|
||||
if (in_array($status->configuration_key, $templateKeys, true) || $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}.",
|
||||
);
|
||||
$replacement = Arr::get($replacements, (string) $status->id);
|
||||
if (! is_scalar($replacement) || ! in_array((string) $replacement, [...$templateKeys, ...$allowedIds], true)) {
|
||||
$validator->errors()->add("status_replacements.{$status->id}", "Choose a replacement status for {$status->name}.");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
return ($this->user()?->can('create-project') ?? false)
|
||||
&& ($this->route('project') === null || $this->user()->can('admin-project', $this->route('project')));
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
@@ -29,10 +30,14 @@ class StoreProjectTemplateRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$project = $this->route('project');
|
||||
$baseTemplate = $project?->projectTemplate;
|
||||
$publishingBase = $baseTemplate !== null && ! $baseTemplate->is_published;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')->ignore($publishingBase ? $baseTemplate->id : null), Rule::when($publishingBase, Rule::in([$baseTemplate?->slug]))],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
|
||||
class StoreStandaloneProjectTemplateRequest extends StoreProjectTemplateRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [...parent::rules(), ...$this->configurationRules()];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Http\Requests\Concerns\ValidatesProjectTemplateConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
use ValidatesProjectTemplateConfiguration;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
@@ -25,8 +28,9 @@ class UpdateProjectTemplateRequest extends FormRequest
|
||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||
|
||||
return [
|
||||
...$this->configurationRules(),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas' => ['present', 'array'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||
|
||||
@@ -32,6 +32,8 @@ class ProjectResource extends JsonResource
|
||||
? null
|
||||
: [
|
||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||
'local_configuration' => Arr::get($templateState, 'localConfiguration', []),
|
||||
'local_areas' => Arr::get($templateState, 'localAreas', []),
|
||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
|
||||
@@ -2,24 +2,32 @@
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Mcp\Tools\CreateTicket;
|
||||
use App\Mcp\Tools\GetProjectConfiguration;
|
||||
use App\Mcp\Tools\GetTicket;
|
||||
use App\Mcp\Tools\ListMyAssignedTickets;
|
||||
use App\Mcp\Tools\ListProjects;
|
||||
use App\Mcp\Tools\ListTicketsByStatus;
|
||||
use App\Mcp\Tools\ListTicketTypes;
|
||||
use App\Mcp\Tools\MoveTicketStatus;
|
||||
use App\Mcp\Tools\SearchTickets;
|
||||
use App\Mcp\Tools\UpdateTicket;
|
||||
use App\Mcp\Tools\UpdateTicketAssignee;
|
||||
use App\Mcp\Tools\UpdateTicketDescription;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
|
||||
#[Name('KanbanGit MCP')]
|
||||
#[Version('0.1.0')]
|
||||
#[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.')]
|
||||
#[Version('0.2.1')]
|
||||
class KanbanGitServer extends Server
|
||||
{
|
||||
protected function boot(): void
|
||||
{
|
||||
$this->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_projects and get_project_configuration to discover visible projects and current workflow metadata. Use get_ticket with ticket_id or ticket_key; use search_tickets for paginated discovery. Use list_ticket_types to discover types and create_ticket with parent_ticket_id to create children of parent-enabled tickets. Use update_ticket for partial updates and move_ticket_status separately for workflow transitions. '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
|
||||
protected array $tools = [
|
||||
ListMyAssignedTickets::class,
|
||||
ListTicketsByStatus::class,
|
||||
@@ -29,6 +37,10 @@ class KanbanGitServer extends Server
|
||||
MoveTicketStatus::class,
|
||||
CreateTicket::class,
|
||||
ListTicketTypes::class,
|
||||
ListProjects::class,
|
||||
GetProjectConfiguration::class,
|
||||
SearchTickets::class,
|
||||
UpdateTicket::class,
|
||||
];
|
||||
|
||||
protected array $resources = [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
|
||||
trait DescribesTicketWriting
|
||||
{
|
||||
public function description(): string
|
||||
{
|
||||
return parent::description().' '.TicketDescriptionFormat::instructions();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
@@ -78,6 +79,17 @@ class KanbanGitMcp
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function ticketForKey(User $user, string $ticketKey): Ticket
|
||||
{
|
||||
$ticket = Ticket::query()->with('project')->where('ticket_key', $ticketKey)->first();
|
||||
if ($ticket === null || $ticket->ticket_key !== $ticketKey) {
|
||||
throw ValidationException::withMessages(['ticket_key' => 'Ticket was not found.']);
|
||||
}
|
||||
$this->ensureCanRead($user, $ticket->project);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
@@ -201,6 +213,18 @@ class KanbanGitMcp
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array{current_page: int, per_page: int, total: int, last_page: int, has_more_pages: bool} */
|
||||
public function paginationPayload(LengthAwarePaginator $paginator): array
|
||||
{
|
||||
return [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'has_more_pages' => $paginator->hasMorePages(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Support;
|
||||
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\User;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
class TicketUpdateInput
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function validate(Request $request, Ticket $ticket, User $user): array
|
||||
{
|
||||
$form = new UpdateProjectTicketRequest;
|
||||
$route = new Route('PUT', '/', fn () => null);
|
||||
$route->bind(\Illuminate\Http\Request::create('/', 'PUT'));
|
||||
$route->setParameter('project', $ticket->project);
|
||||
$route->setParameter('ticket', $ticket);
|
||||
$form->setRouteResolver(fn () => $route);
|
||||
$form->setUserResolver(fn () => $user);
|
||||
$rules = $form->rules();
|
||||
foreach ($rules as $key => &$rule) {
|
||||
if (! str_contains($key, '.')) {
|
||||
$rule = ['sometimes', ...$rule];
|
||||
}
|
||||
}
|
||||
unset($rule);
|
||||
$rules['description'][] = new VisualTicketDescription;
|
||||
$rules['acceptance_criteria'] = ['sometimes', 'array', 'list'];
|
||||
$rules['acceptance_criteria.*'] = ['array:id,text,checked,indent'];
|
||||
$rules['acceptance_criteria.*.id'] = ['sometimes', 'integer', 'min:1', 'distinct', Rule::exists('acceptance_criteria', 'id')->where('ticket_id', $ticket->id)];
|
||||
$rules['acceptance_criteria.*.checked'] = ['sometimes', 'boolean'];
|
||||
$rules['acceptance_criteria.*.indent'] = ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL];
|
||||
$rules['field_values'] = ['sometimes', 'array', 'list'];
|
||||
$rules['field_values.*'] = ['array:ticket_field_definition_id,value_number,value_user_id'];
|
||||
$rules['field_values.*.ticket_field_definition_id'][] = 'distinct';
|
||||
$rules['ticket_status_id'] = ['prohibited'];
|
||||
$rules['acceptance_criteria.*.text'][] = function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if (is_string($value) && trim($value) === '') {
|
||||
$fail('Criterion text must not be blank.');
|
||||
}
|
||||
};
|
||||
$input = $request->validate($rules);
|
||||
$previousIndent = -1;
|
||||
foreach (Arr::get($input, 'acceptance_criteria', []) as $index => $item) {
|
||||
$indent = Arr::get($item, 'indent', 0);
|
||||
if ($indent > $previousIndent + 1) {
|
||||
throw ValidationException::withMessages(["acceptance_criteria.{$index}.indent" => 'Indentation cannot skip a level; the first item must have indent 0.']);
|
||||
}
|
||||
$previousIndent = $indent;
|
||||
}
|
||||
$merged = array_replace($this->kanbanGitMcp->updatePayload($ticket), $input);
|
||||
$merged['acceptance_criteria'] = Arr::exists($input, 'acceptance_criteria')
|
||||
? AcceptanceCriteriaChecklistMapper::normalize($input['acceptance_criteria'])
|
||||
: AcceptanceCriteriaChecklistMapper::fromModelsWithIds($ticket->acceptanceCriteria()->get());
|
||||
|
||||
$validator = Validator::make($merged, $form->rules());
|
||||
$form->setValidator($validator);
|
||||
$validator->validate();
|
||||
foreach ($form->after() as $callback) {
|
||||
$callback($validator);
|
||||
}
|
||||
|
||||
$people = User::query()->visibleToProject($ticket->project)->pluck('id')->all();
|
||||
foreach (['assignee_user_id', 'author_user_id'] as $field) {
|
||||
if (Arr::exists($input, $field) && $input[$field] !== null && ! in_array((int) $input[$field], $people, true)) {
|
||||
$validator->errors()->add($field, 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
$definitions = $ticket->project->ticketFieldDefinitions()->get()->keyBy('id');
|
||||
foreach (Arr::get($input, 'field_values', []) as $index => $value) {
|
||||
$definition = $definitions->get($value['ticket_field_definition_id']);
|
||||
$incompatible = $definition->field_type === 'user' ? 'value_number' : 'value_user_id';
|
||||
if (Arr::get($value, $incompatible) !== null) {
|
||||
$validator->errors()->add("field_values.{$index}.{$incompatible}", 'The value must match the custom field type.');
|
||||
}
|
||||
if (Arr::get($value, 'value_user_id') !== null && ! in_array((int) $value['value_user_id'], $people, true)) {
|
||||
$validator->errors()->add("field_values.{$index}.value_user_id", 'The person must be able to view this project.');
|
||||
}
|
||||
}
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
|
||||
if (Arr::exists($input, 'field_values')) {
|
||||
$replacement = $definitions->map(fn (TicketFieldDefinition $definition): array => [
|
||||
'ticket_field_definition_id' => $definition->id,
|
||||
'value_number' => null,
|
||||
'value_user_id' => null,
|
||||
])->all();
|
||||
foreach ($input['field_values'] as $value) {
|
||||
$replacement[$value['ticket_field_definition_id']] = $value;
|
||||
}
|
||||
$merged['field_values'] = array_values($replacement);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,14 @@ namespace App\Mcp\Tools;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Tickets\CreateProjectTicket;
|
||||
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -28,6 +31,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class CreateTicket extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly CreateProjectTicket $createProjectTicket,
|
||||
@@ -44,7 +49,7 @@ class CreateTicket extends Tool
|
||||
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'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)],
|
||||
@@ -140,7 +145,7 @@ class CreateTicket extends Tool
|
||||
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.'),
|
||||
'description' => $schema->string()->nullable()->description('Ticket description. '.TicketDescriptionFormat::instructions()),
|
||||
'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(),
|
||||
@@ -156,7 +161,7 @@ class CreateTicket extends Tool
|
||||
'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.'),
|
||||
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||
'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(),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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('get_project_configuration')]
|
||||
#[Description('Get compact current configuration for a visible project: ordered columns, statuses with IDs/slugs/column assignment and allowed target status IDs, types, priorities, custom field definitions, and permitted people IDs/names. Reads live effective configuration, without template snapshots or full user/project objects. People are paginated (default 25, maximum 100); use people_page for subsequent pages. Author changes require project admin permission, reported in permissions.')]
|
||||
#[IsReadOnly]
|
||||
class GetProjectConfiguration extends Tool
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$input = $request->validate([
|
||||
'project_id' => ['required', 'integer'],
|
||||
'people_page' => ['sometimes', 'integer', 'min:1'],
|
||||
'people_per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$project = $this->kanbanGitMcp->projectForRead($user, $input['project_id']);
|
||||
$people = User::query()->visibleToProject($project)->orderBy('id')->paginate($input['people_per_page'] ?? 25, ['id', 'name'], 'people_page', $input['people_page'] ?? 1);
|
||||
|
||||
$columns = $project->boardColumns()->orderBy('id')->get(['id', 'name', 'position']);
|
||||
$columnPositions = $columns->pluck('position', 'id');
|
||||
$statuses = $project->ticketStatuses()->with('allowedTargetStatuses')->get()->sortBy(
|
||||
fn (TicketStatus $status): array => [$columnPositions->get($status->board_column_id, 0), $status->position, $status->id],
|
||||
)->values();
|
||||
|
||||
return Response::structured([
|
||||
'data' => [
|
||||
'project' => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name],
|
||||
'permissions' => [
|
||||
'can_write' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||
'can_change_author' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
],
|
||||
'board_columns' => $columns->toArray(),
|
||||
'ticket_statuses' => $statuses->map(fn (TicketStatus $status): array => [
|
||||
'id' => $status->id, 'name' => $status->name, 'slug' => $status->slug,
|
||||
'position' => $status->position, 'board_column_id' => $status->board_column_id,
|
||||
'allowed_target_status_ids' => $status->allowedTargetStatuses->pluck('id')->all(),
|
||||
])->all(),
|
||||
'ticket_types' => $project->ticketTypes()->orderBy('id')->get(['id', 'name', 'position', 'is_parent'])->toArray(),
|
||||
'ticket_priorities' => $project->ticketPriorities()->orderBy('id')->get(['id', 'name', 'position'])->toArray(),
|
||||
'ticket_field_definitions' => $project->ticketFieldDefinitions()->orderBy('id')->get(['id', 'label', 'field_type', 'position'])->toArray(),
|
||||
'people' => $people->getCollection()->map(fn (User $person): array => ['id' => $person->id, 'name' => $person->name])->all(),
|
||||
],
|
||||
'meta' => ['people' => $this->kanbanGitMcp->paginationPayload($people)],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'project_id' => $schema->integer()->description('Visible project ID from list_projects.')->required(),
|
||||
'people_page' => $schema->integer()->min(1)->description('People page number, defaults to 1.'),
|
||||
'people_per_page' => $schema->integer()->min(1)->max(100)->description('People page size, defaults to 25; maximum 100.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Tool;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[Name('get_ticket')]
|
||||
#[Description('Get a visible ticket with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[Description('Get a visible ticket by exactly one of numeric ticket_id or exact full ticket_key (e.g. KAN-67), with project, status, type, priority, assignee, author, field values, and worklog data.')]
|
||||
#[IsReadOnly]
|
||||
class GetTicket extends Tool
|
||||
{
|
||||
@@ -25,11 +25,14 @@ class GetTicket extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
'ticket_id' => ['required_without:ticket_key', 'prohibits:ticket_key', 'integer'],
|
||||
'ticket_key' => ['required_without:ticket_id', 'prohibits:ticket_id', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||
]);
|
||||
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id']);
|
||||
$ticket = isset($validated['ticket_id'])
|
||||
? $this->kanbanGitMcp->ticketForRead($user, (int) $validated['ticket_id'])
|
||||
: $this->kanbanGitMcp->ticketForKey($user, $validated['ticket_key']);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket),
|
||||
@@ -43,8 +46,8 @@ class GetTicket extends Tool
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
->description('Ticket ID. Supply exactly one of ticket_id or ticket_key.'),
|
||||
'ticket_key' => $schema->string()->description('Exact full ticket key, e.g. KAN-67. Supply exactly one of ticket_id or ticket_key.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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_projects')]
|
||||
#[Description('List visible projects independently of assigned tickets. Returns only ID, key and name, ordered by ID, with bounded pagination. page starts at 1; per_page defaults to 25 and may not exceed 100.')]
|
||||
#[IsReadOnly]
|
||||
class ListProjects extends Tool
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$input = $request->validate(['page' => ['sometimes', 'integer', 'min:1'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100']]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$projects = Project::query()->whereIn('id', $this->permissionService->viewableProjectIds($user))
|
||||
->orderBy('id')->paginate($input['per_page'] ?? 25, ['id', 'key', 'name'], 'page', $input['page'] ?? 1);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $projects->getCollection()->map(fn (Project $project): array => ['id' => $project->id, 'key' => $project->key, 'name' => $project->name])->all(),
|
||||
'meta' => $this->kanbanGitMcp->paginationPayload($projects),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\MoveProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class MoveTicketStatus extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly MoveProjectTicket $moveProjectTicket,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
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('search_tickets')]
|
||||
#[Description('Search visible tickets with combined filters: exact full ticket key, literal text in title/description, project, status, type, assignee and parent. Omitted filters do not restrict results; assignee_user_id/parent_ticket_id null selects unassigned/top-level tickets. Returns compact ID, key, title, project ID and status with deterministic ID order and bounded pagination (default 25, maximum 100).')]
|
||||
#[IsReadOnly]
|
||||
class SearchTickets extends Tool
|
||||
{
|
||||
public function __construct(private readonly KanbanGitMcp $kanbanGitMcp, private readonly PermissionService $permissionService) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$input = $request->validate([
|
||||
'ticket_key' => ['sometimes', 'string', 'regex:/^[A-Z0-9]+-[1-9][0-9]*$/'],
|
||||
'query' => ['sometimes', 'string', 'max:255'],
|
||||
'project_id' => ['sometimes', 'integer'],
|
||||
'ticket_status_id' => ['sometimes', 'integer'],
|
||||
'ticket_type_id' => ['sometimes', 'integer'],
|
||||
'assignee_user_id' => ['sometimes', 'nullable', 'integer'],
|
||||
'parent_ticket_id' => ['sometimes', 'nullable', 'integer'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$this->kanbanGitMcp->projectForRead($user, $input['project_id'] ?? null);
|
||||
$query = Ticket::query()->whereIn('project_id', $this->permissionService->viewableProjectIds($user));
|
||||
foreach (['ticket_key', 'project_id', 'ticket_status_id', 'ticket_type_id', 'assignee_user_id', 'parent_ticket_id'] as $filter) {
|
||||
if (Arr::exists($input, $filter)) {
|
||||
$query->where($filter, $input[$filter]);
|
||||
}
|
||||
}
|
||||
if (Arr::exists($input, 'query')) {
|
||||
$pattern = '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $input['query']).'%';
|
||||
$query->where(fn (Builder $q) => $q->whereRaw("title LIKE ? ESCAPE '!'", [$pattern])->orWhereRaw("description LIKE ? ESCAPE '!'", [$pattern]));
|
||||
}
|
||||
$tickets = $query->with('ticketStatus:id,name,slug,board_column_id')->orderBy('id')
|
||||
->paginate($input['per_page'] ?? 25, ['id', 'ticket_key', 'title', 'project_id', 'ticket_status_id'], 'page', $input['page'] ?? 1);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $tickets->getCollection()->map(fn (Ticket $ticket): array => [
|
||||
'id' => $ticket->id,
|
||||
'ticket_key' => $ticket->ticket_key,
|
||||
'title' => $ticket->title,
|
||||
'project_id' => $ticket->project_id,
|
||||
'ticket_status_id' => $ticket->ticket_status_id,
|
||||
'status' => $ticket->ticketStatus === null ? null : ['id' => $ticket->ticketStatus->id, 'name' => $ticket->ticketStatus->name, 'slug' => $ticket->ticketStatus->slug],
|
||||
])->all(),
|
||||
'meta' => $this->kanbanGitMcp->paginationPayload($tickets),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'ticket_key' => $schema->string()->description('Exact full key, e.g. KAN-67.'),
|
||||
'query' => $schema->string()->max(255)->description('Literal substring in title or description.'),
|
||||
'project_id' => $schema->integer()->description('Visible project ID.'),
|
||||
'ticket_status_id' => $schema->integer()->description('Status ID from get_project_configuration.'),
|
||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('Assignee ID; null selects unassigned tickets.'),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent ID; null selects top-level tickets.'),
|
||||
'page' => $schema->integer()->min(1)->description('Page number, defaults to 1.'),
|
||||
'per_page' => $schema->integer()->min(1)->max(100)->description('Page size, defaults to 25; maximum 100.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Mcp\Support\TicketUpdateInput;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
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('update_ticket')]
|
||||
#[Description('Partially update a ticket by ID using existing project permissions and ticket rules. Omitted fields stay unchanged. Explicit null clears nullable scalar fields only. acceptance_criteria and field_values replace their entire lists; [] clears a list and null is rejected. Checklist items with an existing ID retain their identity; omit id for a new item. Use move_ticket_status separately for workflow transitions. Author changes require project admin permission; people must be visible to the project.')]
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicket extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly TicketUpdateInput $ticketUpdateInput,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$selector = $request->validate(['ticket_id' => ['required', 'integer']]);
|
||||
$user = $this->kanbanGitMcp->user($request);
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $selector['ticket_id']);
|
||||
$validated = $this->ticketUpdateInput->validate($request, $ticket, $user);
|
||||
$updated = $this->updateProjectTicket->handle($ticket, $user, $validated, true);
|
||||
|
||||
return Response::structured([
|
||||
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
|
||||
'meta' => ['updated' => $updated],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, Type> */
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => $schema->integer()->description('Existing ticket ID.')->required(),
|
||||
'title' => $schema->string()->max(255)->description('Nonblank title; omit to preserve.'),
|
||||
'description' => $schema->string()->nullable()->description('Description; null clears it. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_type_id' => $schema->integer()->description('Type ID from get_project_configuration.'),
|
||||
'ticket_priority_id' => $schema->integer()->description('Priority ID from get_project_configuration.'),
|
||||
'parent_ticket_id' => $schema->integer()->nullable()->description('Parent-enabled ticket in this project; null removes the parent. Cycles are forbidden.'),
|
||||
'assignee_user_id' => $schema->integer()->nullable()->description('Allowed person ID; null unassigns.'),
|
||||
'author_user_id' => $schema->integer()->nullable()->description('Author ID; null clears. Changes require project admin permission.'),
|
||||
'story_points' => $schema->integer()->min(0)->nullable()->description('Nonnegative estimate; null clears.'),
|
||||
'is_blocked' => $schema->boolean()->description('Blocked state; true requires a nonblank blocker comment. Omit to preserve.'),
|
||||
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason; null clears only when the ticket is unblocked.'),
|
||||
'acceptance_criteria' => $schema->array()->items($schema->object([
|
||||
'id' => $schema->integer()->min(1)->description('Existing criterion ID belonging to this ticket. Omit for a new item. IDs must be unique.'),
|
||||
'text' => $schema->string()->description('Nonblank criterion text.')->required(),
|
||||
'checked' => $schema->boolean()->description('Checked state, defaults to false for supplied items.'),
|
||||
'indent' => $schema->integer()->min(0)->max(8)->description('Indent level, defaults to 0; cannot skip a level.'),
|
||||
])->withoutAdditionalProperties())->description('Ordered replacement checklist. Omitted items are removed. [] clears; omit field to preserve; null is invalid. '.TicketDescriptionFormat::criteriaInstructions()),
|
||||
'field_values' => $schema->array()->items($schema->object([
|
||||
'ticket_field_definition_id' => $schema->integer()->description('Project field ID, supplied once.')->required(),
|
||||
'value_number' => $schema->number()->nullable()->description('Value for a numeric field; null clears.'),
|
||||
'value_user_id' => $schema->integer()->nullable()->description('Allowed person ID for a user field; null clears.'),
|
||||
])->withoutAdditionalProperties())->description('Replacement custom-field list. Omitted definitions are cleared. [] clears all; omit field to preserve; null is invalid.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\JsonSchema\Types\Type;
|
||||
@@ -19,6 +20,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketAssignee extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use App\Actions\Tickets\UpdateProjectTicket;
|
||||
use App\Mcp\Support\DescribesTicketWriting;
|
||||
use App\Mcp\Support\KanbanGitMcp;
|
||||
use App\Rules\VisualTicketDescription;
|
||||
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;
|
||||
@@ -19,6 +23,8 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
#[IsDestructive(false)]
|
||||
class UpdateTicketDescription extends Tool
|
||||
{
|
||||
use DescribesTicketWriting;
|
||||
|
||||
public function __construct(
|
||||
private readonly KanbanGitMcp $kanbanGitMcp,
|
||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||
@@ -27,7 +33,7 @@ class UpdateTicketDescription extends Tool
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'description' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', new VisualTicketDescription],
|
||||
'ticket_id' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
@@ -35,7 +41,7 @@ class UpdateTicketDescription extends Tool
|
||||
$ticket = $this->kanbanGitMcp->ticketForWrite($user, (int) $validated['ticket_id']);
|
||||
$payload = [
|
||||
...$this->kanbanGitMcp->updatePayload($ticket),
|
||||
'description' => $validated['description'] ?? null,
|
||||
...(Arr::exists($validated, 'description') ? ['description' => Arr::get($validated, 'description')] : []),
|
||||
];
|
||||
|
||||
$wasUpdated = $this->updateProjectTicket->handle($ticket, $user, $payload);
|
||||
@@ -58,7 +64,7 @@ class UpdateTicketDescription extends Tool
|
||||
return [
|
||||
'description' => $schema->string()
|
||||
->nullable()
|
||||
->description('New ticket description. Pass null to clear it.'),
|
||||
->description('New ticket description. Omit to preserve; pass null to clear it. '.TicketDescriptionFormat::instructions()),
|
||||
'ticket_id' => $schema->integer()
|
||||
->description('Ticket ID.')
|
||||
->required(),
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
|
||||
@@ -5,11 +5,44 @@ namespace App\Models\Concerns;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait SyncsProjectTemplateOwnership
|
||||
{
|
||||
/** @param array<string, mixed> $options */
|
||||
public function save(array $options = []): bool
|
||||
{
|
||||
return DB::transaction(function () use ($options): bool {
|
||||
$this->syncProjectTemplateOwnership();
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
$project = Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
if ($project?->project_template_id !== null) {
|
||||
$this->setAttribute('project_template_id', $project->project_template_id);
|
||||
}
|
||||
}
|
||||
|
||||
return parent::save($options);
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function delete(): ?bool
|
||||
{
|
||||
return DB::transaction(function (): ?bool {
|
||||
if ($this->getAttribute('project_id') !== null) {
|
||||
Project::query()->whereKey($this->getAttribute('project_id'))->lockForUpdate()->first();
|
||||
}
|
||||
|
||||
return parent::delete();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
protected function syncProjectTemplateOwnership(): void
|
||||
{
|
||||
if ($this->getAttribute('configuration_key') === null) {
|
||||
$this->setAttribute('configuration_key', (string) Str::uuid());
|
||||
}
|
||||
|
||||
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||
$projectId = $this->getAttribute('project_id');
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'channel',
|
||||
'channel_configuration',
|
||||
'delivery_url',
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
#[Fillable(['configuration_key',
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
|
||||
@@ -10,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', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketFieldDefinition extends Model
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
#[Fillable(['configuration_key', 'board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
|
||||
@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
#[Fillable(['configuration_key', 'name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
|
||||
@@ -30,6 +30,9 @@ class ProjectConfigurationObserver
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
if ($model instanceof TicketStatus && $model->project !== null) {
|
||||
$this->refreshProjectBaseTemplate->handle($model->project, DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS);
|
||||
}
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Actions\Tickets\Support\TicketDescriptionFormat;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class VisualTicketDescription implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return;
|
||||
}
|
||||
$unsupported = TicketDescriptionFormat::unsupportedContent($value);
|
||||
if ($unsupported !== []) {
|
||||
$fail('The description contains unsupported '.implode(', ', $unsupported).'. Remove these contents or show literal examples inside code. Use ordinary Markdown and safe links; put acceptance criteria only in acceptance_criteria. No ticket fields were changed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+26
-22
@@ -1451,20 +1451,20 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.26.1",
|
||||
"version": "v13.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -1576,18 +1576,20 @@
|
||||
"league/flysystem-sftp-v3": "^3.25.1",
|
||||
"mockery/mockery": "^1.6.10",
|
||||
"opis/json-schema": "^2.4.1",
|
||||
"orchestra/testbench-core": "^11.0.0",
|
||||
"orchestra/testbench-core": "^11.5.0",
|
||||
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||
"php-http/discovery": "^1.15",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan": "2.2.15",
|
||||
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||
"predis/predis": "^2.3 || ^3.0",
|
||||
"rector/rector": "^2.3",
|
||||
"rector/rector": "2.6.3",
|
||||
"resend/resend-php": "^1.0",
|
||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||
"symfony/http-client": "^7.4.0 || ^8.0.0",
|
||||
"symfony/mercure": "^0.8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0"
|
||||
"symfony/translation": "^7.4.0 || ^8.0.0",
|
||||
"web-token/jwt-library": "^4.1"
|
||||
},
|
||||
"suggest": {
|
||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
||||
@@ -1623,8 +1625,10 @@
|
||||
"symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
|
||||
"symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
|
||||
"symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
|
||||
"symfony/mercure": "Required to use the Mercure broadcast driver (^0.8).",
|
||||
"symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
|
||||
"symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0).",
|
||||
"web-token/jwt-library": "Required to sign Mercure JWTs and use end-to-end encrypted channels (^4.1)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -1674,7 +1678,7 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-08-18T20:31:28+00:00"
|
||||
"time": "2026-09-29T12:46:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
@@ -2003,16 +2007,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2106,7 +2110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
@@ -2192,16 +2196,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem",
|
||||
"version": "3.35.2",
|
||||
"version": "3.36.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem.git",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2269,9 +2273,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
|
||||
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
|
||||
},
|
||||
"time": "2026-07-06T14:42:07+00:00"
|
||||
"time": "2026-09-02T08:00:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<BoardColumn>
|
||||
@@ -19,6 +20,7 @@ class BoardColumnFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<NotificationAction>
|
||||
@@ -21,6 +22,7 @@ class NotificationActionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'channel' => NotificationChannel::Webhook,
|
||||
'channel_configuration' => [],
|
||||
'delivery_url' => 'https://hooks.'.fake()->domainName().'/kanbangit/'.fake()->uuid(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<RepositoryConnection>
|
||||
@@ -20,6 +21,7 @@ class RepositoryConnectionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'base_url' => 'https://git.'.fake()->domainName(),
|
||||
'owner_name' => fake()->userName(),
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketFieldDefinition>
|
||||
@@ -19,6 +20,7 @@ class TicketFieldDefinitionFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'field_type' => fake()->randomElement(TicketFieldDefinition::FIELD_TYPES),
|
||||
'label' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketLinkType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketLinkType>
|
||||
@@ -21,6 +22,7 @@ class TicketLinkTypeFactory extends Factory
|
||||
$label = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'label' => $label,
|
||||
'counterpart_label' => $label,
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketPriority;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketPriority>
|
||||
@@ -19,6 +20,7 @@ class TicketPriorityFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'name' => fake()->randomElement(['Low', 'Medium', 'High', 'Urgent']),
|
||||
'position' => 1,
|
||||
'project_id' => Project::factory(),
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketStatus>
|
||||
@@ -19,6 +20,7 @@ class TicketStatusFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'board_column_id' => null,
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'position' => 1,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<TicketType>
|
||||
@@ -19,6 +20,7 @@ class TicketTypeFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'configuration_key' => (string) Str::uuid(),
|
||||
'is_parent' => false,
|
||||
'name' => fake()->unique()->jobTitle(),
|
||||
'position' => 1,
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectConfigurationIdentity;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
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
|
||||
{
|
||||
private const TABLES = [
|
||||
'board_columns', 'ticket_statuses', 'ticket_types', 'ticket_priorities',
|
||||
'ticket_field_definitions', 'ticket_link_types', 'repository_connections', 'notification_actions',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->uuid('configuration_key')->nullable()->index();
|
||||
});
|
||||
DB::table($name)->orderBy('id')->chunkById(100, function ($rows) use ($name): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table($name)->where('id', $row->id)->update(['configuration_key' => (string) Str::uuid()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
$identity = app(ProjectConfigurationIdentity::class);
|
||||
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration, $identity): void {
|
||||
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$source = $template->sourceProject;
|
||||
if (! $template->is_published && $template->project !== null) {
|
||||
$snapshot = $configuration->export($template->project, DuplicateProjectConfiguration::copyableAreas());
|
||||
} elseif ($source !== null) {
|
||||
$sourceConfiguration = $configuration->export($source, array_keys($snapshot));
|
||||
$replacements = [];
|
||||
$usedKeys = [];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
foreach ($snapshot[$area] ?? [] as $index => $item) {
|
||||
$matches = collect($sourceConfiguration[$area] ?? [])->filter(
|
||||
fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item),
|
||||
);
|
||||
$baselineMatches = collect($snapshot[$area])->filter(fn (array $candidate): bool => $identity->signature($area, $candidate) === $identity->signature($area, $item));
|
||||
if ($matches->count() > 1 || $baselineMatches->count() > 1) {
|
||||
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['position'] ?? null) === ($item['position'] ?? null));
|
||||
if ($area === 'ticket_statuses') {
|
||||
$columnKey = $replacements[$item['board_column_key'] ?? ''] ?? ($item['board_column_key'] ?? null);
|
||||
$matches = $matches->filter(static fn (array $candidate): bool => ($candidate['board_column_key'] ?? null) === $columnKey);
|
||||
}
|
||||
}
|
||||
if ($matches->count() === 1 && ! in_array($matches->first()['configuration_key'], $usedKeys[$area] ?? [], true)) {
|
||||
$usedKeys[$area][] = $matches->first()['configuration_key'];
|
||||
$replacements[$item['configuration_key']] = $matches->first()['configuration_key'];
|
||||
$snapshot[$area][$index]['configuration_key'] = $matches->first()['configuration_key'];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
|
||||
$snapshot['ticket_statuses'][$index]['board_column_key'] = $replacements[$status['board_column_key'] ?? ''] ?? ($status['board_column_key'] ?? null);
|
||||
}
|
||||
foreach ($snapshot['status_transitions'] ?? [] as $index => $transition) {
|
||||
$snapshot['status_transitions'][$index] = [
|
||||
'status_key' => $replacements[$transition['status_key']] ?? $transition['status_key'],
|
||||
'target_keys' => array_map(static fn (string $key): string => $replacements[$key] ?? $key, $transition['target_keys']),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($source !== null) {
|
||||
$statusKeys = $source->ticketStatuses()->pluck('configuration_key', 'id')->all();
|
||||
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
|
||||
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $statusKeys);
|
||||
}
|
||||
}
|
||||
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
|
||||
});
|
||||
|
||||
Project::query()->with('projectTemplate')->eachById(function (Project $project) use ($identity): void {
|
||||
$snapshot = $project->projectTemplate?->configuration_snapshot ?? [];
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
$models = $project->{$relation}()->get();
|
||||
$usedIds = [];
|
||||
$columnKeys = $area === 'ticket_statuses' ? $project->boardColumns()->pluck('configuration_key', 'id')->all() : [];
|
||||
foreach ($snapshot[$area] ?? [] as $item) {
|
||||
$matches = $models->filter(fn ($model): bool => $identity->signature($area, $model->attributesToArray()) === $identity->signature($area, $item));
|
||||
$matches = $matches->reject(static fn ($model): bool => in_array($model->id, $usedIds, true));
|
||||
if ($matches->count() > 1) {
|
||||
$matches = $matches->filter(static fn ($model): bool => $model->position === ($item['position'] ?? null));
|
||||
if ($area === 'ticket_statuses') {
|
||||
$matches = $matches->filter(static fn ($model): bool => ($columnKeys[$model->board_column_id] ?? null) === ($item['board_column_key'] ?? null));
|
||||
}
|
||||
}
|
||||
if ($matches->count() === 1) {
|
||||
$model = $matches->first();
|
||||
$usedIds[] = $model->id;
|
||||
DB::table($model->getTable())->where('id', $model->id)->update(['configuration_key' => $item['configuration_key']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->unique(['project_id', 'configuration_key']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||
$snapshot = $configuration->normalizeConfiguration($template->configuration_snapshot ?? []);
|
||||
$columnIndexes = array_flip(array_column($snapshot['board_columns'] ?? [], 'configuration_key'));
|
||||
$statusIndexes = array_flip(array_column($snapshot['ticket_statuses'] ?? [], 'configuration_key'));
|
||||
if (isset($snapshot['status_transitions'])) {
|
||||
$transitions = array_fill(0, count($statusIndexes), []);
|
||||
foreach ($snapshot['status_transitions'] as $transition) {
|
||||
$index = $statusIndexes[$transition['status_key']] ?? null;
|
||||
if ($index !== null) {
|
||||
$transitions[$index] = array_values(array_filter(array_map(
|
||||
static fn (string $key): ?int => $statusIndexes[$key] ?? null,
|
||||
$transition['target_keys'],
|
||||
), static fn (?int $index): bool => $index !== null));
|
||||
}
|
||||
}
|
||||
$snapshot['status_transitions'] = $transitions;
|
||||
}
|
||||
foreach ($snapshot['ticket_statuses'] ?? [] as $index => $status) {
|
||||
$status['board_column_index'] = $columnIndexes[$status['board_column_key'] ?? ''] ?? null;
|
||||
unset($status['board_column_key']);
|
||||
$snapshot['ticket_statuses'][$index] = $status;
|
||||
}
|
||||
$sourceStatusIds = $template->sourceProject?->ticketStatuses()->pluck('id', 'configuration_key')->all() ?? [];
|
||||
foreach ($snapshot['notification_actions'] ?? [] as $index => $action) {
|
||||
$snapshot['notification_actions'][$index]['filters'] = $configuration->mapNotificationFilters($action['filters'] ?? [], $sourceStatusIds);
|
||||
}
|
||||
foreach (ProjectConfigurationIdentity::relations() as $area => $relation) {
|
||||
foreach ($snapshot[$area] ?? [] as $index => $item) {
|
||||
unset($item['configuration_key']);
|
||||
$snapshot[$area][$index] = $item;
|
||||
}
|
||||
}
|
||||
$template->forceFill(['configuration_snapshot' => $snapshot])->saveQuietly();
|
||||
});
|
||||
foreach (self::TABLES as $name) {
|
||||
Schema::table($name, function (Blueprint $table): void {
|
||||
$table->dropUnique(['project_id', 'configuration_key']);
|
||||
$table->dropIndex(['configuration_key']);
|
||||
$table->dropColumn('configuration_key');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kanbangit-native",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -25,5 +25,5 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4"
|
||||
},
|
||||
"releaseNotes": "Correct iOS and macOS navigation gestures, preserve single-step page history, and constrain vertical overscroll."
|
||||
"releaseNotes": "Fix blocked navigation after changing project creation options or other forms on iOS by displaying native confirmations for unsaved changes. Pending confirmations close safely when changing workspaces."
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.4"
|
||||
version = "0.2.8"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.4"
|
||||
version = "0.2.8"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -9,8 +9,10 @@ fn build_navigation() {
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
||||
println!("cargo:rerun-if-changed=src/settings.m");
|
||||
let output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||
let object = output.join("navigation.o");
|
||||
let settings_object = output.join("settings.o");
|
||||
let archive = output.join("libkanban_navigation.a");
|
||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||
"aarch64" => "arm64",
|
||||
@@ -37,10 +39,31 @@ fn build_navigation() {
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args([
|
||||
"--sdk",
|
||||
"macosx",
|
||||
"clang",
|
||||
"-fobjc-arc",
|
||||
"-fblocks",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-mmacosx-version-min=14.0",
|
||||
"-arch",
|
||||
arch,
|
||||
"-c",
|
||||
"src/settings.m",
|
||||
"-o"
|
||||
])
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode clang is required")
|
||||
.success());
|
||||
assert!(Command::new("xcrun")
|
||||
.args(["ar", "crs"])
|
||||
.arg(&archive)
|
||||
.arg(&object)
|
||||
.arg(&settings_object)
|
||||
.status()
|
||||
.expect("Xcode ar is required")
|
||||
.success());
|
||||
|
||||
@@ -65,3 +65,32 @@ struct WorkspaceConnection {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum NativeSettingsError: Error { case invalidRequest, incompatibleVersion, invalidServer }
|
||||
|
||||
struct NativeSettingsRequest {
|
||||
let id: Int
|
||||
let action: String
|
||||
let payload: [String: Any]
|
||||
|
||||
static func parse(_ body: String) throws -> NativeSettingsRequest {
|
||||
guard body.utf8.count <= 8192,
|
||||
let value = try JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any],
|
||||
Set(value.keys) == Set(["version", "id", "action", "payload"]),
|
||||
let version = value["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(),
|
||||
version == 1 else { throw NativeSettingsError.incompatibleVersion }
|
||||
guard let id = value["id"] as? NSNumber, CFGetTypeID(id) != CFBooleanGetTypeID(),
|
||||
id.doubleValue >= 0, id.doubleValue <= 9007199254740991, id.doubleValue.rounded() == id.doubleValue,
|
||||
let action = value["action"] as? String,
|
||||
let payload = value["payload"] as? [String: Any] else { throw NativeSettingsError.invalidRequest }
|
||||
switch action {
|
||||
case "getState", "settingsReady", "openSettings":
|
||||
guard payload.isEmpty else { throw NativeSettingsError.invalidRequest }
|
||||
case "setWorkspace":
|
||||
guard Set(payload.keys) == Set(["server"]), payload["server"] is String else { throw NativeSettingsError.invalidRequest }
|
||||
default: throw NativeSettingsError.invalidRequest
|
||||
}
|
||||
return NativeSettingsRequest(id: id.intValue, action: action, payload: payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func installIOSWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, UIGestureRecognizerDelegate {
|
||||
final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate {
|
||||
private let defaults: UserDefaults
|
||||
private let serverKey = "ios.activeServer"
|
||||
private(set) var server: WorkspaceServer?
|
||||
@@ -40,10 +40,10 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
private let connectButton = UIButton(type: .system)
|
||||
private let resumeButton = UIButton(type: .system)
|
||||
private let toolbar = UIToolbar()
|
||||
private var gestureStart = CGPoint.zero
|
||||
private var gestureAllowed = false
|
||||
private var gestureRevision = 0
|
||||
private var settingsAvailable = false
|
||||
private var toolbarHeight: NSLayoutConstraint!
|
||||
private var appearance: UISegmentedControl!
|
||||
private var javaScriptConfirmation: (alert: UIAlertController, completion: (Bool) -> Void)?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -52,6 +52,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
deinit { javaScriptConfirmation?.completion(false) }
|
||||
|
||||
private func text(_ key: String) -> String {
|
||||
NSLocalizedString(key, comment: "KanbanGit iOS workspace")
|
||||
}
|
||||
@@ -168,7 +170,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||
settings.accessibilityIdentifier = "showServerSettings"
|
||||
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
|
||||
NSLayoutConstraint.activate([
|
||||
toolbarHeight,
|
||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
@@ -198,12 +202,14 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
func open(_ url: URL) {
|
||||
if server?.url == url, connection.ready { showWorkspace(); return }
|
||||
completeJavaScriptConfirmation(false)
|
||||
let selected = WorkspaceServer.selecting(url, current: server)
|
||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||
defaults.set(data, forKey: serverKey)
|
||||
timeout?.cancel()
|
||||
workspace?.stopLoading()
|
||||
workspace?.navigationDelegate = nil
|
||||
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
|
||||
workspace?.removeFromSuperview()
|
||||
connection = WorkspaceConnection()
|
||||
server = selected
|
||||
@@ -212,21 +218,16 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
// Remote content receives no native message handlers or Tauri bridge.
|
||||
let settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
|
||||
let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
|
||||
configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||
configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
|
||||
setSettingsAvailable(false)
|
||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||
webview.navigationDelegate = self
|
||||
webview.uiDelegate = self
|
||||
// Native WebKit swipes can skip async pushState entries. One edge recognizer
|
||||
// owns each gesture and traverses the same history that Inertia restores.
|
||||
webview.allowsBackForwardNavigationGestures = false
|
||||
webview.allowsBackForwardNavigationGestures = true
|
||||
webview.scrollView.bounces = false
|
||||
for edge: UIRectEdge in [.left, .right] {
|
||||
let gesture = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(navigateGesture(_:)))
|
||||
gesture.edges = edge
|
||||
gesture.delegate = self
|
||||
webview.addGestureRecognizer(gesture)
|
||||
webview.scrollView.panGestureRecognizer.require(toFail: gesture)
|
||||
}
|
||||
webview.accessibilityIdentifier = "workspace"
|
||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||
webview.isHidden = true
|
||||
@@ -268,6 +269,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
completeJavaScriptConfirmation(false)
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = false
|
||||
@@ -281,47 +283,79 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
loading.stopAnimating()
|
||||
view.endEditing(true)
|
||||
launcher.isHidden = true
|
||||
toolbar.isHidden = false
|
||||
toolbar.isHidden = settingsAvailable
|
||||
workspace?.isHidden = false
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer is UIScreenEdgePanGestureRecognizer, let webview = workspace else { return false }
|
||||
gestureStart = touch.location(in: webview)
|
||||
gestureAllowed = false
|
||||
gestureRevision += 1
|
||||
let revision = gestureRevision
|
||||
webview.evaluateJavaScript("window.__kanbanNavigation?.canSwipe(\(gestureStart.x), \(gestureStart.y)) === true") { [weak self, weak gestureRecognizer] result, _ in
|
||||
guard let self, self.gestureRevision == revision, self.workspace === webview else { return }
|
||||
self.gestureAllowed = result as? Bool == true
|
||||
// Release the scroll view immediately when a board, field or overlay owns the touch.
|
||||
if !self.gestureAllowed, gestureRecognizer?.state == .possible { gestureRecognizer?.state = .failed }
|
||||
private func setSettingsAvailable(_ available: Bool) {
|
||||
settingsAvailable = available
|
||||
toolbarHeight.constant = available ? 0 : 44
|
||||
toolbar.isHidden = available || !launcher.isHidden || !connection.ready
|
||||
}
|
||||
|
||||
func handleSettings(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) {
|
||||
guard message.webView === workspace, message.frameInfo.isMainFrame,
|
||||
let source = message.frameInfo.request.url, server?.allows(source) == true,
|
||||
let current = workspace?.url, server?.allows(current) == true,
|
||||
let body = message.body as? String else { reply(nil, "untrustedFrame"); return }
|
||||
do {
|
||||
let request = try NativeSettingsRequest.parse(body)
|
||||
var result: Any = NSNull()
|
||||
switch request.action {
|
||||
case "getState":
|
||||
result = ["apiVersion": 1, "platform": "ios", "workspace": server!.url.absoluteString,
|
||||
"version": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown",
|
||||
"capabilities": ["workspace"]] as [String: Any]
|
||||
case "settingsReady": setSettingsAvailable(true)
|
||||
case "openSettings": showSettings()
|
||||
case "setWorkspace":
|
||||
guard let address = request.payload["server"] as? String,
|
||||
let url = WorkspaceServer.normalize(address) else { throw NativeSettingsError.invalidServer }
|
||||
let owner = workspace
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.workspace === owner else { return }
|
||||
self.open(url)
|
||||
}
|
||||
default: throw NativeSettingsError.invalidRequest
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: ["id": request.id, "result": result])
|
||||
reply(String(decoding: data, as: UTF8.self), nil)
|
||||
} catch {
|
||||
reply(nil, String(describing: error))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
guard let edge = gestureRecognizer as? UIScreenEdgePanGestureRecognizer,
|
||||
let webview = workspace, !webview.isHidden, gestureAllowed else { return false }
|
||||
let velocity = edge.velocity(in: webview)
|
||||
return abs(velocity.x) > abs(velocity.y)
|
||||
&& (edge.edges == .left ? webview.canGoBack && velocity.x > 0 : webview.canGoForward && velocity.x < 0)
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
guard webView === workspace, viewIfLoaded?.window != nil,
|
||||
presentedViewController == nil, javaScriptConfirmation == nil else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
let alert = UIAlertController(title: frame.request.url?.host ?? text("appName"),
|
||||
message: message, preferredStyle: .alert)
|
||||
let cancel = UIAlertAction(title: text("cancel"), style: .cancel) { [weak self] _ in
|
||||
self?.completeJavaScriptConfirmation(false)
|
||||
}
|
||||
alert.addAction(cancel)
|
||||
alert.addAction(UIAlertAction(title: text("ok"), style: .default) { [weak self] _ in
|
||||
self?.completeJavaScriptConfirmation(true)
|
||||
})
|
||||
alert.preferredAction = cancel
|
||||
javaScriptConfirmation = (alert, completionHandler)
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func navigateGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
|
||||
guard gesture.state == .ended, let webview = workspace else { return }
|
||||
let direction = gesture.edges == .left ? -1 : 1
|
||||
let distance = gesture.translation(in: webview).x * CGFloat(-direction)
|
||||
let velocity = gesture.velocity(in: webview).x * CGFloat(-direction)
|
||||
guard distance > webview.bounds.width * 0.25 || (distance > 30 && velocity > 500) else { return }
|
||||
navigateHistory(direction, from: gestureStart)
|
||||
}
|
||||
|
||||
func navigateHistory(_ direction: Int, from point: CGPoint) {
|
||||
guard let webview = workspace,
|
||||
direction == -1 ? webview.canGoBack : webview.canGoForward else { return }
|
||||
let script = "if (window.__kanbanNavigation?.canSwipe(\(point.x), \(point.y))) window.__kanbanNavigation.step(\(direction))"
|
||||
webview.evaluateJavaScript(script, completionHandler: nil)
|
||||
func completeJavaScriptConfirmation(_ accepted: Bool) {
|
||||
guard let confirmation = javaScriptConfirmation else { return }
|
||||
javaScriptConfirmation = nil
|
||||
if confirmation.alert.presentingViewController == nil {
|
||||
confirmation.completion(accepted)
|
||||
} else {
|
||||
confirmation.alert.dismiss(animated: false) {
|
||||
confirmation.completion(accepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
@@ -350,6 +384,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
guard webView === workspace else { return }
|
||||
setSettingsAvailable(false)
|
||||
pendingNavigation = navigation
|
||||
beginNavigation()
|
||||
}
|
||||
@@ -389,3 +424,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
||||
showSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private final class NativeSettingsHandler: NSObject, WKScriptMessageHandlerWithReply {
|
||||
weak var controller: WorkspaceViewController?
|
||||
init(controller: WorkspaceViewController) { self.controller = controller }
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage,
|
||||
replyHandler: @escaping (Any?, String?) -> Void) {
|
||||
guard let controller else { replyHandler(nil, "bridgeUnavailable"); return }
|
||||
controller.handleSettings(message, reply: replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"switchWarning" = "Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.";
|
||||
"confirmSwitch" = "Switch server";
|
||||
"cancel" = "Cancel";
|
||||
"ok" = "OK";
|
||||
"connecting" = "Connecting to your server…";
|
||||
"connected" = "Your workspace is open. Use Server settings to change servers.";
|
||||
"connectionError" = "The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.";
|
||||
|
||||
@@ -95,7 +95,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testGesturesTraverseEverySPAEntryOnceAndPreserveForwardHistory() async throws {
|
||||
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||
let name = "KAN71-history-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
@@ -113,44 +113,16 @@ final class WorkspaceTests: XCTestCase {
|
||||
""")
|
||||
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||
XCTAssertFalse(webview.canGoBack)
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
XCTAssertNil(webview.goBack())
|
||||
XCTAssertNil(webview.goForward())
|
||||
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||
for index in 1...3 {
|
||||
_ = try await webview.evaluateJavaScript("history.pushState({page: {url: '/page\(index)'}}, '', '/page\(index)')")
|
||||
}
|
||||
_ = try await webview.evaluateJavaScript("window.steps = 0; addEventListener('popstate', () => window.steps++)")
|
||||
let edge = TestEdgeGesture()
|
||||
edge.edges = .left
|
||||
edge.testVelocity = CGPoint(x: 800, y: 0)
|
||||
XCTAssertTrue(controller.gestureRecognizer(edge, shouldReceive: TestTouch()))
|
||||
var allowed = false
|
||||
for _ in 0..<50 {
|
||||
if controller.gestureRecognizerShouldBegin(edge) { allowed = true; break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertTrue(allowed)
|
||||
edge.testState = .cancelled
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testState = .ended
|
||||
edge.testTranslation = CGPoint(x: 10, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
try await waitFor(webview, "location.pathname === '/page3' && window.steps === 0")
|
||||
edge.testTranslation = CGPoint(x: 200, y: 0)
|
||||
controller.perform(NSSelectorFromString("navigateGesture:"), with: edge)
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 1")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page1' && window.steps === 2")
|
||||
controller.navigateHistory(1, from: CGPoint(x: 1, y: 1))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "unsaved")
|
||||
XCTAssertFalse(webview.scrollView.bounces)
|
||||
XCTAssertFalse(webview.allowsBackForwardNavigationGestures)
|
||||
XCTAssertEqual(webview.gestureRecognizers?.compactMap { $0 as? UIScreenEdgePanGestureRecognizer }.count, 2)
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.items!.contains { $0.accessibilityIdentifier == "workspaceBack" })
|
||||
XCTAssertTrue(toolbar.items!.contains { $0.accessibilityIdentifier == "showServerSettings" })
|
||||
@@ -158,10 +130,174 @@ final class WorkspaceTests: XCTestCase {
|
||||
XCTAssertEqual(scroll as? String, "none")
|
||||
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||
XCTAssertEqual(horizontal as? String, "auto")
|
||||
_ = try await webview.evaluateJavaScript("document.body.insertAdjacentHTML('beforeend', '<div role=dialog style=\"position:fixed;inset:0\">Dialog</div>')")
|
||||
controller.navigateHistory(-1, from: CGPoint(x: 1, y: 1))
|
||||
try await Task.sleep(for: .milliseconds(200))
|
||||
try await waitFor(webview, "location.pathname === '/page2' && window.steps === 3")
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||
"Changing workspaces must retain native gesture navigation")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDirtyFormNavigationPresentsNativeConfirmation() async throws {
|
||||
let animationsEnabled = UIView.areAnimationsEnabled
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(animationsEnabled) }
|
||||
let name = "ios-confirmation-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true; window.rootViewController = nil }
|
||||
let url = URL(string: "https://example.invalid/")!
|
||||
controller.open(url)
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("projects/create")), responseHTML: """
|
||||
<html><body><input id="duplicate" type="radio"><input id="draft" value="Unsaved project">
|
||||
<a id="back" href="#back">Back</a><script>
|
||||
window.visits = 0;
|
||||
document.querySelector('#back').onclick = event => {
|
||||
event.preventDefault();
|
||||
if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++;
|
||||
};
|
||||
</script></body></html>
|
||||
""")
|
||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.visits === 'number'")
|
||||
_ = try await webview.evaluateJavaScript("document.querySelector('#back').click(); true")
|
||||
let pristineVisits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(pristineVisits as? Int, 1)
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
_ = try await webview.evaluateJavaScript("document.querySelector('#duplicate').click(); true")
|
||||
var expectedVisits = 1
|
||||
for (index, accepted) in [false, true, false, true].enumerated() {
|
||||
let click = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||
let alert = try await waitForConfirmation(controller)
|
||||
XCTAssertEqual(alert.title, "example.invalid")
|
||||
XCTAssertEqual(alert.message, "Discard unsaved changes?")
|
||||
XCTAssertEqual(alert.actions.map(\.style), [.cancel, .default])
|
||||
XCTAssertTrue(alert.preferredAction === alert.actions.first, "The default action must preserve the draft")
|
||||
if index == 3 {
|
||||
await withCheckedContinuation { continuation in
|
||||
alert.dismiss(animated: false) { continuation.resume() }
|
||||
}
|
||||
}
|
||||
controller.completeJavaScriptConfirmation(accepted)
|
||||
controller.completeJavaScriptConfirmation(!accepted)
|
||||
_ = try await click.value
|
||||
if accepted { expectedVisits += 1 }
|
||||
let visits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(visits as? Int, expectedVisits, "Only accepted visits may run, exactly once each")
|
||||
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||
XCTAssertEqual(draft as? String, "Unsaved project")
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
}
|
||||
|
||||
let otherAlert = UIAlertController(title: "Already open", message: nil, preferredStyle: .alert)
|
||||
controller.present(otherAlert, animated: false)
|
||||
let blocked = try await webview.evaluateJavaScript("confirm('Another dialog')")
|
||||
XCTAssertEqual(blocked as? Bool, false, "A blocked presentation must release JavaScript")
|
||||
XCTAssertTrue(controller.presentedViewController === otherAlert)
|
||||
await withCheckedContinuation { continuation in
|
||||
otherAlert.dismiss(animated: false) { continuation.resume() }
|
||||
}
|
||||
|
||||
let pendingClick = Task { try await webview.evaluateJavaScript("document.querySelector('#back').click(); true") }
|
||||
_ = try await waitForConfirmation(controller)
|
||||
controller.open(URL(string: "https://other.invalid/")!)
|
||||
_ = try await pendingClick.value
|
||||
XCTAssertNil(controller.presentedViewController, "Replacing a workspace must dismiss its confirmation")
|
||||
let oldVisits = try await webview.evaluateJavaScript("window.visits")
|
||||
XCTAssertEqual(oldVisits as? Int, expectedVisits)
|
||||
let stale = try await webview.evaluateJavaScript("confirm('Previous workspace')")
|
||||
XCTAssertEqual(stale as? Bool, false, "A previous workspace must not present new confirmations")
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func waitForConfirmation(_ controller: WorkspaceViewController) async throws -> UIAlertController {
|
||||
for _ in 0..<100 {
|
||||
if let alert = controller.presentedViewController as? UIAlertController, !alert.isBeingPresented { return alert }
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
return try XCTUnwrap(controller.presentedViewController as? UIAlertController,
|
||||
"Dirty forms must offer a visible confirmation instead of silently cancelling navigation")
|
||||
}
|
||||
|
||||
func testSettingsProtocolRejectsUnsupportedActionsAndInvalidPayloads() throws {
|
||||
for body in [
|
||||
#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":true,"id":1,"action":"getState","payload":{}}"#,
|
||||
#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1"}}"#,
|
||||
#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":12}}"#,
|
||||
#"{"version":1,"id":1,"action":"getState","payload":{"extra":1}}"#,
|
||||
#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||
] { XCTAssertThrowsError(try NativeSettingsRequest.parse(body), body) }
|
||||
XCTAssertThrowsError(try NativeSettingsRequest.parse(String(repeating: "x", count: 8193)))
|
||||
XCTAssertEqual(try NativeSettingsRequest.parse(#"{"version":1,"id":2,"action":"getState","payload":{}}"#).id, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSettingsBridgeHidesToolbarOnlyAfterHandshakeAndRejectsFramesAndOldWorkspaces() async throws {
|
||||
let name = "KAN70-settings-\(UUID())"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let controller = WorkspaceViewController(defaults: defaults)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
let url = URL(string: "https://example.invalid/")!
|
||||
controller.open(url)
|
||||
let originalProfile = controller.server!.profile
|
||||
let webview = try XCTUnwrap(controller.workspace)
|
||||
webview.stopLoading()
|
||||
webview.loadSimulatedRequest(URLRequest(url: url.appendingPathComponent("login")), responseHTML: "<html><body>Login<iframe srcdoc='<p>frame</p>'></iframe></body></html>")
|
||||
try await waitFor(webview, "document.readyState === 'complete' && typeof window.kanbanNative === 'object'")
|
||||
let state = try await callScript(webview, "return await window.kanbanNative.request('getState')") as? [String: Any]
|
||||
XCTAssertEqual(state?["platform"] as? String, "ios")
|
||||
XCTAssertEqual(state?["workspace"] as? String, url.absoluteString)
|
||||
XCTAssertEqual(state?["capabilities"] as? [String], ["workspace"])
|
||||
XCTAssertNil(state?["updates"])
|
||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
||||
XCTAssertFalse(toolbar.isHidden, "Older pages must retain native recovery")
|
||||
let rejected = try await callScript(webview, """
|
||||
try {
|
||||
await window.frames[0].webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:2,action:'settingsReady',payload:{}}));
|
||||
return false;
|
||||
} catch { return true; }
|
||||
""")
|
||||
XCTAssertEqual(rejected as? Bool, true)
|
||||
XCTAssertFalse(toolbar.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('settingsReady')")
|
||||
controller.view.layoutIfNeeded()
|
||||
XCTAssertTrue(toolbar.isHidden)
|
||||
XCTAssertEqual(toolbar.frame.height, 0)
|
||||
XCTAssertEqual(webview.frame.minY, controller.view.safeAreaLayoutGuide.layoutFrame.minY, accuracy: 1)
|
||||
let invalid = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'http://bad.test'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(invalid as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url, url)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('openSettings')")
|
||||
XCTAssertFalse(controller.view.subviews.first { $0.accessibilityIdentifier == "serverSettings" }!.isHidden)
|
||||
_ = try await callScript(webview, "return await window.kanbanNative.request('setWorkspace', {server:'https://other.invalid/app/'})")
|
||||
for _ in 0..<50 {
|
||||
if controller.workspace !== webview { break }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
XCTAssertFalse(controller.workspace === webview)
|
||||
XCTAssertNotEqual(controller.server?.profile, originalProfile)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
let stale = try await callScript(webview, "try { await window.kanbanNative.request('setWorkspace', {server:'https://stale.invalid/'}); return false; } catch { return true; }")
|
||||
XCTAssertEqual(stale as? Bool, true)
|
||||
XCTAssertEqual(controller.server?.url.absoluteString, "https://other.invalid/app/")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func callScript(_ webview: WKWebView, _ script: String) async throws -> Any {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
webview.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -230,7 +366,7 @@ final class WorkspaceTests: XCTestCase {
|
||||
controller.open(url)
|
||||
let server = try XCTUnwrap(controller.server)
|
||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
|
||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
|
||||
let restored = WorkspaceViewController(defaults: defaults)
|
||||
restored.loadViewIfNeeded()
|
||||
XCTAssertEqual(restored.server, server)
|
||||
@@ -272,19 +408,3 @@ final class WorkspaceTests: XCTestCase {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class TestTouch: UITouch {
|
||||
override func location(in view: UIView?) -> CGPoint { CGPoint(x: 1, y: 1) }
|
||||
}
|
||||
|
||||
private final class TestEdgeGesture: UIScreenEdgePanGestureRecognizer {
|
||||
var testState: UIGestureRecognizer.State = .possible
|
||||
var testVelocity = CGPoint.zero
|
||||
var testTranslation = CGPoint.zero
|
||||
override var state: UIGestureRecognizer.State {
|
||||
get { testState }
|
||||
set { testState = newValue }
|
||||
}
|
||||
override func velocity(in view: UIView?) -> CGPoint { testVelocity }
|
||||
override func translation(in view: UIView?) -> CGPoint { testTranslation }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||
@@ -27,10 +28,18 @@
|
||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */ = {isa = PBXBuildFile; fileRef = BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */; };
|
||||
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 721CF221CEDDC28FF2C5EBD5;
|
||||
remoteInfo = "kanbangit-native_iOS";
|
||||
};
|
||||
8665A25D07C5F1A295164797 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA6AD2A91A9E86D319D86D59 /* Project object */;
|
||||
@@ -45,12 +54,13 @@
|
||||
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; sourceTree = "<group>"; };
|
||||
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
|
||||
31630170C358EA8657E34854 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
47C531812A31207F97A89C5F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; sourceTree = "<group>"; };
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = settings.m; sourceTree = "<group>"; };
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */ = {isa = PBXFileReference; path = metadata.rs; sourceTree = "<group>"; };
|
||||
63685B1189795CAEA85F7AB7 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
|
||||
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||
@@ -63,8 +73,10 @@
|
||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||
9C56BE6890F0452F69D64795 /* android.rs */ = {isa = PBXFileReference; path = android.rs; sourceTree = "<group>"; };
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KanbanGit.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */ = {isa = PBXFileReference; path = locale.rs; sourceTree = "<group>"; };
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-settings.js"; sourceTree = "<group>"; };
|
||||
CA65E67046326EA008175045 /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
|
||||
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -156,6 +168,8 @@
|
||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||
E8BA104C81071623E1FF367D /* server.rs */,
|
||||
5F196E34D1B9E14FBD7191F4 /* settings.m */,
|
||||
1CDE5C303A96423E2E6A194D /* settings.rs */,
|
||||
6610D376CF901AC1733D394E /* updates.rs */,
|
||||
);
|
||||
name = src;
|
||||
@@ -182,7 +196,7 @@
|
||||
59D2DE9FD94E6D2D64CB0117 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */,
|
||||
9E29B2FA90E1D53F604961F6 /* KanbanGit.app */,
|
||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */,
|
||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */,
|
||||
);
|
||||
@@ -193,6 +207,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||
);
|
||||
name = ui;
|
||||
path = ../../../ui;
|
||||
@@ -240,7 +255,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "kanbangit-native_iOS";
|
||||
productReference = 23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */;
|
||||
productReference = 9E29B2FA90E1D53F604961F6 /* KanbanGit.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
988CFF304CB81C8F3A4D6612 /* WorkspaceUITests */ = {
|
||||
@@ -271,6 +286,7 @@
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = WorkspaceTests;
|
||||
packageProductDependencies = (
|
||||
@@ -334,6 +350,7 @@
|
||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -342,6 +359,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -408,6 +426,11 @@
|
||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||
targetProxy = 8665A25D07C5F1A295164797 /* PBXContainerItemProxy */;
|
||||
};
|
||||
DEBBEA8683567208E49E40A5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 721CF221CEDDC28FF2C5EBD5 /* kanbangit-native_iOS */;
|
||||
targetProxy = 1C790E40E71919331C1C4F34 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
@@ -441,7 +464,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -457,7 +480,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -618,7 +641,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -634,7 +657,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME) $(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/$(PLATFORM_NAME)";
|
||||
MARKETING_VERSION = 0.2.4;
|
||||
MARKETING_VERSION = 0.2.8;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||
PRODUCT_NAME = KanbanGit;
|
||||
@@ -681,6 +704,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||
};
|
||||
name = debug;
|
||||
};
|
||||
@@ -700,6 +724,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit";
|
||||
};
|
||||
name = release;
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ settingGroups:
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||
MARKETING_VERSION: "0.2.4"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "0.2.8"
|
||||
CURRENT_PROJECT_VERSION: "7"
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
@@ -40,9 +40,11 @@ targets:
|
||||
kanbangit-native_iOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
productName: KanbanGit
|
||||
sources:
|
||||
- path: Sources
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Assets.xcassets
|
||||
- path: kanbangit-native_iOS
|
||||
- path: LaunchScreen.storyboard
|
||||
@@ -114,8 +116,11 @@ targets:
|
||||
WorkspaceTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
dependencies:
|
||||
- target: kanbangit-native_iOS
|
||||
sources:
|
||||
- path: ../../../ui/workspace-navigation.js
|
||||
- path: ../../../ui/workspace-settings.js
|
||||
- path: Tests
|
||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||
@@ -124,6 +129,7 @@ targets:
|
||||
base:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios.tests
|
||||
TEST_HOST: $(BUILT_PRODUCTS_DIR)/KanbanGit.app/KanbanGit
|
||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||
CODE_SIGN_STYLE: Manual
|
||||
SWIFT_VERSION: "5.0"
|
||||
|
||||
@@ -17,6 +17,15 @@ struct Connection {
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn allows_settings(&self, label: &str, url: &Url, main: bool) -> bool {
|
||||
main && self.label.as_deref() == Some(label)
|
||||
&& self
|
||||
.server
|
||||
.as_ref()
|
||||
.is_some_and(|server| allows_server_navigation(&server.url, url))
|
||||
}
|
||||
|
||||
fn begin_navigation(&mut self) -> u64 {
|
||||
self.revision += 1;
|
||||
self.phase = "connecting";
|
||||
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
install_workspace_navigation(webview.inner().cast());
|
||||
})
|
||||
.map_err(|_| "connectionError")?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::settings::install(app, &window).map_err(|_| "connectionError")?;
|
||||
let handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn allows_settings(app: &AppHandle, label: &str, url: &Url, main: bool) -> bool {
|
||||
let state = app.state::<Mutex<Connection>>();
|
||||
let state = state.lock().unwrap();
|
||||
state.allows_settings(label, url, main)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn settings_action(
|
||||
app: &AppHandle,
|
||||
action: crate::settings::Action,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
use crate::settings::Action;
|
||||
match action {
|
||||
Action::GetState {} => {
|
||||
let workspace = app
|
||||
.state::<Mutex<Connection>>()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.server
|
||||
.as_ref()
|
||||
.map(|server| server.url.to_string())
|
||||
.ok_or("bridgeUnavailable")?;
|
||||
return Ok(
|
||||
serde_json::json!({"apiVersion":1, "platform":"macos", "workspace":workspace,
|
||||
"version":app.package_info().version.to_string(), "capabilities":["workspace", "updates"],
|
||||
"updates":crate::updates::snapshot(app)}),
|
||||
);
|
||||
}
|
||||
Action::SettingsReady {} => {}
|
||||
Action::OpenSettings {} => show_launcher(app),
|
||||
Action::SetWorkspace { server } => {
|
||||
if let Err(error) = open_workspace(app, &server) {
|
||||
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||
show_launcher(app);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Action::CheckForUpdates {} => crate::updates::check_inline(app),
|
||||
Action::SetUpdatePreferences {
|
||||
automatic,
|
||||
interval_hours,
|
||||
} => crate::updates::save_preferences(app, automatic, interval_hours)?,
|
||||
Action::InstallUpdate { version } => crate::updates::install(app, Some(&version)),
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||
let result = match destination.path() {
|
||||
"/action/updates" => {
|
||||
@@ -464,6 +523,30 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn settings_only_accept_the_current_workspace_main_frame() {
|
||||
let mut state = Connection {
|
||||
server: Some(Server::new(validate_url("https://example.com/app/").unwrap()).unwrap()),
|
||||
label: Some("workspace-1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let url = Url::parse("https://example.com/settings/app").unwrap();
|
||||
assert!(state.allows_settings("workspace-1", &url, true));
|
||||
assert!(!state.allows_settings("workspace-1", &url, false));
|
||||
assert!(!state.allows_settings("workspace-0", &url, true));
|
||||
for address in [
|
||||
"https://evil.test",
|
||||
"http://example.com",
|
||||
"https://example.com:444/",
|
||||
"file:///tmp/page",
|
||||
] {
|
||||
assert!(!state.allows_settings("workspace-1", &Url::parse(address).unwrap(), true));
|
||||
}
|
||||
state.label = Some("workspace-2".into());
|
||||
assert!(!state.allows_settings("workspace-1", &url, true));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
||||
|
||||
@@ -27,3 +27,6 @@ pub use updates::refresh_menu as refresh_update_menu_for_smoke_test;
|
||||
pub fn run() {
|
||||
builder().run(context()).expect("Could not start KanbanGit");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod settings;
|
||||
|
||||
@@ -2,101 +2,54 @@
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Keep AppKit's system swipe preferences and cancellation thresholds, but use
|
||||
// script history traversal so asynchronously created Inertia entries aren't skipped.
|
||||
@interface KanbanNavigation : NSObject
|
||||
@property(nonatomic, weak) WKWebView *webview;
|
||||
@property(nonatomic, strong) id monitor;
|
||||
@property(nonatomic) BOOL pending;
|
||||
@property(nonatomic) BOOL tracking;
|
||||
@property(nonatomic) BOOL blocked;
|
||||
@property(nonatomic) NSUInteger sequence;
|
||||
@interface KanbanWorkspaceUIDelegate : NSObject <WKUIDelegate>
|
||||
@property(nonatomic, strong) id<WKUIDelegate> originalDelegate;
|
||||
@end
|
||||
|
||||
@implementation KanbanNavigation
|
||||
- (BOOL)swipeTrackingEnabled {
|
||||
return NSEvent.isSwipeTrackingFromScrollEventsEnabled;
|
||||
@implementation KanbanWorkspaceUIDelegate
|
||||
- (BOOL)respondsToSelector:(SEL)selector {
|
||||
return [super respondsToSelector:selector] || [self.originalDelegate respondsToSelector:selector];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (_monitor) [NSEvent removeMonitor:_monitor];
|
||||
- (id)forwardingTargetForSelector:(SEL)selector {
|
||||
if ([self.originalDelegate respondsToSelector:selector]) {
|
||||
return self.originalDelegate;
|
||||
}
|
||||
return [super forwardingTargetForSelector:selector];
|
||||
}
|
||||
|
||||
- (NSEvent *)handleEvent:(NSEvent *)event {
|
||||
WKWebView *webview = self.webview;
|
||||
if (!webview || event.window != webview.window || !webview.window.isKeyWindow ||
|
||||
webview.hidden || ![self swipeTrackingEnabled]) return event;
|
||||
if (event.phase == NSEventPhaseBegan) {
|
||||
self.sequence++;
|
||||
self.blocked = NO;
|
||||
self.pending = NO;
|
||||
self.tracking = NO;
|
||||
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message
|
||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler {
|
||||
NSWindow *window = webView.window;
|
||||
if (!window || window.attachedSheet) {
|
||||
completionHandler(NO);
|
||||
return;
|
||||
}
|
||||
if (self.tracking) return nil;
|
||||
if (event.phase == NSEventPhaseEnded || event.phase == NSEventPhaseCancelled) {
|
||||
self.sequence++;
|
||||
self.pending = NO;
|
||||
return event;
|
||||
}
|
||||
if (self.blocked || self.pending || event.momentumPhase != NSEventPhaseNone ||
|
||||
!(event.phase & (NSEventPhaseBegan | NSEventPhaseChanged)) || !event.hasPreciseScrollingDeltas) return event;
|
||||
if (fabs(event.scrollingDeltaX) <= fabs(event.scrollingDeltaY)) {
|
||||
if (fabs(event.scrollingDeltaY) > 0) self.blocked = YES;
|
||||
return event;
|
||||
}
|
||||
NSPoint point = [webview convertPoint:event.locationInWindow fromView:nil];
|
||||
if (!NSPointInRect(point, webview.bounds)) return event;
|
||||
if (!webview.isFlipped) point.y = webview.bounds.size.height - point.y;
|
||||
NSString *script = [NSString stringWithFormat:@"window.__kanbanNavigation?.canSwipe(%f, %f) === true", point.x, point.y];
|
||||
self.pending = YES;
|
||||
NSUInteger sequence = self.sequence;
|
||||
NSURL *page = webview.URL;
|
||||
__weak KanbanNavigation *weakSelf = self;
|
||||
[webview evaluateJavaScript:script completionHandler:^(id allowed, NSError *error) {
|
||||
KanbanNavigation *owner = weakSelf;
|
||||
if (!owner || owner.sequence != sequence) return;
|
||||
owner.pending = NO;
|
||||
owner.blocked = YES;
|
||||
if (error || ![allowed isEqual:@YES] || ![webview.URL isEqual:page] ||
|
||||
(!webview.canGoBack && !webview.canGoForward)) return;
|
||||
owner.tracking = YES;
|
||||
[event trackSwipeEventWithOptions:NSEventSwipeTrackingLockDirection | NSEventSwipeTrackingClampGestureAmount
|
||||
dampenAmountThresholdMin:webview.canGoForward ? -1 : 0
|
||||
max:webview.canGoBack ? 1 : 0
|
||||
usingHandler:^(CGFloat amount, NSEventPhase phase, BOOL complete, BOOL *stop) {
|
||||
KanbanNavigation *current = weakSelf;
|
||||
if (current.sequence != sequence) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
if (!current || !webview.window.isKeyWindow || ![webview.URL isEqual:page]) {
|
||||
current.tracking = NO;
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
if (!complete) return;
|
||||
current.tracking = NO;
|
||||
if (phase == NSEventPhaseCancelled) return;
|
||||
NSInteger direction = amount >= 1 ? -1 : amount <= -1 ? 1 : 0;
|
||||
if ((direction == -1 && webview.canGoBack) || (direction == 1 && webview.canGoForward)) {
|
||||
NSString *navigate = [NSString stringWithFormat:@"if (%@) window.__kanbanNavigation.step(%ld)", script, (long)direction];
|
||||
[webview evaluateJavaScript:navigate completionHandler:nil];
|
||||
}
|
||||
}];
|
||||
|
||||
NSAlert *alert = [NSAlert new];
|
||||
alert.alertStyle = NSAlertStyleWarning;
|
||||
alert.messageText = frame.request.URL.host ?: @"KanbanGit";
|
||||
alert.informativeText = message;
|
||||
[alert addButtonWithTitle:@"OK"];
|
||||
[alert addButtonWithTitle:@"Cancel"];
|
||||
[alert beginSheetModalForWindow:window completionHandler:^(NSModalResponse response) {
|
||||
completionHandler(response == NSAlertFirstButtonReturn);
|
||||
}];
|
||||
return event;
|
||||
}
|
||||
@end
|
||||
|
||||
static char workspaceUIDelegateKey;
|
||||
|
||||
void install_workspace_navigation(void *pointer) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
webview.allowsBackForwardNavigationGestures = NO;
|
||||
KanbanNavigation *navigation = [KanbanNavigation new];
|
||||
navigation.webview = webview;
|
||||
__weak KanbanNavigation *weakNavigation = navigation;
|
||||
navigation.monitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskScrollWheel handler:^NSEvent *(NSEvent *event) {
|
||||
KanbanNavigation *owner = weakNavigation;
|
||||
return owner ? [owner handleEvent:event] : event;
|
||||
}];
|
||||
objc_setAssociatedObject(webview, @selector(handleEvent:), navigation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
webview.allowsBackForwardNavigationGestures = YES;
|
||||
if ([webview.UIDelegate isKindOfClass:KanbanWorkspaceUIDelegate.class]) {
|
||||
return;
|
||||
}
|
||||
|
||||
KanbanWorkspaceUIDelegate *delegate = [KanbanWorkspaceUIDelegate new];
|
||||
delegate.originalDelegate = webview.UIDelegate;
|
||||
// WKWebView keeps a weak UI delegate, so retain the forwarding adapter with its view.
|
||||
objc_setAssociatedObject(webview, &workspaceUIDelegateKey, delegate, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
webview.UIDelegate = delegate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#import <WebKit/WebKit.h>
|
||||
|
||||
typedef char *(*SettingsCallback)(const char *, const char *, const char *, bool);
|
||||
typedef void (*SettingsFree)(char *);
|
||||
|
||||
@interface KanbanSettingsHandler : NSObject <WKScriptMessageHandlerWithReply>
|
||||
@property(nonatomic, weak) WKWebView *webview;
|
||||
@property(nonatomic, copy) NSString *label;
|
||||
@property(nonatomic) SettingsCallback callback;
|
||||
@property(nonatomic) SettingsFree releaseReply;
|
||||
@end
|
||||
|
||||
@implementation KanbanSettingsHandler
|
||||
- (void)userContentController:(WKUserContentController *)controller
|
||||
didReceiveScriptMessage:(WKScriptMessage *)message
|
||||
replyHandler:(void (^)(id, NSString *))replyHandler {
|
||||
if (message.webView != self.webview || !message.frameInfo.isMainFrame ||
|
||||
![message.body isKindOfClass:NSString.class]) {
|
||||
replyHandler(nil, @"untrustedFrame");
|
||||
return;
|
||||
}
|
||||
char *reply = self.callback(self.label.UTF8String,
|
||||
message.frameInfo.request.URL.absoluteString.UTF8String,
|
||||
[(NSString *)message.body UTF8String], message.frameInfo.isMainFrame);
|
||||
if (!reply) { replyHandler(nil, @"bridgeUnavailable"); return; }
|
||||
NSString *value = [NSString stringWithUTF8String:reply];
|
||||
self.releaseReply(reply);
|
||||
replyHandler(value, nil);
|
||||
}
|
||||
@end
|
||||
|
||||
void install_workspace_settings(void *pointer, const char *label, const char *script,
|
||||
SettingsCallback callback, SettingsFree releaseReply) {
|
||||
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||
KanbanSettingsHandler *handler = [KanbanSettingsHandler new];
|
||||
handler.webview = webview;
|
||||
handler.label = [NSString stringWithUTF8String:label];
|
||||
handler.callback = callback;
|
||||
handler.releaseReply = releaseReply;
|
||||
[webview.configuration.userContentController addScriptMessageHandlerWithReply:handler
|
||||
contentWorld:WKContentWorld.pageWorld name:@"kanbanSettings"];
|
||||
[webview.configuration.userContentController addUserScript:[[WKUserScript alloc]
|
||||
initWithSource:[NSString stringWithUTF8String:script]
|
||||
injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]];
|
||||
// The initial document may already have started loading before the handler is attached.
|
||||
[webview evaluateJavaScript:[NSString stringWithUTF8String:script] completionHandler:nil];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::ffi::{c_char, CStr, CString};
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{AppHandle, Url};
|
||||
|
||||
static APP: OnceLock<AppHandle> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Request {
|
||||
pub version: u8,
|
||||
pub id: u64,
|
||||
pub action: String,
|
||||
pub payload: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(
|
||||
tag = "action",
|
||||
content = "payload",
|
||||
rename_all = "camelCase",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Action {
|
||||
GetState {},
|
||||
SettingsReady {},
|
||||
OpenSettings {},
|
||||
SetWorkspace {
|
||||
server: String,
|
||||
},
|
||||
CheckForUpdates {},
|
||||
SetUpdatePreferences {
|
||||
automatic: bool,
|
||||
#[serde(rename = "intervalHours")]
|
||||
interval_hours: u64,
|
||||
},
|
||||
InstallUpdate {
|
||||
version: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Result<(u64, Action), &'static str> {
|
||||
if value.len() > 8192 {
|
||||
return Err("invalidRequest");
|
||||
}
|
||||
let request: Request = serde_json::from_str(value).map_err(|_| "invalidRequest")?;
|
||||
if request.version != 1 {
|
||||
return Err("incompatibleVersion");
|
||||
}
|
||||
let action =
|
||||
serde_json::from_value(json!({"action": request.action, "payload": request.payload}))
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
Ok((request.id, action))
|
||||
}
|
||||
|
||||
pub fn install(app: &AppHandle, window: &tauri::WebviewWindow) -> tauri::Result<()> {
|
||||
let _ = APP.set(app.clone());
|
||||
let label = CString::new(window.label()).unwrap();
|
||||
let script = CString::new(include_str!("../../ui/workspace-settings.js")).unwrap();
|
||||
window.with_webview(move |view| unsafe {
|
||||
unsafe extern "C" {
|
||||
fn install_workspace_settings(
|
||||
view: *mut std::ffi::c_void,
|
||||
label: *const c_char,
|
||||
script: *const c_char,
|
||||
callback: extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
bool,
|
||||
) -> *mut c_char,
|
||||
free: extern "C" fn(*mut c_char),
|
||||
);
|
||||
}
|
||||
install_workspace_settings(
|
||||
view.inner().cast(),
|
||||
label.as_ptr(),
|
||||
script.as_ptr(),
|
||||
receive,
|
||||
release,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
extern "C" fn release(value: *mut c_char) {
|
||||
if !value.is_null() {
|
||||
unsafe {
|
||||
drop(CString::from_raw(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn receive(
|
||||
label: *const c_char,
|
||||
url: *const c_char,
|
||||
body: *const c_char,
|
||||
main: bool,
|
||||
) -> *mut c_char {
|
||||
let result = std::panic::catch_unwind(|| -> Result<Value, &'static str> {
|
||||
if label.is_null() || url.is_null() || body.is_null() {
|
||||
return Err("invalidRequest");
|
||||
}
|
||||
let label = unsafe { CStr::from_ptr(label) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let url = unsafe { CStr::from_ptr(url) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let body = unsafe { CStr::from_ptr(body) }
|
||||
.to_str()
|
||||
.map_err(|_| "invalidRequest")?;
|
||||
let app = APP.get().ok_or("bridgeUnavailable")?;
|
||||
let url = Url::parse(url).map_err(|_| "untrustedFrame")?;
|
||||
if !crate::desktop::allows_settings(app, label, &url, main) {
|
||||
return Err("untrustedFrame");
|
||||
}
|
||||
let (id, action) = parse(body)?;
|
||||
let result = crate::desktop::settings_action(app, action)?;
|
||||
Ok(json!({"id": id, "result": result}))
|
||||
})
|
||||
.unwrap_or(Err("bridgeUnavailable"));
|
||||
let value = result.unwrap_or_else(|error| json!({"error": error}));
|
||||
CString::new(value.to_string()).unwrap().into_raw()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn protocol_rejects_unknown_versions_actions_and_malformed_payloads() {
|
||||
for value in [
|
||||
r#"{"version":2,"id":1,"action":"getState","payload":{}}"#,
|
||||
r#"{"version":1,"id":1,"action":"shell","payload":{}}"#,
|
||||
r#"{"version":1,"id":1,"action":"setWorkspace","payload":{"server":3}}"#,
|
||||
r#"{"version":1,"id":1,"action":"setUpdatePreferences","payload":{"automatic":"true","intervalHours":6}}"#,
|
||||
r#"{"version":1,"id":1,"action":"installUpdate","payload":{"version":"1.2.3","url":"https://evil.test"}}"#,
|
||||
] {
|
||||
assert!(parse(value).is_err(), "{value}");
|
||||
}
|
||||
assert!(parse(&"x".repeat(8193)).is_err());
|
||||
assert!(matches!(
|
||||
parse(r#"{"version":1,"id":1,"action":"getState","payload":{}}"#),
|
||||
Ok((1, Action::GetState {}))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,12 @@ pub fn check(app: &AppHandle, manual: bool) {
|
||||
if manual {
|
||||
show(app);
|
||||
}
|
||||
check_with_mode(app, manual);
|
||||
}
|
||||
pub fn check_inline(app: &AppHandle) {
|
||||
check_with_mode(app, true);
|
||||
}
|
||||
fn check_with_mode(app: &AppHandle, manual: bool) {
|
||||
let begin = app
|
||||
.state::<Mutex<State>>()
|
||||
.lock()
|
||||
@@ -297,7 +303,7 @@ fn verify_bundle(archive: &Path, directory: &Path, version: &str) -> Result<(),
|
||||
command("/usr/bin/codesign", &["--verify", "--deep", "--strict", "-R", "=anchor apple generic and identifier \"de.kanbangit.desktop\" and certificate leaf[subject.OU] = \"ZZX2KZGTHU\" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists", app.to_str().ok_or("verificationError")?])?;
|
||||
Ok(())
|
||||
}
|
||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||
let update = {
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
@@ -400,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
}
|
||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
fs::create_dir_all(&directory)?;
|
||||
let pending = directory.join("update-preferences.pending");
|
||||
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
|
||||
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||
Ok(())
|
||||
})();
|
||||
if saved.is_err() {
|
||||
fail(app, "preferencesError");
|
||||
return;
|
||||
if let Err(error) =
|
||||
save_preferences(app, preferences.automatic, preferences.interval_hours)
|
||||
{
|
||||
fail(app, error);
|
||||
}
|
||||
app.state::<Mutex<State>>().lock().unwrap().view.preferences = preferences;
|
||||
refresh(app);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(app: &AppHandle) -> serde_json::Value {
|
||||
serde_json::to_value(&app.state::<Mutex<State>>().lock().unwrap().view).unwrap()
|
||||
}
|
||||
pub fn save_preferences(
|
||||
app: &AppHandle,
|
||||
automatic: bool,
|
||||
interval_hours: u64,
|
||||
) -> Result<(), &'static str> {
|
||||
let preferences = Preferences {
|
||||
automatic,
|
||||
interval_hours,
|
||||
};
|
||||
if !preferences.valid() {
|
||||
return Err("preferencesError");
|
||||
}
|
||||
let state = app.state::<Mutex<State>>();
|
||||
let mut state = state.lock().unwrap();
|
||||
if state.busy() {
|
||||
return Err("updateBusy");
|
||||
}
|
||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
fs::create_dir_all(&directory)?;
|
||||
let pending = directory.join("update-preferences.pending");
|
||||
fs::write(&pending, serde_json::to_vec(&preferences)?)?;
|
||||
fs::rename(pending, directory.join("update-preferences.json"))?;
|
||||
Ok(())
|
||||
})();
|
||||
saved.map_err(|_| "preferencesError")?;
|
||||
state.view.preferences = preferences;
|
||||
drop(state);
|
||||
refresh(app);
|
||||
Ok(())
|
||||
}
|
||||
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||
app.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "KanbanGit",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"identifier": "de.kanbangit.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "14.0",
|
||||
"bundleVersion": "9",
|
||||
"bundleVersion": "12",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "17.0",
|
||||
"developmentTeam": "ZZX2KZGTHU",
|
||||
"bundleVersion": "4"
|
||||
"bundleVersion": "7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn main() {
|
||||
.setup(move |app, _| {
|
||||
let directory = app.path().app_config_dir()?;
|
||||
std::fs::create_dir_all(&directory)?;
|
||||
std::fs::write(directory.join("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
|
||||
if needs_setup {
|
||||
if startup == "invalid" {
|
||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||
@@ -118,36 +119,34 @@ fn main() {
|
||||
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||
let denied = true;
|
||||
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
||||
try { await window.__TAURI_INTERNALS__.invoke(cmd); denied = false; }
|
||||
try { await Promise.race([window.__TAURI_INTERNALS__.invoke(cmd), new Promise((_, reject) => setTimeout(() => reject(new Error('IPC timeout')), 3000))]); denied = false; }
|
||||
catch (error) { denied = denied && String(error).includes('not allowed'); }
|
||||
}
|
||||
const api = window.kanbanNative;
|
||||
if (api?.version !== 1) throw new Error('Versioned settings bridge missing');
|
||||
const initial = await api.request('getState');
|
||||
if (initial.platform !== 'macos' || initial.workspace !== 'https://kanbangit.de/' || !initial.capabilities.includes('updates')) throw new Error('Incorrect native settings');
|
||||
await api.request('setUpdatePreferences', {automatic:false, intervalHours:24});
|
||||
const changed = await api.request('getState');
|
||||
if (changed.updates.preferences.automatic || changed.updates.preferences.intervalHours !== 24) throw new Error('Update preferences did not persist');
|
||||
const invalid = await Promise.race([window.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:5,action:'shell',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Raw bridge timeout')), 3000))]);
|
||||
if (!JSON.parse(invalid).error) throw new Error('Unknown native action accepted');
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.append(frame);
|
||||
if (!frame.contentWindow.webkit?.messageHandlers.kanbanSettings) throw new Error('Frame bridge fixture missing');
|
||||
let frameDenied = false;
|
||||
try { await Promise.race([frame.contentWindow.webkit.messageHandlers.kanbanSettings.postMessage(JSON.stringify({version:1,id:6,action:'getState',payload:{}})), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame bridge timeout')), 3000))]); }
|
||||
catch (error) { frameDenied = String(error).includes('untrustedFrame'); }
|
||||
if (!frameDenied) throw new Error('Subframe settings access accepted');
|
||||
const input = document.createElement('input');
|
||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||
localStorage.setItem('kan52_fixture', 'isolated');
|
||||
const initialHistory = history.length;
|
||||
const initialPath = location.pathname;
|
||||
const navigation = window.__kanbanNavigation;
|
||||
if (!navigation || getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const traverse = direction => new Promise(resolve => {
|
||||
addEventListener('popstate', resolve, { once: true });
|
||||
navigation.step(direction);
|
||||
});
|
||||
for (const hash of ['#board', '#ticket', '#settings']) history.pushState({ page: { url: hash } }, '', hash);
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#ticket') throw new Error('Back skipped an SPA entry');
|
||||
await traverse(-1);
|
||||
if (location.hash !== '#board') throw new Error('Back skipped a second SPA entry');
|
||||
await traverse(1);
|
||||
if (location.hash !== '#ticket' || history.length !== initialHistory + 3) throw new Error('Forward history was lost');
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.textContent = 'Navigation overlay'; document.body.append(dialog); dialog.showModal();
|
||||
if (navigation.canSwipe(1, 1)) throw new Error('Overlay must own navigation');
|
||||
dialog.close(); dialog.remove();
|
||||
if (input.value !== 'unsaved input') throw new Error('History discarded form state');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && initialPath === '/login' && initialHistory === 1;
|
||||
if (typeof window.__kanbanNavigation !== 'undefined') throw new Error('Custom gesture bridge must be removed');
|
||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
||||
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||
})();
|
||||
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||
"#).unwrap();
|
||||
} else {
|
||||
webview.eval(r#"
|
||||
@@ -179,12 +178,10 @@ fn main() {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||
}
|
||||
"#).unwrap();
|
||||
let launcher = app.get_webview_window("launcher").unwrap();
|
||||
launcher.show().unwrap();
|
||||
launcher.eval(r#"
|
||||
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
||||
document.querySelector('#server-form').requestSubmit();
|
||||
document.querySelector('#confirm-switch').click();
|
||||
window.eval(r#"
|
||||
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
||||
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
||||
});
|
||||
"#).unwrap();
|
||||
});
|
||||
} else {
|
||||
@@ -258,6 +255,19 @@ fn main() {
|
||||
closed.load(Ordering::SeqCst),
|
||||
"The close button must quit the application"
|
||||
);
|
||||
if online {
|
||||
assert!(std::fs::read_to_string(config_dir.join("active-server"))
|
||||
.unwrap()
|
||||
.starts_with("https://kanbangit.de/login/\n"));
|
||||
let preferences: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(config_dir.join("update-preferences.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preferences,
|
||||
serde_json::json!({"automatic": false, "intervalHours": 24})
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(config_dir);
|
||||
assert!(
|
||||
passed.load(Ordering::SeqCst),
|
||||
|
||||
@@ -25,74 +25,17 @@ await page.addInitScript(script);
|
||||
const root = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
await page.goto(root);
|
||||
const length = await page.evaluate(() => history.length);
|
||||
for (const path of ['/board', '/ticket', '/settings']) {
|
||||
await page.evaluate(
|
||||
(path) =>
|
||||
history.pushState(
|
||||
{
|
||||
page: { url: path },
|
||||
rememberedState: { draft: 'retained' },
|
||||
},
|
||||
'',
|
||||
path,
|
||||
),
|
||||
path,
|
||||
);
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
window.steps = 0;
|
||||
addEventListener('popstate', () => window.steps++);
|
||||
});
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 1);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForFunction(() => location.pathname === '/board');
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.waitForFunction(() => location.pathname === '/ticket');
|
||||
assert.equal(await page.evaluate(() => window.steps), 3);
|
||||
assert.equal(await page.evaluate(() => history.length), length + 3);
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'retained');
|
||||
assert.equal(
|
||||
await page.evaluate(() => history.state.rememberedState.draft),
|
||||
'retained',
|
||||
await page.evaluate(() => typeof window.__kanbanNavigation),
|
||||
'undefined',
|
||||
);
|
||||
await page.goto(`${root}/full-page`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(-1));
|
||||
await page.waitForURL(`${root}/ticket`);
|
||||
await page.evaluate(() => window.__kanbanNavigation.step(1));
|
||||
await page.goBack();
|
||||
await page.waitForURL(`${root}/`);
|
||||
await page.goForward();
|
||||
await page.waitForURL(`${root}/full-page`);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.locator('dialog').evaluate((dialog) => dialog.close());
|
||||
for (const selector of ['#board', '#draft']) {
|
||||
const point = await page.locator(selector).boundingBox();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
({ x, y }) => window.__kanbanNavigation.canSwipe(x + 5, y + 5),
|
||||
point,
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragstart');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
false,
|
||||
);
|
||||
await page.dispatchEvent('#board', 'dragend');
|
||||
assert.equal(
|
||||
await page.evaluate(() => window.__kanbanNavigation.canSwipe(1, 1)),
|
||||
true,
|
||||
);
|
||||
await page.locator('#draft').fill('unsaved draft');
|
||||
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
@@ -143,7 +86,7 @@ try {
|
||||
() => document.querySelector('dialog').scrollTop > 0,
|
||||
);
|
||||
console.log(
|
||||
'PASS: SPA/full-page history, forward history, overlays, short/long scroll bounds, horizontal board, drag and input guards',
|
||||
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
+147
-124
@@ -1,142 +1,165 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import "../src-tauri/src/navigation.m"
|
||||
|
||||
// Exercise the AppKit event adapter deterministically; macos_smoke separately
|
||||
// runs the shared history script against a real WKWebView.
|
||||
@interface TestWindow : NSWindow
|
||||
@end
|
||||
@implementation TestWindow
|
||||
- (BOOL)isKeyWindow { return YES; }
|
||||
static void check(BOOL condition, NSString *message) {
|
||||
if (!condition) {
|
||||
fprintf(stderr, "FAIL: %s\n", message.UTF8String);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
static void waitUntil(BOOL (^condition)(void), NSString *message) {
|
||||
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:10];
|
||||
while (!condition() && deadline.timeIntervalSinceNow > 0) {
|
||||
[NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
||||
}
|
||||
check(condition(), message);
|
||||
}
|
||||
|
||||
static id evaluate(WKWebView *webview, NSString *script) {
|
||||
__block BOOL finished = NO;
|
||||
__block id result;
|
||||
[webview evaluateJavaScript:script completionHandler:^(id value, NSError *error) {
|
||||
check(error == nil, error.localizedDescription);
|
||||
result = value;
|
||||
finished = YES;
|
||||
}];
|
||||
waitUntil(^BOOL { return finished; }, @"JavaScript must finish after the confirmation is dismissed");
|
||||
return result;
|
||||
}
|
||||
|
||||
static NSButton *findButton(NSView *view, NSString *title) {
|
||||
if ([view isKindOfClass:NSButton.class] && [((NSButton *)view).title isEqualToString:title]) {
|
||||
return (NSButton *)view;
|
||||
}
|
||||
for (NSView *child in view.subviews) {
|
||||
NSButton *button = findButton(child, title);
|
||||
if (button) { return button; }
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL containsText(NSView *view, NSString *text) {
|
||||
if ([view isKindOfClass:NSTextField.class] && [((NSTextField *)view).stringValue isEqualToString:text]) {
|
||||
return YES;
|
||||
}
|
||||
for (NSView *child in view.subviews) {
|
||||
if (containsText(child, text)) { return YES; }
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
@interface WorkspaceUI : NSObject <WKUIDelegate>
|
||||
@property(nonatomic) BOOL closed;
|
||||
@property(nonatomic) BOOL uploaded;
|
||||
@end
|
||||
|
||||
@interface TestWebView : WKWebView
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic, strong) NSURL *testURL;
|
||||
@property(nonatomic) BOOL back;
|
||||
@property(nonatomic) BOOL forward;
|
||||
@property(nonatomic) BOOL allowsSwipe;
|
||||
@property(nonatomic) BOOL deferPermission;
|
||||
@property(nonatomic, copy) void (^permission)(id, NSError *);
|
||||
@property(nonatomic, strong) NSMutableArray<NSString *> *steps;
|
||||
@end
|
||||
@implementation TestWebView
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSURL *)URL { return self.testURL; }
|
||||
- (BOOL)canGoBack { return self.back; }
|
||||
- (BOOL)canGoForward { return self.forward; }
|
||||
- (NSPoint)convertPoint:(NSPoint)point fromView:(NSView *)view { return point; }
|
||||
- (void)evaluateJavaScript:(NSString *)script completionHandler:(void (^)(id, NSError *))handler {
|
||||
if ([script containsString:@".step("]) [self.steps addObject:script];
|
||||
else if (self.deferPermission) self.permission = handler;
|
||||
else handler(@(self.allowsSwipe), nil);
|
||||
@implementation WorkspaceUI
|
||||
- (void)webViewDidClose:(WKWebView *)webView { self.closed = YES; }
|
||||
- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters
|
||||
initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> *))completionHandler {
|
||||
self.uploaded = YES;
|
||||
completionHandler(nil);
|
||||
}
|
||||
@end
|
||||
|
||||
@interface TestEvent : NSEvent
|
||||
@property(nonatomic, strong) NSWindow *testWindow;
|
||||
@property(nonatomic) NSEventPhase testPhase;
|
||||
@property(nonatomic) NSEventPhase testMomentum;
|
||||
@property(nonatomic) CGFloat dx;
|
||||
@property(nonatomic) CGFloat dy;
|
||||
@property(nonatomic) BOOL precise;
|
||||
@property(nonatomic) NSInteger trackCount;
|
||||
@property(nonatomic, copy) void (^tracking)(CGFloat, NSEventPhase, BOOL, BOOL *);
|
||||
@end
|
||||
@implementation TestEvent
|
||||
- (NSWindow *)window { return self.testWindow; }
|
||||
- (NSEventPhase)phase { return self.testPhase; }
|
||||
- (NSEventPhase)momentumPhase { return self.testMomentum; }
|
||||
- (CGFloat)scrollingDeltaX { return self.dx; }
|
||||
- (CGFloat)scrollingDeltaY { return self.dy; }
|
||||
- (BOOL)hasPreciseScrollingDeltas { return self.precise; }
|
||||
- (NSPoint)locationInWindow { return NSMakePoint(20, 20); }
|
||||
- (void)trackSwipeEventWithOptions:(NSEventSwipeTrackingOptions)options dampenAmountThresholdMin:(CGFloat)minimum max:(CGFloat)maximum usingHandler:(void (^)(CGFloat, NSEventPhase, BOOL, BOOL *))handler {
|
||||
NSCAssert(options & NSEventSwipeTrackingLockDirection, @"Direction must be locked");
|
||||
self.trackCount++;
|
||||
self.tracking = handler;
|
||||
}
|
||||
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||
@property(nonatomic) NSUInteger finished;
|
||||
@end
|
||||
|
||||
@interface TestNavigation : KanbanNavigation
|
||||
@property(nonatomic) BOOL enabled;
|
||||
@end
|
||||
@implementation TestNavigation
|
||||
- (BOOL)swipeTrackingEnabled { return self.enabled; }
|
||||
@implementation WorkspacePages
|
||||
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)task {
|
||||
NSData *body = [[NSString stringWithFormat:@"<html><body>%@<input value='draft'></body></html>", task.request.URL.path]
|
||||
dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:task.request.URL MIMEType:@"text/html"
|
||||
expectedContentLength:body.length textEncodingName:@"utf-8"];
|
||||
[task didReceiveResponse:response];
|
||||
[task didReceiveData:body];
|
||||
[task didFinish];
|
||||
}
|
||||
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)task {}
|
||||
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
|
||||
self.finished++;
|
||||
}
|
||||
@end
|
||||
|
||||
int main(void) {
|
||||
@autoreleasepool {
|
||||
[NSApplication sharedApplication];
|
||||
TestWindow *window = [[TestWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 400) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
TestWebView *webview = [[TestWebView alloc] initWithFrame:NSMakeRect(0, 0, 600, 400)];
|
||||
webview.testWindow = window;
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/board"];
|
||||
webview.back = YES;
|
||||
webview.forward = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.steps = [NSMutableArray new];
|
||||
TestNavigation *navigation = [TestNavigation new];
|
||||
navigation.webview = webview;
|
||||
navigation.enabled = YES;
|
||||
TestEvent *(^event)(void) = ^{
|
||||
TestEvent *value = [TestEvent new];
|
||||
value.testWindow = window;
|
||||
value.testPhase = NSEventPhaseBegan;
|
||||
value.precise = YES;
|
||||
value.dx = 15;
|
||||
return value;
|
||||
};
|
||||
BOOL stop = NO;
|
||||
TestEvent *back = event();
|
||||
[navigation handleEvent:back];
|
||||
NSCAssert(back.trackCount == 1, @"A horizontal gesture must use AppKit tracking");
|
||||
back.tracking(0.5, NSEventPhaseChanged, NO, &stop);
|
||||
NSCAssert(webview.steps.count == 0, @"A partial swipe must not navigate");
|
||||
back.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 1 && [webview.steps.lastObject containsString:@"step(-1)"], @"Back must request exactly one step");
|
||||
TestEvent *forward = event();
|
||||
forward.dx = -15;
|
||||
[navigation handleEvent:forward];
|
||||
forward.tracking(-1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2 && [webview.steps.lastObject containsString:@"step(1)"], @"Forward must request exactly one step");
|
||||
TestEvent *cancelled = event();
|
||||
[navigation handleEvent:cancelled];
|
||||
cancelled.tracking(0, NSEventPhaseCancelled, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"Cancelled swipes must not navigate");
|
||||
TestEvent *interrupted = event();
|
||||
[navigation handleEvent:interrupted];
|
||||
webview.allowsSwipe = NO;
|
||||
[navigation handleEvent:event()];
|
||||
interrupted.tracking(1, NSEventPhaseNone, YES, &stop);
|
||||
NSCAssert(webview.steps.count == 2, @"A new gesture must cancel the previous gesture's animation callback");
|
||||
WorkspacePages *pages = [WorkspacePages new];
|
||||
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
|
||||
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistentDataStore;
|
||||
[configuration setURLSchemeHandler:pages forURLScheme:@"kanban-test"];
|
||||
WKWebView *webview = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 800, 600) configuration:configuration];
|
||||
webview.navigationDelegate = pages;
|
||||
WorkspaceUI *originalUI = [WorkspaceUI new];
|
||||
webview.UIDelegate = originalUI;
|
||||
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
id<WKUIDelegate> installedUI = webview.UIDelegate;
|
||||
install_workspace_navigation((__bridge void *)webview);
|
||||
check(webview.UIDelegate == installedUI, @"Installing workspace behavior twice must retain the same delegate");
|
||||
check([installedUI respondsToSelector:@selector(webViewDidClose:)], @"Existing popup callbacks must remain available");
|
||||
[installedUI webViewDidClose:webview];
|
||||
check(originalUI.closed, @"Existing popup callbacks must reach the original delegate");
|
||||
check([installedUI respondsToSelector:@selector(webView:runOpenPanelWithParameters:initiatedByFrame:completionHandler:)],
|
||||
@"Existing file upload callbacks must remain available");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"The workspace must enable WebKit's interactive gestures");
|
||||
check(webview.configuration.userContentController.userScripts.count == 0, @"Enabling gestures must not inject a history adapter");
|
||||
check([webview goBack] == nil && [webview goForward] == nil, @"Empty history must not navigate");
|
||||
|
||||
for (NSInteger test = 0; test < 7; test++) {
|
||||
TestEvent *ignored = event();
|
||||
navigation.enabled = test != 0;
|
||||
webview.allowsSwipe = test != 1;
|
||||
webview.back = webview.forward = test != 2;
|
||||
if (test == 3) ignored.precise = NO;
|
||||
if (test == 4) ignored.dy = 30;
|
||||
if (test == 5) ignored.testMomentum = NSEventPhaseChanged;
|
||||
if (test == 6) ignored.testWindow = nil;
|
||||
NSCAssert([navigation handleEvent:ignored] == ignored && ignored.trackCount == 0, @"Non-navigation events must remain available for scrolling");
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/first"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 1; }, @"First page must load");
|
||||
check(!webview.canGoBack && !webview.canGoForward, @"The first page must have no navigation target");
|
||||
[webview loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"kanban-test://workspace/second"]]];
|
||||
waitUntil(^BOOL { return pages.finished == 2; }, @"Second page must load");
|
||||
check(webview.canGoBack && !webview.canGoForward, @"Native history must retain the first page");
|
||||
check([webview goBack] != nil, @"Native back navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 3; }, @"Native back navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/first"] && !webview.canGoBack && webview.canGoForward,
|
||||
@"Back must restore the first page and retain forward history");
|
||||
check([webview goBack] == nil, @"Back at the first page must leave it in place");
|
||||
check([webview goForward] != nil, @"Native forward navigation must start");
|
||||
waitUntil(^BOOL { return pages.finished == 4; }, @"Native forward navigation must complete");
|
||||
check([webview.URL.path isEqualToString:@"/second"] && webview.canGoBack && !webview.canGoForward,
|
||||
@"Forward must restore the second page");
|
||||
check(webview.allowsBackForwardNavigationGestures, @"Native gestures must remain enabled across page navigation");
|
||||
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
|
||||
styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
|
||||
window.releasedWhenClosed = NO;
|
||||
window.title = @"KanbanGit navigation test";
|
||||
window.contentView = webview;
|
||||
[window orderFront:nil];
|
||||
evaluate(webview, @"document.body.innerHTML = `<input id='duplicate' type='radio'><input id='draft' value='Unsaved project'><input id='upload' type='file'><a id='back' href='#back'>Back</a>`; window.visits = 0; document.querySelector('#back').onclick = event => { event.preventDefault(); if (!document.querySelector('#duplicate').checked || confirm('Discard unsaved changes?')) window.visits++; }; true;");
|
||||
evaluate(webview, @"document.querySelector('#upload').click()");
|
||||
waitUntil(^BOOL { return originalUI.uploaded; }, @"File selection must still reach the original UI delegate");
|
||||
evaluate(webview, @"document.querySelector('#back').click()");
|
||||
check([evaluate(webview, @"window.visits") intValue] == 1, @"Pristine forms must navigate without a confirmation");
|
||||
evaluate(webview, @"document.querySelector('#duplicate').click()");
|
||||
for (NSString *choice in @[@"Cancel", @"OK", @"Cancel", @"OK"]) {
|
||||
__block BOOL finished = NO;
|
||||
[webview evaluateJavaScript:@"document.querySelector('#back').click()" completionHandler:^(id result, NSError *error) {
|
||||
check(error == nil, error.localizedDescription);
|
||||
finished = YES;
|
||||
}];
|
||||
waitUntil(^BOOL { return window.attachedSheet != nil; }, @"Dirty form navigation must show a native confirmation");
|
||||
check(!finished, @"The original click must wait for the user's decision");
|
||||
check(containsText(window.attachedSheet.contentView, @"workspace"), @"The confirmation must identify the requesting website");
|
||||
check(containsText(window.attachedSheet.contentView, @"Discard unsaved changes?"), @"The confirmation must show the application's message");
|
||||
NSButton *button = findButton(window.attachedSheet.contentView, choice);
|
||||
check(button != nil, @"The native confirmation must offer OK and Cancel");
|
||||
[button performClick:nil];
|
||||
waitUntil(^BOOL { return finished && window.attachedSheet == nil; }, @"Dismissal must release the original navigation");
|
||||
check([evaluate(webview, @"document.querySelector('#draft').value") isEqualToString:@"Unsaved project"],
|
||||
@"Cancelling a confirmation must preserve the draft");
|
||||
}
|
||||
navigation.enabled = YES;
|
||||
webview.allowsSwipe = YES;
|
||||
webview.back = YES;
|
||||
webview.deferPermission = YES;
|
||||
TestEvent *stale = event();
|
||||
[navigation handleEvent:stale];
|
||||
TestEvent *ended = event();
|
||||
ended.testPhase = NSEventPhaseEnded;
|
||||
[navigation handleEvent:ended];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(stale.trackCount == 0, @"Late hit tests must not resurrect ended gestures");
|
||||
TestEvent *changedPage = event();
|
||||
[navigation handleEvent:changedPage];
|
||||
webview.testURL = [NSURL URLWithString:@"https://example.com/settings"];
|
||||
webview.permission(@YES, nil);
|
||||
NSCAssert(changedPage.trackCount == 0, @"Page changes must invalidate a pending gesture");
|
||||
puts("PASS: AppKit back/forward, cancelled/partial swipes, system preference, nested scroll ownership, empty history, mouse/vertical/momentum events, stale callbacks");
|
||||
check([evaluate(webview, @"window.visits") intValue] == 3, @"Only accepted visits must run, exactly once each");
|
||||
window.contentView = nil;
|
||||
check(![evaluate(webview, @"confirm('Detached view')") boolValue], @"A detached view must cancel and release JavaScript immediately");
|
||||
[window orderOut:nil];
|
||||
[window close];
|
||||
puts("PASS: WebKit interactive gesture configuration, full-page back/forward, native confirmations and delegate forwarding");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
test(
|
||||
'AppKit gestures preserve single steps, cancellation, system preferences and scroll ownership',
|
||||
'WebKit preserves history and confirms dirty form navigation without losing delegate callbacks',
|
||||
{ skip: process.platform !== 'darwin' },
|
||||
() => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||
@@ -48,9 +48,9 @@ test(
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
result.stderr || result.error?.message,
|
||||
result.stderr || result.error?.message || result.signal,
|
||||
);
|
||||
assert.match(result.stdout, /PASS: AppKit/);
|
||||
assert.match(result.stdout, /PASS: WebKit/);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import test from 'node:test';
|
||||
|
||||
const script = readFileSync(
|
||||
new URL('../ui/workspace-settings.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
function context(platform = 'apple', frame = false, protocol = 'https:') {
|
||||
const calls = [];
|
||||
const window = { dispatchEvent() {} };
|
||||
window.top = frame ? {} : window;
|
||||
if (platform === 'apple') {
|
||||
window.webkit = {
|
||||
messageHandlers: {
|
||||
kanbanSettings: {
|
||||
postMessage: async (body) => {
|
||||
const request = JSON.parse(body);
|
||||
calls.push(request);
|
||||
return JSON.stringify({
|
||||
id: request.id,
|
||||
result: { workspace: 'https://example.test/' },
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
vm.runInNewContext(script, {
|
||||
window,
|
||||
location: { protocol },
|
||||
Event,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
return { window, calls };
|
||||
}
|
||||
test('Apple WebViews expose a versioned request/reply API without Tauri', async () => {
|
||||
const { window, calls } = context('apple');
|
||||
assert.equal(window.kanbanNative.version, 1);
|
||||
assert.equal(
|
||||
(await window.kanbanNative.request('getState')).workspace,
|
||||
'https://example.test/',
|
||||
);
|
||||
assert.deepEqual(calls[0], {
|
||||
version: 1,
|
||||
id: 1,
|
||||
action: 'getState',
|
||||
payload: {},
|
||||
});
|
||||
await assert.rejects(
|
||||
window.kanbanNative.request('shell'),
|
||||
/unsupportedAction/,
|
||||
);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(window.__TAURI_INTERNALS__, undefined);
|
||||
assert.equal(context('apple', true).window.kanbanNative, undefined);
|
||||
assert.equal(
|
||||
context('apple', false, 'http:').window.kanbanNative,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
test('normal browsers receive no native settings API', () => {
|
||||
assert.equal(context('browser').window.kanbanNative, undefined);
|
||||
});
|
||||
test('native errors reject the caller without losing future requests', async () => {
|
||||
const { window } = context();
|
||||
window.webkit.messageHandlers.kanbanSettings.postMessage = async () =>
|
||||
JSON.stringify({ error: 'invalidServer' });
|
||||
await assert.rejects(
|
||||
window.kanbanNative.request('setWorkspace', { server: 'bad' }),
|
||||
/invalidServer/,
|
||||
);
|
||||
});
|
||||
@@ -1,21 +1,8 @@
|
||||
(() => {
|
||||
if (window.top !== window || window.__kanbanNavigation) {
|
||||
if (window.top !== window) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dragging = false;
|
||||
const overlay = () =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
'dialog[open], [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [data-reka-popper-content-wrapper]',
|
||||
),
|
||||
].some(
|
||||
(element) =>
|
||||
element.getAttribute('data-state') !== 'closed' &&
|
||||
element.getClientRects().length > 0 &&
|
||||
getComputedStyle(element).visibility !== 'hidden',
|
||||
);
|
||||
|
||||
const constrainScroll = () => {
|
||||
if (document.documentElement) {
|
||||
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||
@@ -28,55 +15,4 @@
|
||||
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||
once: true,
|
||||
});
|
||||
document.addEventListener('dragstart', () => {
|
||||
dragging = true;
|
||||
});
|
||||
document.addEventListener('dragend', () => {
|
||||
dragging = false;
|
||||
});
|
||||
document.addEventListener('drop', () => {
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
window.__kanbanNavigation = Object.freeze({
|
||||
canSwipe(x, y) {
|
||||
if (dragging || overlay()) {
|
||||
return false;
|
||||
}
|
||||
for (
|
||||
let element = document.elementFromPoint(x, y);
|
||||
element;
|
||||
element = element.parentElement
|
||||
) {
|
||||
if (
|
||||
element.matches(
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [draggable="true"]',
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(element);
|
||||
if (
|
||||
/(auto|scroll)/.test(style.overflowX) &&
|
||||
element.scrollWidth > element.clientWidth
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
style.overscrollBehaviorX === 'contain' ||
|
||||
style.overscrollBehaviorX === 'none'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
step(direction) {
|
||||
// The host checks canGoBack/canGoForward. JS traversal preserves SPA entries
|
||||
// that WebKit's user-initiated history traversal is allowed to skip.
|
||||
if (direction === -1 || direction === 1) {
|
||||
window.history.go(direction);
|
||||
}
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
(() => {
|
||||
if (
|
||||
window !== window.top ||
|
||||
location.protocol !== 'https:' ||
|
||||
window.kanbanNative
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const handler = window.webkit?.messageHandlers?.kanbanSettings;
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
let sequence = 0;
|
||||
const actions = new Set([
|
||||
'getState',
|
||||
'settingsReady',
|
||||
'openSettings',
|
||||
'setWorkspace',
|
||||
'checkForUpdates',
|
||||
'setUpdatePreferences',
|
||||
'installUpdate',
|
||||
]);
|
||||
Object.defineProperty(window, 'kanbanNative', {
|
||||
value: Object.freeze({
|
||||
version: 1,
|
||||
async request(action, payload = {}) {
|
||||
if (!actions.has(action)) {
|
||||
throw new Error('unsupportedAction');
|
||||
}
|
||||
const message = JSON.stringify({
|
||||
version: 1,
|
||||
id: ++sequence,
|
||||
action,
|
||||
payload,
|
||||
});
|
||||
let timer;
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
handler.postMessage(message).then(JSON.parse),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error('bridgeTimeout')),
|
||||
10000,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
window.dispatchEvent(new Event('kanban-native-ready'));
|
||||
})();
|
||||
+7
-2
@@ -15,7 +15,7 @@
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"types:check": "vue-tsc --noEmit"
|
||||
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -48,6 +48,11 @@
|
||||
"dependencies": {
|
||||
"@inertiajs/core": "2.3.18",
|
||||
"@inertiajs/vue3": "^2.3.7",
|
||||
"@tiptap/core": "^3.31.3",
|
||||
"@tiptap/markdown": "^3.31.3",
|
||||
"@tiptap/pm": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
"@tiptap/vue-3": "^3.31.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,7 +62,7 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue": "^3.5.43",
|
||||
"vue-input-otp": "^0.3.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Generated
+645
-138
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,22 @@
|
||||
minimumReleaseAge: 10080
|
||||
# Security patches for GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7 and GHSA-6j4f-fj2g-mc7p.
|
||||
minimumReleaseAgeExclude:
|
||||
- brace-expansion@1.1.21
|
||||
- brace-expansion@2.1.7
|
||||
- brace-expansion@5.0.12
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
'@vue/server-renderer@<3.5.42': ^3.5.42
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
shell-quote@>=1.8.4 <1.11.0: ^1.11.0
|
||||
source-map-js@>=1.0.0 <1.2.2: ^1.2.2
|
||||
audit:
|
||||
ignore:
|
||||
- GHSA-vfj7-8cjw-p6xm
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import './theme.css';
|
||||
@import './ticket-description.css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
.ticket-description {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.ticket-description > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ticket-description h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ticket-description h3,
|
||||
.ticket-description h4,
|
||||
.ticket-description h5,
|
||||
.ticket-description h6 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ticket-description ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ticket-description li > ul,
|
||||
.ticket-description li > ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.ticket-description blockquote {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding-left: 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ticket-description a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.ticket-description code {
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.ticket-description pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-muted);
|
||||
padding: 0.75rem;
|
||||
white-space: pre;
|
||||
}
|
||||
.ticket-description pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.ticket-description hr {
|
||||
border-color: var(--color-border);
|
||||
margin-block: 1rem;
|
||||
}
|
||||
.ticket-description:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import type { DefineComponent } from 'vue';
|
||||
import { createApp, h } from 'vue';
|
||||
import '../css/app.css';
|
||||
import {
|
||||
initializeNativeApp,
|
||||
registerNativeRecovery,
|
||||
} from '@/composables/useNativeApp';
|
||||
import { createAppearance } from '@/composables/useAppearance';
|
||||
import { createLocalization } from '@/lib/localization';
|
||||
|
||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
||||
.use(createLocalization(props.initialPage.props.localization))
|
||||
.use(createAppearance(props.initialPage.props.appearance))
|
||||
.mount(el);
|
||||
registerNativeRecovery();
|
||||
window.addEventListener('kanban-native-ready', () => {
|
||||
void initializeNativeApp();
|
||||
});
|
||||
void initializeNativeApp();
|
||||
},
|
||||
progress: {
|
||||
color: 'var(--primary)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user