Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 | ||
|
|
752ba2cd38 | ||
|
|
1cf7524a7b | ||
|
|
386b25858b | ||
|
|
aa14068c66 | ||
|
|
4afaa79834 | ||
|
|
0662a6970e | ||
|
|
6820e0b25e |
@@ -1,78 +1,18 @@
|
|||||||
name: "Lint and Test APP"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Working Agreements
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Actions\Projects;
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class DeleteProject
|
class DeleteProject
|
||||||
{
|
{
|
||||||
@@ -10,7 +11,12 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
$project->delete();
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->delete();
|
||||||
|
$baseTemplate?->delete();
|
||||||
|
});
|
||||||
|
|
||||||
return $projectName;
|
return $projectName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||||
{
|
{
|
||||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||||
|
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||||
|
|
||||||
$linkedTicket = Ticket::query()
|
$linkedTicket = Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->findOrFail($linkedTicketId);
|
->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()
|
Ticket::query()
|
||||||
->whereKey([$ticket->id, $linkedTicket->id])
|
->whereKey([$ticket->id, $linkedTicket->id])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
@@ -50,31 +53,31 @@ class StoreProjectTicketLink
|
|||||||
|
|
||||||
if ($ticketLink === null) {
|
if ($ticketLink === null) {
|
||||||
$ticketLink = TicketLink::query()->create([
|
$ticketLink = TicketLink::query()->create([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$ticketLink->update([
|
$ticketLink->update([
|
||||||
'source_ticket_id' => $ticket->id,
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
'target_ticket_id' => $linkedTicket->id,
|
'target_ticket_id' => $targetTicket->id,
|
||||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketLink->load('ticketLinkType');
|
$ticketLink->load('ticketLinkType');
|
||||||
|
|
||||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||||
|
|
||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
'ticket_link_type' => $sourceLabel,
|
'ticket_link_type' => $ticketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +85,10 @@ class StoreProjectTicketLink
|
|||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
TicketWorklogEntryType::LinkUpdated,
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
'ticket_link_type' => $targetLabel,
|
'ticket_link_type' => $linkedTicketLabel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ class ProjectTicketLinkController extends ApiController
|
|||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
$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']))
|
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||||
->additional([
|
->additional([
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
'message' => "{$label} link was saved.",
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->response()
|
->response()
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ class ProjectTicketLinkController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
$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])
|
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(
|
public function destroy(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class RequirePasswordChange
|
|||||||
|
|
||||||
if ($request->routeIs([
|
if ($request->routeIs([
|
||||||
'logout',
|
'logout',
|
||||||
|
'native-app.edit',
|
||||||
'password.force-change.edit',
|
'password.force-change.edit',
|
||||||
'user-password.update',
|
'user-password.update',
|
||||||
])) {
|
])) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()?->can('access-admin') ?? false;
|
return $this->user()?->can('admin-project', $this->route('project')) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('project_id', $project->id),
|
fn ($query) => $query->where('project_id', $project->id),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+26
-22
@@ -1451,20 +1451,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.26.1",
|
"version": "v13.34.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab"
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"reference": "e4a1bc52ef551d52e60244bb004256d6861da7ab",
|
"reference": "c829b4982d29344cbcf1d7ad78eb5d2bb8ae66c4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"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",
|
"composer-runtime-api": "^2.2",
|
||||||
"doctrine/inflector": "^2.0.5",
|
"doctrine/inflector": "^2.0.5",
|
||||||
"dragonmantank/cron-expression": "^3.4",
|
"dragonmantank/cron-expression": "^3.4",
|
||||||
@@ -1576,18 +1576,20 @@
|
|||||||
"league/flysystem-sftp-v3": "^3.25.1",
|
"league/flysystem-sftp-v3": "^3.25.1",
|
||||||
"mockery/mockery": "^1.6.10",
|
"mockery/mockery": "^1.6.10",
|
||||||
"opis/json-schema": "^2.4.1",
|
"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",
|
"pda/pheanstalk": "^7.0.0 || ^8.0.0",
|
||||||
"php-http/discovery": "^1.15",
|
"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",
|
"phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
|
||||||
"predis/predis": "^2.3 || ^3.0",
|
"predis/predis": "^2.3 || ^3.0",
|
||||||
"rector/rector": "^2.3",
|
"rector/rector": "2.6.3",
|
||||||
"resend/resend-php": "^1.0",
|
"resend/resend-php": "^1.0",
|
||||||
"symfony/cache": "^7.4.0 || ^8.0.0",
|
"symfony/cache": "^7.4.0 || ^8.0.0",
|
||||||
"symfony/http-client": "^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/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": {
|
"suggest": {
|
||||||
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
|
"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/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/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/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/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",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -1674,7 +1678,7 @@
|
|||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"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",
|
"name": "laravel/mcp",
|
||||||
@@ -2003,16 +2007,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.10.0",
|
"version": "2.10.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2106,7 +2110,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-11T16:06:25+00:00"
|
"time": "2026-09-21T13:07:34+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
@@ -2192,16 +2196,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/flysystem",
|
"name": "league/flysystem",
|
||||||
"version": "3.35.2",
|
"version": "3.36.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/flysystem.git",
|
"url": "https://github.com/thephpleague/flysystem.git",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
|
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
|
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2269,9 +2273,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/thephpleague/flysystem/issues",
|
"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",
|
"name": "league/flysystem-aws-s3-v3",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.2.2",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,5 +25,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@tauri-apps/cli": "2.11.4"
|
||||||
},
|
},
|
||||||
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
|
"releaseNotes": "Manage local workspace settings in the web app on macOS and iOS, with shared macOS update controls and native recovery for older or unavailable servers."
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.2"
|
version = "0.2.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.2.2"
|
version = "0.2.6"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -1,3 +1,74 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
|
||||||
|
build_navigation();
|
||||||
|
}
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"x86_64" => "x86_64",
|
||||||
|
_ => panic!("Unsupported macOS architecture"),
|
||||||
|
};
|
||||||
|
assert!(Command::new("xcrun")
|
||||||
|
.args([
|
||||||
|
"--sdk",
|
||||||
|
"macosx",
|
||||||
|
"clang",
|
||||||
|
"-fobjc-arc",
|
||||||
|
"-fblocks",
|
||||||
|
"-Wall",
|
||||||
|
"-Werror",
|
||||||
|
"-mmacosx-version-min=14.0",
|
||||||
|
"-arch",
|
||||||
|
arch,
|
||||||
|
"-c",
|
||||||
|
"src/navigation.m",
|
||||||
|
"-o"
|
||||||
|
])
|
||||||
|
.arg(&object)
|
||||||
|
.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());
|
||||||
|
println!("cargo:rustc-link-search=native={}", output.display());
|
||||||
|
println!("cargo:rustc-link-lib=static=kanban_navigation");
|
||||||
|
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||||
|
println!("cargo:rustc-link-lib=framework=WebKit");
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,3 +65,32 @@ struct WorkspaceConnection {
|
|||||||
return true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
private let connectButton = UIButton(type: .system)
|
private let connectButton = UIButton(type: .system)
|
||||||
private let resumeButton = UIButton(type: .system)
|
private let resumeButton = UIButton(type: .system)
|
||||||
private let toolbar = UIToolbar()
|
private let toolbar = UIToolbar()
|
||||||
private var historyObservation: NSKeyValueObservation?
|
private var settingsAvailable = false
|
||||||
private var backButton: UIBarButtonItem!
|
private var toolbarHeight: NSLayoutConstraint!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@@ -94,7 +94,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
launcher.accessibilityIdentifier = "serverSettings"
|
launcher.accessibilityIdentifier = "serverSettings"
|
||||||
launcher.keyboardDismissMode = .interactive
|
launcher.keyboardDismissMode = .interactive
|
||||||
launcher.alwaysBounceVertical = true
|
launcher.bounces = false
|
||||||
view.addSubview(launcher)
|
view.addSubview(launcher)
|
||||||
launcher.translatesAutoresizingMaskIntoConstraints = false
|
launcher.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@@ -164,12 +164,12 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
toolbar.isHidden = true
|
toolbar.isHidden = true
|
||||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(toolbar)
|
view.addSubview(toolbar)
|
||||||
backButton = UIBarButtonItem(title: text("back"), style: .plain, target: self, action: #selector(goBack))
|
|
||||||
backButton.accessibilityIdentifier = "workspaceBack"
|
|
||||||
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
let settings = UIBarButtonItem(title: text("serverSettings"), style: .plain, target: self, action: #selector(showSettings))
|
||||||
settings.accessibilityIdentifier = "showServerSettings"
|
settings.accessibilityIdentifier = "showServerSettings"
|
||||||
toolbar.items = [backButton, UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
toolbar.items = [UIBarButtonItem(systemItem: .flexibleSpace), settings]
|
||||||
|
toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: 44)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
|
toolbarHeight,
|
||||||
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
toolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||||
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
toolbar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||||
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
toolbar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
@@ -203,19 +203,27 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
guard let data = try? JSONEncoder().encode(selected) else { status.text = text("storageError"); return }
|
||||||
defaults.set(data, forKey: serverKey)
|
defaults.set(data, forKey: serverKey)
|
||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
historyObservation = nil
|
|
||||||
workspace?.stopLoading()
|
workspace?.stopLoading()
|
||||||
workspace?.navigationDelegate = nil
|
workspace?.navigationDelegate = nil
|
||||||
|
workspace?.configuration.userContentController.removeScriptMessageHandler(forName: "kanbanSettings", contentWorld: .page)
|
||||||
workspace?.removeFromSuperview()
|
workspace?.removeFromSuperview()
|
||||||
connection = WorkspaceConnection()
|
connection = WorkspaceConnection()
|
||||||
server = selected
|
server = selected
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
configuration.websiteDataStore = WKWebsiteDataStore(forIdentifier: selected.profile)
|
||||||
// Remote content receives a plain WKWebView, with no native message handlers or Tauri bridge.
|
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))
|
||||||
|
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)
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webview.navigationDelegate = self
|
webview.navigationDelegate = self
|
||||||
webview.uiDelegate = self
|
webview.uiDelegate = self
|
||||||
webview.allowsBackForwardNavigationGestures = true
|
webview.allowsBackForwardNavigationGestures = true
|
||||||
|
webview.scrollView.bounces = false
|
||||||
webview.accessibilityIdentifier = "workspace"
|
webview.accessibilityIdentifier = "workspace"
|
||||||
webview.translatesAutoresizingMaskIntoConstraints = false
|
webview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
webview.isHidden = true
|
webview.isHidden = true
|
||||||
@@ -227,9 +235,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
webview.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||||
])
|
])
|
||||||
workspace = webview
|
workspace = webview
|
||||||
historyObservation = webview.observe(\.canGoBack, options: [.initial, .new]) { [weak self] view, _ in
|
|
||||||
self?.backButton.isEnabled = view.canGoBack
|
|
||||||
}
|
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
address.text = url.absoluteString
|
address.text = url.absoluteString
|
||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
@@ -273,12 +278,47 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
loading.stopAnimating()
|
loading.stopAnimating()
|
||||||
view.endEditing(true)
|
view.endEditing(true)
|
||||||
launcher.isHidden = true
|
launcher.isHidden = true
|
||||||
toolbar.isHidden = false
|
toolbar.isHidden = settingsAvailable
|
||||||
workspace?.isHidden = false
|
workspace?.isHidden = false
|
||||||
backButton.isEnabled = workspace?.canGoBack == true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func goBack() { if workspace?.canGoBack == true { workspace?.goBack() } }
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
guard webView === workspace, let url = action.request.url else { decisionHandler(.cancel); return }
|
||||||
@@ -306,6 +346,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
|
|
||||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
guard webView === workspace else { return }
|
guard webView === workspace else { return }
|
||||||
|
setSettingsAvailable(false)
|
||||||
pendingNavigation = navigation
|
pendingNavigation = navigation
|
||||||
beginNavigation()
|
beginNavigation()
|
||||||
}
|
}
|
||||||
@@ -320,7 +361,6 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
connectButton.setTitle(text("openWorkspace"), for: .normal)
|
||||||
status.text = text("connected")
|
status.text = text("connected")
|
||||||
resumeButton.isHidden = false
|
resumeButton.isHidden = false
|
||||||
backButton.isEnabled = webView.canGoBack
|
|
||||||
if firstLoad { showWorkspace() }
|
if firstLoad { showWorkspace() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,3 +386,13 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
showSettings()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,41 +95,130 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testBackButtonTracksSameDocumentHistory() async throws {
|
func testWebKitOwnsInteractiveGesturesAndKeepsScrollBounds() async throws {
|
||||||
let name = "KAN55-history-\(UUID())"
|
let name = "KAN71-history-\(UUID())"
|
||||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||||
defer { defaults.removePersistentDomain(forName: name) }
|
defer { defaults.removePersistentDomain(forName: name) }
|
||||||
let controller = WorkspaceViewController(defaults: defaults)
|
let controller = WorkspaceViewController(defaults: defaults)
|
||||||
controller.loadViewIfNeeded()
|
controller.loadViewIfNeeded()
|
||||||
let url = try XCTUnwrap(WorkspaceServer.normalize("https://example.invalid/"))
|
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||||
controller.open(url)
|
let url = URL(string: "https://example.invalid/dashboard")!
|
||||||
|
controller.open(URL(string: "https://example.invalid/")!)
|
||||||
let webview = try XCTUnwrap(controller.workspace)
|
let webview = try XCTUnwrap(controller.workspace)
|
||||||
webview.stopLoading()
|
webview.stopLoading()
|
||||||
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: "<html><body><input id='draft' value='unsaved'></body></html>")
|
webview.frame = controller.view.bounds
|
||||||
var loaded = false
|
webview.loadSimulatedRequest(URLRequest(url: url), responseHTML: """
|
||||||
for _ in 0..<50 {
|
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
if let value = try? await webview.evaluateJavaScript("document.getElementById('draft')?.value"),
|
<body style="margin:0"><p>Workspace</p><input id="draft" value="unsaved"></body></html>
|
||||||
value as? String == "unsaved" { loaded = true; break }
|
""")
|
||||||
try await Task.sleep(for: .milliseconds(100))
|
try await waitFor(webview, "document.querySelector('#draft')?.value === 'unsaved'")
|
||||||
}
|
XCTAssertFalse(webview.canGoBack)
|
||||||
XCTAssertTrue(loaded)
|
XCTAssertNil(webview.goBack())
|
||||||
_ = try await webview.evaluateJavaScript("history.pushState({}, '', '#ticket')")
|
XCTAssertNil(webview.goForward())
|
||||||
let toolbar = try XCTUnwrap(controller.view.subviews.compactMap { $0 as? UIToolbar }.first)
|
try await waitFor(webview, "location.pathname === '/dashboard'")
|
||||||
for _ in 0..<20 {
|
XCTAssertTrue(webview.allowsBackForwardNavigationGestures)
|
||||||
if toolbar.items?.first?.isEnabled == true { break }
|
XCTAssertFalse(webview.scrollView.bounces)
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
XCTAssertFalse(controller.responds(to: NSSelectorFromString("navigateGesture:")))
|
||||||
}
|
let customNavigation = try await webview.evaluateJavaScript("typeof window.__kanbanNavigation")
|
||||||
XCTAssertTrue(webview.canGoBack)
|
XCTAssertEqual(customNavigation as? String, "undefined")
|
||||||
XCTAssertTrue(toolbar.items?.first?.isEnabled == true)
|
let draft = try await webview.evaluateJavaScript("document.querySelector('#draft').value")
|
||||||
webview.goBack()
|
|
||||||
var returned = false
|
|
||||||
for _ in 0..<20 {
|
|
||||||
if let hash = try? await webview.evaluateJavaScript("location.hash"), hash as? String == "" { returned = true; break }
|
|
||||||
try await Task.sleep(for: .milliseconds(50))
|
|
||||||
}
|
|
||||||
XCTAssertTrue(returned)
|
|
||||||
let draft = try await webview.evaluateJavaScript("document.getElementById('draft').value")
|
|
||||||
XCTAssertEqual(draft as? String, "unsaved")
|
XCTAssertEqual(draft as? String, "unsaved")
|
||||||
|
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" })
|
||||||
|
let scroll = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorY")
|
||||||
|
XCTAssertEqual(scroll as? String, "none")
|
||||||
|
let horizontal = try await webview.evaluateJavaScript("getComputedStyle(document.documentElement).overscrollBehaviorX")
|
||||||
|
XCTAssertEqual(horizontal as? String, "auto")
|
||||||
|
controller.open(URL(string: "https://other.invalid/")!)
|
||||||
|
XCTAssertTrue(try XCTUnwrap(controller.workspace).allowsBackForwardNavigationGestures,
|
||||||
|
"Changing workspaces must retain native gesture 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
|
||||||
|
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||||
|
for _ in 0..<100 {
|
||||||
|
if let result = try? await webview.evaluateJavaScript(expression), result as? Bool == true { return }
|
||||||
|
try await Task.sleep(for: .milliseconds(50))
|
||||||
|
}
|
||||||
|
XCTFail("WebView condition timed out: \(expression)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testServerChangesNeverReusePreviousProfile() throws {
|
func testServerChangesNeverReusePreviousProfile() throws {
|
||||||
@@ -189,7 +278,7 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
controller.open(url)
|
controller.open(url)
|
||||||
let server = try XCTUnwrap(controller.server)
|
let server = try XCTUnwrap(controller.server)
|
||||||
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
XCTAssertEqual(controller.workspace?.configuration.websiteDataStore.identifier, server.profile)
|
||||||
XCTAssertTrue(controller.workspace?.configuration.userContentController.userScripts.isEmpty == true)
|
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 2)
|
||||||
let restored = WorkspaceViewController(defaults: defaults)
|
let restored = WorkspaceViewController(defaults: defaults)
|
||||||
restored.loadViewIfNeeded()
|
restored.loadViewIfNeeded()
|
||||||
XCTAssertEqual(restored.server, server)
|
XCTAssertEqual(restored.server, server)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
009F8FEC2765B2F19DC12CA8 /* WorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */; };
|
||||||
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */ = {isa = PBXBuildFile; fileRef = 7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */; };
|
||||||
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
0FAB3CC7BB466BFF871C0C30 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 31630170C358EA8657E34854 /* Assets.xcassets */; };
|
||||||
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
205D0CBA33F7D3C59AAD5300 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63685B1189795CAEA85F7AB7 /* QuartzCore.framework */; };
|
||||||
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
333E7761EA87AE29CE04F1AC /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = F46963153D20DB21D97843E9 /* main.mm */; };
|
||||||
@@ -18,6 +19,8 @@
|
|||||||
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
7245AD23F39C4620BAA3AA92 /* WorkspaceUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED199829CC1B1598F58EDC9B /* WorkspaceUITests.swift */; };
|
||||||
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
73954B7AADE8C37663BEF288 /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CA65E67046326EA008175045 /* Metal.framework */; };
|
||||||
98D5B23C9BE2F62C23DF397A /* WorkspacePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 554AB059B4228085924F4959 /* WorkspacePolicy.swift */; };
|
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 */; };
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C8C02BC0E757897278EB954A /* Localizable.strings */; };
|
||||||
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
B939982700E800053A9C7663 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE3B71153677F722CAC6DCF7 /* Security.framework */; };
|
||||||
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
C362C102D4950A81D89BB8C8 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */; };
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 2AB6F9398EA3CB62A68287D2 /* InfoPlist.strings */; };
|
||||||
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
CD64D99EBD87E6FFC0C9001C /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D581BB90601D7A89F8F188E2 /* MetalKit.framework */; };
|
||||||
D8772A5E508092D6F831058A /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 91991DFDB0C064B06DF854ED /* libapp.a */; };
|
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 */; };
|
EB37EAF992C1A156795CAEC0 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -43,17 +47,21 @@
|
|||||||
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
05A753935B6CB0316DA56D96 /* desktop.rs */ = {isa = PBXFileReference; path = desktop.rs; sourceTree = "<group>"; };
|
||||||
08DF9B2F499E1EB89EB39BC5 /* WorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTests.swift; 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; };
|
0CF713E7BFE3F6B5BAA3550E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
|
||||||
|
1CDE5C303A96423E2E6A194D /* settings.rs */ = {isa = PBXFileReference; path = settings.rs; sourceTree = "<group>"; };
|
||||||
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
23960010246A2A72E9940D3F /* kanbangit-native_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "kanbangit-native_iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
2B96972F58CC158AB3DFF94A /* lib.rs */ = {isa = PBXFileReference; path = lib.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>"; };
|
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>"; };
|
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>"; };
|
4C76059D31C50B198779A5F7 /* en.lproj */ = {isa = PBXFileReference; path = en.lproj; sourceTree = "<group>"; };
|
||||||
554AB059B4228085924F4959 /* WorkspacePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePolicy.swift; 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>"; };
|
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; };
|
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>"; };
|
6610D376CF901AC1733D394E /* updates.rs */ = {isa = PBXFileReference; path = updates.rs; sourceTree = "<group>"; };
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
7440C7F0426E6B3977DD1D94 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "workspace-navigation.js"; sourceTree = "<group>"; };
|
||||||
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
8243D0C3821DAC731FECF105 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = navigation.m; sourceTree = "<group>"; };
|
||||||
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
91991DFDB0C064B06DF854ED /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
|
||||||
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
92EBA86D28BBFD1C822CC6F6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
|
||||||
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
946BA5CC3FD90AD90D101A36 /* kanbangit-native_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "kanbangit-native_iOS.entitlements"; sourceTree = "<group>"; };
|
||||||
@@ -61,6 +69,7 @@
|
|||||||
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
9CAD0AE17A7966154CA29001 /* WorkspaceUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A372EBDD353022BDB3E78972 /* WorkspaceTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WorkspaceTests.xctest; 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>"; };
|
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; };
|
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>"; };
|
CAF08AD60D2F29A879CDAEBB /* ios.rs */ = {isa = PBXFileReference; path = ios.rs; sourceTree = "<group>"; };
|
||||||
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
D13B8D854BEB7757AB95231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
@@ -110,6 +119,7 @@
|
|||||||
8606F3EE5A736385061692EF /* Sources */,
|
8606F3EE5A736385061692EF /* Sources */,
|
||||||
335B1262A9019C70C7F887A5 /* src */,
|
335B1262A9019C70C7F887A5 /* src */,
|
||||||
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
140A7DB7E2461E64B1FBFA70 /* Tests */,
|
||||||
|
709BE6DF191B953724A55555 /* ui */,
|
||||||
3C9F31BB065F210267ECA390 /* UITests */,
|
3C9F31BB065F210267ECA390 /* UITests */,
|
||||||
3028C3786B437115B6486F1C /* Frameworks */,
|
3028C3786B437115B6486F1C /* Frameworks */,
|
||||||
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
59D2DE9FD94E6D2D64CB0117 /* Products */,
|
||||||
@@ -149,7 +159,10 @@
|
|||||||
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
AEBAD9ABE758897C50BC1F2E /* locale.rs */,
|
||||||
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
7440C7F0426E6B3977DD1D94 /* main.rs */,
|
||||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||||
|
8C0F963909020429B3D257D5 /* navigation.m */,
|
||||||
E8BA104C81071623E1FF367D /* server.rs */,
|
E8BA104C81071623E1FF367D /* server.rs */,
|
||||||
|
5F196E34D1B9E14FBD7191F4 /* settings.m */,
|
||||||
|
1CDE5C303A96423E2E6A194D /* settings.rs */,
|
||||||
6610D376CF901AC1733D394E /* updates.rs */,
|
6610D376CF901AC1733D394E /* updates.rs */,
|
||||||
);
|
);
|
||||||
name = src;
|
name = src;
|
||||||
@@ -183,6 +196,16 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
709BE6DF191B953724A55555 /* ui */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||||
|
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||||
|
);
|
||||||
|
name = ui;
|
||||||
|
path = ../../../ui;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8606F3EE5A736385061692EF /* Sources */ = {
|
8606F3EE5A736385061692EF /* Sources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -251,6 +274,7 @@
|
|||||||
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
buildConfigurationList = 26F675D59EF454AE0D263F35 /* Build configuration list for PBXNativeTarget "WorkspaceTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
2A0467365B852D44D1A9FD5B /* Sources */,
|
2A0467365B852D44D1A9FD5B /* Sources */,
|
||||||
|
DC3295DE0893151786FEA37B /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -317,6 +341,17 @@
|
|||||||
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
C53E7EC983C008DF98244C74 /* InfoPlist.strings in Resources */,
|
||||||
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
34C189B72A1B9EBE33D700D1 /* LaunchScreen.storyboard in Resources */,
|
||||||
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
A46344B6DF1B3A0F2798B751 /* Localizable.strings in Resources */,
|
||||||
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||||
|
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
DC3295DE0893151786FEA37B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||||
|
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -416,7 +451,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -432,7 +467,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=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)";
|
"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.1;
|
MARKETING_VERSION = 0.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = KanbanGit;
|
PRODUCT_NAME = KanbanGit;
|
||||||
@@ -593,7 +628,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "kanbangit-native_iOS/kanbangit-native_iOS.entitlements";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 6;
|
||||||
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
DEVELOPMENT_TEAM = ZZX2KZGTHU;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -609,7 +644,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=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)";
|
"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.1;
|
MARKETING_VERSION = 0.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = de.kanbangit.ios;
|
||||||
PRODUCT_MODULE_NAME = KanbanGitNative;
|
PRODUCT_MODULE_NAME = KanbanGitNative;
|
||||||
PRODUCT_NAME = KanbanGit;
|
PRODUCT_NAME = KanbanGit;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ settingGroups:
|
|||||||
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
DEVELOPMENT_TEAM: ZZX2KZGTHU
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
PRODUCT_BUNDLE_IDENTIFIER: de.kanbangit.ios
|
||||||
MARKETING_VERSION: "0.2.1"
|
MARKETING_VERSION: "0.2.6"
|
||||||
CURRENT_PROJECT_VERSION: "1"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_IDENTITY: Apple Development
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
@@ -42,6 +42,8 @@ targets:
|
|||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: Sources
|
- path: Sources
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
|
- path: ../../../ui/workspace-settings.js
|
||||||
- path: Assets.xcassets
|
- path: Assets.xcassets
|
||||||
- path: kanbangit-native_iOS
|
- path: kanbangit-native_iOS
|
||||||
- path: LaunchScreen.storyboard
|
- path: LaunchScreen.storyboard
|
||||||
@@ -114,6 +116,8 @@ targets:
|
|||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
|
- path: ../../../ui/workspace-navigation.js
|
||||||
|
- path: ../../../ui/workspace-settings.js
|
||||||
- path: Tests
|
- path: Tests
|
||||||
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
- path: Sources/kanbangit-native/WorkspacePolicy.swift
|
||||||
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
- path: Sources/kanbangit-native/WorkspaceViewController.swift
|
||||||
|
|||||||
+131
-35
@@ -17,6 +17,15 @@ struct Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn begin_navigation(&mut self) -> u64 {
|
||||||
self.revision += 1;
|
self.revision += 1;
|
||||||
self.phase = "connecting";
|
self.phase = "connecting";
|
||||||
@@ -177,6 +186,7 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
.center()
|
.center()
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.data_store_identifier(server.profile)
|
.data_store_identifier(server.profile)
|
||||||
|
.initialization_script(include_str!("../../ui/workspace-navigation.js"))
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_server_navigation(&server_url, destination) {
|
if !allows_server_navigation(&server_url, destination) {
|
||||||
open_external(destination);
|
open_external(destination);
|
||||||
@@ -227,30 +237,84 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
.map_err(|_| "connectionError")?;
|
.map_err(|_| "connectionError")?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
window
|
||||||
|
.with_webview(|webview| unsafe {
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn install_workspace_navigation(webview: *mut std::ffi::c_void);
|
||||||
|
}
|
||||||
|
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();
|
let handle = app.clone();
|
||||||
window.on_window_event(move |event| {
|
window.on_window_event(move |event| {
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
let state = handle.state::<Mutex<Connection>>();
|
let current = handle
|
||||||
let current = {
|
.state::<Mutex<Connection>>()
|
||||||
let mut state = state.lock().unwrap();
|
.lock()
|
||||||
if state.label.as_deref() == Some(&label) {
|
.unwrap()
|
||||||
state.label = None;
|
.label
|
||||||
state.ready = false;
|
.as_deref()
|
||||||
state.revision += 1;
|
== Some(&label);
|
||||||
state.phase = "idle";
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if current {
|
if current {
|
||||||
show_launcher(&handle);
|
api.prevent_close();
|
||||||
|
handle.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Ok(())
|
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) {
|
fn handle_launcher_action(app: &AppHandle, destination: &Url) {
|
||||||
let result = match destination.path() {
|
let result = match destination.path() {
|
||||||
"/action/updates" => {
|
"/action/updates" => {
|
||||||
@@ -308,6 +372,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
Ok(None) => (None, "idle"),
|
Ok(None) => (None, "idle"),
|
||||||
Err(_) => (None, "storageError"),
|
Err(_) => (None, "storageError"),
|
||||||
};
|
};
|
||||||
|
let startup_server = server.clone();
|
||||||
app.manage(Mutex::new(Connection {
|
app.manage(Mutex::new(Connection {
|
||||||
server,
|
server,
|
||||||
phase,
|
phase,
|
||||||
@@ -392,6 +457,7 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
.title(en::APP_NAME)
|
.title(en::APP_NAME)
|
||||||
.inner_size(560.0, 720.0)
|
.inner_size(560.0, 720.0)
|
||||||
.min_inner_size(375.0, 640.0)
|
.min_inner_size(375.0, 640.0)
|
||||||
|
.visible(false)
|
||||||
.center()
|
.center()
|
||||||
.on_navigation(move |destination| {
|
.on_navigation(move |destination| {
|
||||||
if !allows_launcher_navigation(destination) {
|
if !allows_launcher_navigation(destination) {
|
||||||
@@ -414,25 +480,15 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
update_launcher(&app);
|
update_launcher(&app);
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
crate::updates::refresh(&app);
|
crate::updates::refresh(&app);
|
||||||
std::thread::spawn(move || {
|
let needs_setup = app
|
||||||
let handle = app.clone();
|
.state::<Mutex<Connection>>()
|
||||||
let _ = app.run_on_main_thread(move || {
|
.lock()
|
||||||
let server = handle
|
.unwrap()
|
||||||
.state::<Mutex<Connection>>()
|
.server
|
||||||
.lock()
|
.is_none();
|
||||||
.unwrap()
|
if needs_setup {
|
||||||
.server
|
show_launcher(&app);
|
||||||
.clone();
|
}
|
||||||
if let Some(server) = server {
|
|
||||||
if let Err(error) = open_workspace(&handle, server.url.as_str())
|
|
||||||
{
|
|
||||||
handle.state::<Mutex<Connection>>().lock().unwrap().phase =
|
|
||||||
error;
|
|
||||||
show_launcher(&handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -440,9 +496,25 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
launcher.on_window_event(move |event| {
|
launcher.on_window_event(move |event| {
|
||||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
let _ = window.hide();
|
let has_workspace = window
|
||||||
|
.app_handle()
|
||||||
|
.state::<Mutex<Connection>>()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.ready;
|
||||||
|
if has_workspace {
|
||||||
|
let _ = window.hide();
|
||||||
|
} else {
|
||||||
|
window.app_handle().exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if let Some(server) = startup_server {
|
||||||
|
if let Err(error) = open_workspace(app.handle(), server.url.as_str()) {
|
||||||
|
app.state::<Mutex<Connection>>().lock().unwrap().phase = error;
|
||||||
|
show_launcher(app.handle());
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -451,6 +523,30 @@ pub fn builder() -> tauri::Builder<tauri::Wry> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn about_shows_app_version_followed_by_the_actual_build_number() {
|
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() {
|
pub fn run() {
|
||||||
builder().run(context()).expect("Could not start KanbanGit");
|
builder().run(context()).expect("Could not start KanbanGit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#import <WebKit/WebKit.h>
|
||||||
|
|
||||||
|
void install_workspace_navigation(void *pointer) {
|
||||||
|
WKWebView *webview = (__bridge WKWebView *)pointer;
|
||||||
|
webview.allowsBackForwardNavigationGestures = YES;
|
||||||
|
}
|
||||||
@@ -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 {
|
if manual {
|
||||||
show(app);
|
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
|
let begin = app
|
||||||
.state::<Mutex<State>>()
|
.state::<Mutex<State>>()
|
||||||
.lock()
|
.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")?])?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
pub fn install(app: &AppHandle, confirmed_version: Option<&str>) {
|
||||||
let update = {
|
let update = {
|
||||||
let state = app.state::<Mutex<State>>();
|
let state = app.state::<Mutex<State>>();
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock().unwrap();
|
||||||
@@ -400,24 +406,49 @@ fn action(app: &AppHandle, url: &Url) {
|
|||||||
fail(app, "preferencesError");
|
fail(app, "preferencesError");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let saved = (|| -> Result<(), Box<dyn std::error::Error>> {
|
if let Err(error) =
|
||||||
let directory = app.path().app_config_dir()?;
|
save_preferences(app, preferences.automatic, preferences.interval_hours)
|
||||||
fs::create_dir_all(&directory)?;
|
{
|
||||||
let pending = directory.join("update-preferences.pending");
|
fail(app, error);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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<()> {
|
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
|
||||||
app.plugin(
|
app.plugin(
|
||||||
tauri_plugin_updater::Builder::new()
|
tauri_plugin_updater::Builder::new()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.2.2",
|
"version": "0.2.6",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "8",
|
"bundleVersion": "11",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
@@ -40,6 +40,9 @@
|
|||||||
"y": 170
|
"y": 170
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"versionCode": 2004
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "2"
|
"bundleVersion": "6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,27 @@ fn main() {
|
|||||||
};
|
};
|
||||||
use tauri::{webview::PageLoadEvent, Manager};
|
use tauri::{webview::PageLoadEvent, Manager};
|
||||||
|
|
||||||
|
let startup = std::env::var("KANBANGIT_SMOKE_STARTUP").unwrap_or_default();
|
||||||
|
let online = startup == "online";
|
||||||
|
let needs_setup = matches!(startup.as_str(), "missing" | "invalid");
|
||||||
let passed = Arc::new(AtomicBool::new(false));
|
let passed = Arc::new(AtomicBool::new(false));
|
||||||
let stage = Arc::new(AtomicUsize::new(0));
|
let stage = Arc::new(AtomicUsize::new(0));
|
||||||
let page_stage = stage.clone();
|
let page_stage = stage.clone();
|
||||||
let result_passed = passed.clone();
|
let result_passed = passed.clone();
|
||||||
|
let setup_passed = passed.clone();
|
||||||
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
let plugin = tauri::plugin::Builder::<tauri::Wry>::new("macos-smoke")
|
||||||
.setup(|app, _| {
|
.setup(move |app, _| {
|
||||||
let directory = app.path().app_config_dir()?;
|
let directory = app.path().app_config_dir()?;
|
||||||
std::fs::create_dir_all(&directory)?;
|
std::fs::create_dir_all(&directory)?;
|
||||||
std::fs::write(directory.join("active-server"), "https://127.0.0.1:9/\n0123456789abcdef0123456789abcdef")?;
|
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")?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let server = if online { "https://kanbangit.de/" } else { "https://127.0.0.1:9/" };
|
||||||
|
std::fs::write(directory.join("active-server"), format!("{server}\n0123456789abcdef0123456789abcdef"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.js_init_script(r#"
|
.js_init_script(r#"
|
||||||
@@ -27,7 +39,7 @@ fn main() {
|
|||||||
}).observe(document, { subtree: true, attributes: true, childList: true });
|
}).observe(document, { subtree: true, attributes: true, childList: true });
|
||||||
}
|
}
|
||||||
"#)
|
"#)
|
||||||
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/__native_smoke_result?passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
.js_init_script("window.addEventListener('error', event => { location.href = 'tauri://localhost/?smoke=result&passed=false&error=' + encodeURIComponent(event.message || event.target.src); }, true);")
|
||||||
.on_page_load(move |webview, payload| {
|
.on_page_load(move |webview, payload| {
|
||||||
|
|
||||||
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
eprintln!("Smoke load: {} {:?} {}", webview.label(), payload.event(), payload.url());
|
||||||
@@ -35,6 +47,24 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if webview.label() == "launcher" {
|
if webview.label() == "launcher" {
|
||||||
|
if needs_setup {
|
||||||
|
let app = webview.app_handle().clone();
|
||||||
|
let passed = setup_passed.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
let handle = app.clone();
|
||||||
|
app.run_on_main_thread(move || {
|
||||||
|
let launcher = handle.get_webview_window("launcher").unwrap();
|
||||||
|
assert!(launcher.is_visible().unwrap(), "First launch and invalid configuration must show setup");
|
||||||
|
assert!(!handle.webview_windows().keys().any(|label| label.starts_with("workspace-")));
|
||||||
|
passed.store(true, Ordering::SeqCst);
|
||||||
|
launcher.close().unwrap();
|
||||||
|
}).unwrap();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(!webview.app_handle().get_webview_window("launcher").unwrap().is_visible().unwrap(), "Saved startup must not show the native launcher window");
|
||||||
|
if online { return; }
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
(async () => {
|
(async () => {
|
||||||
const wait = async predicate => {
|
const wait = async predicate => {
|
||||||
@@ -62,6 +92,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
input.value = 'https://127.0.0.1:9/';
|
input.value = 'https://127.0.0.1:9/';
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
|
await wait(() => status.dataset.state === 'connecting');
|
||||||
await wait(() => status.dataset.state === 'connectionError');
|
await wait(() => status.dataset.state === 'connectionError');
|
||||||
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
if (input.value !== 'https://127.0.0.1:9/' || document.querySelector('.open-workspace').disabled) throw new Error('Offline recovery failed');
|
||||||
input.value = 'https://kanbangit.de/';
|
input.value = 'https://kanbangit.de/';
|
||||||
@@ -72,7 +103,7 @@ fn main() {
|
|||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
document.querySelector('#confirm-switch').click();
|
document.querySelector('#confirm-switch').click();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
location.href = 'tauri://localhost/__native_smoke_result?passed=false&reason=' + encodeURIComponent(error.message);
|
location.href = 'tauri://localhost/?smoke=result&passed=false&reason=' + encodeURIComponent(error.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
@@ -88,16 +119,34 @@ fn main() {
|
|||||||
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
const rendered = document.querySelector('#app')?.innerText.trim().length > 100;
|
||||||
let denied = true;
|
let denied = true;
|
||||||
for (const cmd of ['plugin:path|resolve_directory', 'plugin:window|get_all_windows']) {
|
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'); }
|
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');
|
const input = document.createElement('input');
|
||||||
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
input.id = 'kan52-unsaved'; input.value = 'unsaved input'; document.body.append(input);
|
||||||
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
document.cookie = 'kan52_fixture=isolated; Path=/; Secure; SameSite=Lax';
|
||||||
localStorage.setItem('kan52_fixture', 'isolated');
|
localStorage.setItem('kan52_fixture', 'isolated');
|
||||||
|
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;
|
const ok = rendered && denied && typeof window.__TAURI__ === 'undefined' && location.pathname === '/login' && history.length === 1;
|
||||||
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
location.href = '/__native_smoke_result?stage=loaded&passed=' + ok;
|
||||||
})();
|
})().catch(error => { location.href = '/__native_smoke_result?passed=false&reason=' + encodeURIComponent(String(error)); });
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
} else {
|
} else {
|
||||||
webview.eval(r#"
|
webview.eval(r#"
|
||||||
@@ -110,7 +159,7 @@ fn main() {
|
|||||||
.on_navigation(move |webview, url| {
|
.on_navigation(move |webview, url| {
|
||||||
|
|
||||||
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
eprintln!("Smoke navigation: {} {}", webview.label(), url);
|
||||||
if url.path() != "/__native_smoke_result" {
|
if url.path() != "/__native_smoke_result" && !url.query_pairs().any(|(key, value)| key == "smoke" && value == "result") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
let success = url.query_pairs().any(|(key, value)| key == "passed" && value == "true");
|
||||||
@@ -129,17 +178,25 @@ fn main() {
|
|||||||
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
location.href = '/__native_smoke_result?passed=false&stage=preservation';
|
||||||
}
|
}
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
let launcher = app.get_webview_window("launcher").unwrap();
|
window.eval(r#"
|
||||||
launcher.show().unwrap();
|
window.kanbanNative.request('setWorkspace', {server:'https://kanbangit.de/login/'}).catch(error => {
|
||||||
launcher.eval(r#"
|
location.href = '/__native_smoke_result?passed=false&stage=settings-switch&reason=' + encodeURIComponent(error.message);
|
||||||
document.querySelector('#server-url').value = 'https://kanbangit.de/login/';
|
});
|
||||||
document.querySelector('#server-form').requestSubmit();
|
|
||||||
document.querySelector('#confirm-switch').click();
|
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result_passed.store(success, Ordering::SeqCst);
|
result_passed.store(success, Ordering::SeqCst);
|
||||||
webview.app_handle().exit(if success { 0 } else { 1 });
|
if success {
|
||||||
|
let app = webview.app_handle().clone();
|
||||||
|
let label = webview.label().to_string();
|
||||||
|
let handle = app.clone();
|
||||||
|
app.run_on_main_thread(move || {
|
||||||
|
assert!(!handle.get_webview_window("launcher").unwrap().is_visible().unwrap());
|
||||||
|
handle.get_webview_window(&label).unwrap().close().unwrap();
|
||||||
|
}).unwrap();
|
||||||
|
} else {
|
||||||
|
webview.app_handle().exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
})
|
})
|
||||||
@@ -150,6 +207,25 @@ fn main() {
|
|||||||
.plugin(plugin)
|
.plugin(plugin)
|
||||||
.build(context)
|
.build(context)
|
||||||
.expect("The macOS application must start");
|
.expect("The macOS application must start");
|
||||||
|
let visibility_handle = app.handle().clone();
|
||||||
|
let visibility_stage = stage.clone();
|
||||||
|
let visibility_check = std::thread::spawn(move || {
|
||||||
|
if needs_setup {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for _ in 0..30 {
|
||||||
|
if visibility_stage.load(Ordering::SeqCst) > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(launcher) = visibility_handle.get_webview_window("launcher") {
|
||||||
|
assert!(
|
||||||
|
!launcher.is_visible().unwrap(),
|
||||||
|
"Launcher flashed while the workspace was loading"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
});
|
||||||
let config_dir = app.path().app_config_dir().unwrap();
|
let config_dir = app.path().app_config_dir().unwrap();
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -157,7 +233,41 @@ fn main() {
|
|||||||
eprintln!("macOS smoke test timed out");
|
eprintln!("macOS smoke test timed out");
|
||||||
handle.exit(1);
|
handle.exit(1);
|
||||||
});
|
});
|
||||||
app.run_return(|_, _| {});
|
let closed = Arc::new(AtomicBool::new(false));
|
||||||
|
let closed_result = closed.clone();
|
||||||
|
let exit_code = app.run_return(move |app, event| {
|
||||||
|
if let tauri::RunEvent::ExitRequested { code: Some(0), .. } = event {
|
||||||
|
closed_result.store(true, Ordering::SeqCst);
|
||||||
|
assert!(
|
||||||
|
needs_setup
|
||||||
|
|| !app
|
||||||
|
.get_webview_window("launcher")
|
||||||
|
.unwrap()
|
||||||
|
.is_visible()
|
||||||
|
.unwrap(),
|
||||||
|
"Closing the workspace must not reopen settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visibility_check.join().unwrap();
|
||||||
|
assert_eq!(exit_code, 0);
|
||||||
|
assert!(
|
||||||
|
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);
|
let _ = std::fs::remove_dir_all(config_dir);
|
||||||
assert!(
|
assert!(
|
||||||
passed.load(Ordering::SeqCst),
|
passed.load(Ordering::SeqCst),
|
||||||
|
|||||||
@@ -578,3 +578,46 @@ for (const change of [
|
|||||||
assert.equal(existsSync(f.destination), false);
|
assert.equal(existsSync(f.destination), false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('release versions agree across native manifests', () => {
|
||||||
|
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||||
|
const config = JSON.parse(read('../src-tauri/tauri.conf.json'));
|
||||||
|
const ios = JSON.parse(read('../src-tauri/tauri.ios.conf.json'));
|
||||||
|
const packageInfo = JSON.parse(read('../package.json'));
|
||||||
|
assert.equal(packageInfo.version, config.version);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/Cargo.toml').match(/^version = "([^"]+)"$/m)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/Cargo.lock').match(
|
||||||
|
/name = "kanbangit-native"\nversion = "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/gen/apple/project.yml').match(
|
||||||
|
/MARKETING_VERSION: "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
config.version,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
read('../src-tauri/gen/apple/project.yml').match(
|
||||||
|
/CURRENT_PROJECT_VERSION: "([^"]+)"/,
|
||||||
|
)?.[1],
|
||||||
|
ios.bundle.iOS.bundleVersion,
|
||||||
|
);
|
||||||
|
const project = read(
|
||||||
|
'../src-tauri/gen/apple/kanbangit-native.xcodeproj/project.pbxproj',
|
||||||
|
);
|
||||||
|
for (const [, version] of project.matchAll(
|
||||||
|
/MARKETING_VERSION = ([^;]+);/g,
|
||||||
|
)) {
|
||||||
|
assert.equal(version, config.version);
|
||||||
|
}
|
||||||
|
for (const [, version] of project.matchAll(
|
||||||
|
/CURRENT_PROJECT_VERSION = ([^;]+);/g,
|
||||||
|
)) {
|
||||||
|
assert.equal(version, ios.bundle.iOS.bundleVersion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const script = await readFile(
|
||||||
|
new URL('../ui/workspace-navigation.js', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||||
|
body { margin: 0; } #board { overflow-x: auto; width: 300px; height: 100px; }
|
||||||
|
#board > div { width: 1500px; height: 100px; } dialog { max-height: 200px; overflow-y: auto; }
|
||||||
|
</style></head><body><p id="page">${request.url}</p><input id="draft" value="retained"><div id="board"><div>Board columns</div></div>
|
||||||
|
<dialog><button id="close">Close</button><div style="height:1000px">Long dialog</div></dialog><div id="long"></div></body></html>`);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
await page.addInitScript(script);
|
||||||
|
const root = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
try {
|
||||||
|
await page.goto(root);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => typeof window.__kanbanNavigation),
|
||||||
|
'undefined',
|
||||||
|
);
|
||||||
|
await page.goto(`${root}/full-page`);
|
||||||
|
await page.goBack();
|
||||||
|
await page.waitForURL(`${root}/`);
|
||||||
|
await page.goForward();
|
||||||
|
await page.waitForURL(`${root}/full-page`);
|
||||||
|
await page.locator('#draft').fill('unsaved draft');
|
||||||
|
assert.equal(await page.locator('#draft').inputValue(), 'unsaved draft');
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.documentElement).overscrollBehaviorY,
|
||||||
|
),
|
||||||
|
'none',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.documentElement).overscrollBehaviorX,
|
||||||
|
),
|
||||||
|
'auto',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||||
|
844,
|
||||||
|
);
|
||||||
|
await page.mouse.move(380, 400);
|
||||||
|
await page.mouse.wheel(0, -1000);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
assert.equal(await page.evaluate(() => scrollY), 0);
|
||||||
|
await page
|
||||||
|
.locator('#long')
|
||||||
|
.evaluate((element) => (element.style.height = '3000px'));
|
||||||
|
await page.mouse.wheel(0, 1000);
|
||||||
|
await page.waitForFunction(() => scrollY > 0);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
scrollTo(0, document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
await page.mouse.wheel(0, 5000);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(() => scrollY + innerHeight),
|
||||||
|
await page.evaluate(() => document.documentElement.scrollHeight),
|
||||||
|
);
|
||||||
|
await page.evaluate(() => scrollTo(0, 0));
|
||||||
|
await page.locator('#board').hover();
|
||||||
|
await page.mouse.wheel(500, 0);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('#board').scrollLeft > 0,
|
||||||
|
);
|
||||||
|
assert.equal(new URL(page.url()).pathname, '/full-page');
|
||||||
|
await page.locator('dialog').evaluate((dialog) => dialog.showModal());
|
||||||
|
await page.locator('dialog').hover();
|
||||||
|
await page.mouse.wheel(0, 500);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('dialog').scrollTop > 0,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS: browser-owned history, editable input, short/long scroll bounds, horizontal board and dialog scrolling',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#import <WebKit/WebKit.h>
|
||||||
|
#import "../src-tauri/src/navigation.m"
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@interface WorkspacePages : NSObject <WKURLSchemeHandler, WKNavigationDelegate>
|
||||||
|
@property(nonatomic) NSUInteger finished;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@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];
|
||||||
|
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;
|
||||||
|
check(!webview.allowsBackForwardNavigationGestures, @"WebKit defaults to disabled gestures");
|
||||||
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
|
install_workspace_navigation((__bridge void *)webview);
|
||||||
|
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");
|
||||||
|
|
||||||
|
[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");
|
||||||
|
puts("PASS: WebKit interactive gesture configuration, full-page back/forward and empty history");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
test(
|
||||||
|
'WebKit owns interactive navigation and preserves native back/forward history',
|
||||||
|
{ skip: process.platform !== 'darwin' },
|
||||||
|
() => {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-navigation-'));
|
||||||
|
const binary = join(directory, 'navigation-test');
|
||||||
|
try {
|
||||||
|
const compile = spawnSync(
|
||||||
|
'xcrun',
|
||||||
|
[
|
||||||
|
'--sdk',
|
||||||
|
'macosx',
|
||||||
|
'clang',
|
||||||
|
'-fobjc-arc',
|
||||||
|
'-fblocks',
|
||||||
|
'-Wall',
|
||||||
|
'-Werror',
|
||||||
|
'-framework',
|
||||||
|
'AppKit',
|
||||||
|
'-framework',
|
||||||
|
'WebKit',
|
||||||
|
fileURLToPath(
|
||||||
|
new URL('./navigation-macos.m', import.meta.url),
|
||||||
|
),
|
||||||
|
'-o',
|
||||||
|
binary,
|
||||||
|
],
|
||||||
|
{ encoding: 'utf8', timeout: 30000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
compile.status,
|
||||||
|
0,
|
||||||
|
compile.stderr || compile.error?.message,
|
||||||
|
);
|
||||||
|
const result = spawnSync(binary, [], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
result.stderr || result.error?.message,
|
||||||
|
);
|
||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
(() => {
|
||||||
|
if (window.top !== window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const constrainScroll = () => {
|
||||||
|
if (document.documentElement) {
|
||||||
|
document.documentElement.style.overscrollBehaviorY = 'none';
|
||||||
|
}
|
||||||
|
if (document.body) {
|
||||||
|
document.body.style.overscrollBehaviorY = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
constrainScroll();
|
||||||
|
document.addEventListener('DOMContentLoaded', constrainScroll, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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'));
|
||||||
|
})();
|
||||||
+6
-1
@@ -15,7 +15,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
"lint:check": "eslint .",
|
"lint:check": "eslint .",
|
||||||
"types:check": "vue-tsc --noEmit"
|
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -48,6 +48,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/core": "2.3.18",
|
"@inertiajs/core": "2.3.18",
|
||||||
"@inertiajs/vue3": "^2.3.7",
|
"@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",
|
"@vueuse/core": "^12.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Generated
+506
-17
@@ -203,6 +203,14 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
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
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -213,6 +221,21 @@ importers:
|
|||||||
'@inertiajs/vue3':
|
'@inertiajs/vue3':
|
||||||
specifier: ^2.3.7
|
specifier: ^2.3.7
|
||||||
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
|
version: 2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)(vue@3.5.30(typescript@5.9.3))
|
||||||
|
'@tiptap/core':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/markdown':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3
|
||||||
|
'@tiptap/starter-kit':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3
|
||||||
|
'@tiptap/vue-3':
|
||||||
|
specifier: ^3.31.3
|
||||||
|
version: 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^12.8.2
|
specifier: ^12.8.2
|
||||||
version: 12.8.2(typescript@5.9.3)
|
version: 12.8.2(typescript@5.9.3)
|
||||||
@@ -725,6 +748,160 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^2.7.0 || ^3.0.0
|
vue: ^2.7.0 || ^3.0.0
|
||||||
|
|
||||||
|
'@tiptap/core@3.31.3':
|
||||||
|
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.31.3':
|
||||||
|
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.31.3':
|
||||||
|
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bubble-menu@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.31.3':
|
||||||
|
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.31.3':
|
||||||
|
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.31.3':
|
||||||
|
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-floating-menu@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@floating-ui/dom': ^1.0.0
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.31.3':
|
||||||
|
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.31.3':
|
||||||
|
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.31.3':
|
||||||
|
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.31.3':
|
||||||
|
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.31.3':
|
||||||
|
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.31.3':
|
||||||
|
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.31.3':
|
||||||
|
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.31.3':
|
||||||
|
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.31.3':
|
||||||
|
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.31.3':
|
||||||
|
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.31.3':
|
||||||
|
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.31.3':
|
||||||
|
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extensions@3.31.3':
|
||||||
|
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.31.3':
|
||||||
|
resolution: {integrity: sha512-rBbYSxasseUoaBo15C8Yoaqafo7mJJzxwAwV9Z1OpLBvOroW9nQ0EbOdqaSYRw3/d9pP71B/3LNSzKyfyy+dpQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/pm@3.31.3':
|
||||||
|
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.31.3':
|
||||||
|
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
|
||||||
|
|
||||||
|
'@tiptap/vue-3@3.31.3':
|
||||||
|
resolution: {integrity: sha512-PNt0+rm7BXzhe/U+xlNnXBeJ6t9x2mE3902VXeIeEBxu5CrYLCJ0a5yHrof1jU3KwJrLOe/BtlGWjbq4qnTTfQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@floating-ui/dom': ^1.0.0
|
||||||
|
'@tiptap/core': 3.31.3
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -1075,8 +1252,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
axios@1.19.0:
|
axios@1.20.0:
|
||||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
@@ -1088,14 +1265,14 @@ packages:
|
|||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.21:
|
||||||
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
|
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
|
||||||
|
|
||||||
brace-expansion@2.1.4:
|
brace-expansion@2.1.7:
|
||||||
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
|
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
braces@3.0.3:
|
braces@3.0.3:
|
||||||
@@ -1914,6 +2091,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
linkifyjs@4.3.3:
|
||||||
|
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1936,6 +2116,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@17.0.6:
|
||||||
|
resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2023,6 +2208,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
orderedmap@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2150,6 +2338,45 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.3:
|
||||||
|
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.2:
|
||||||
|
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.3:
|
||||||
|
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
|
||||||
|
|
||||||
|
prosemirror-model@1.25.12:
|
||||||
|
resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==}
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
|
||||||
|
|
||||||
|
prosemirror-transform@1.12.1:
|
||||||
|
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
|
||||||
|
|
||||||
|
prosemirror-view@1.42.5:
|
||||||
|
resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==}
|
||||||
|
|
||||||
proxy-from-env@2.1.0:
|
proxy-from-env@2.1.0:
|
||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2203,6 +2430,9 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rope-sequence@1.3.4:
|
||||||
|
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
@@ -2506,6 +2736,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8:
|
||||||
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2671,7 +2904,7 @@ snapshots:
|
|||||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/lodash-es': 4.17.12
|
'@types/lodash-es': 4.17.12
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
qs: 6.16.0
|
qs: 6.16.0
|
||||||
@@ -2888,6 +3121,178 @@ snapshots:
|
|||||||
'@tanstack/virtual-core': 3.13.23
|
'@tanstack/virtual-core': 3.13.23
|
||||||
vue: 3.5.30(typescript@5.9.3)
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
|
||||||
|
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
linkifyjs: 4.3.3
|
||||||
|
|
||||||
|
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
|
'@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/markdown@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
marked: 17.0.6
|
||||||
|
|
||||||
|
'@tiptap/pm@3.31.3':
|
||||||
|
dependencies:
|
||||||
|
prosemirror-changeset: 2.4.3
|
||||||
|
prosemirror-commands: 1.7.2
|
||||||
|
prosemirror-dropcursor: 1.8.3
|
||||||
|
prosemirror-gapcursor: 1.4.1
|
||||||
|
prosemirror-history: 1.5.0
|
||||||
|
prosemirror-inputrules: 1.5.1
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-schema-list: 1.5.1
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-tables: 1.8.5
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
'@tiptap/starter-kit@3.31.3':
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
|
||||||
|
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
|
||||||
|
'@tiptap/vue-3@3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(vue@3.5.30(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/pm': 3.31.3
|
||||||
|
vue: 3.5.30(typescript@5.9.3)
|
||||||
|
optionalDependencies:
|
||||||
|
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -3287,7 +3692,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
|
|
||||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||||
form-data: 4.0.6
|
form-data: 4.0.6
|
||||||
@@ -3303,16 +3708,16 @@ snapshots:
|
|||||||
|
|
||||||
boolbase@1.0.0: {}
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.18:
|
brace-expansion@1.1.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
concat-map: 0.0.1
|
concat-map: 0.0.1
|
||||||
|
|
||||||
brace-expansion@2.1.4:
|
brace-expansion@2.1.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
brace-expansion@5.0.9:
|
brace-expansion@5.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 4.0.4
|
balanced-match: 4.0.4
|
||||||
|
|
||||||
@@ -4055,7 +4460,7 @@ snapshots:
|
|||||||
|
|
||||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||||
lodash-es: 4.18.1
|
lodash-es: 4.18.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- debug
|
- debug
|
||||||
@@ -4171,6 +4576,8 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.33.0
|
lightningcss-win32-arm64-msvc: 1.33.0
|
||||||
lightningcss-win32-x64-msvc: 1.33.0
|
lightningcss-win32-x64-msvc: 1.33.0
|
||||||
|
|
||||||
|
linkifyjs@4.3.3: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -4189,6 +4596,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@17.0.6: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
@@ -4206,15 +4615,15 @@ snapshots:
|
|||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.9
|
brace-expansion: 5.0.12
|
||||||
|
|
||||||
minimatch@3.1.5:
|
minimatch@3.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.18
|
brace-expansion: 1.1.21
|
||||||
|
|
||||||
minimatch@9.0.9:
|
minimatch@9.0.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.1.4
|
brace-expansion: 2.1.7
|
||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
@@ -4276,6 +4685,8 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
orderedmap@2.1.1: {}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
@@ -4333,6 +4744,80 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.8.2: {}
|
prettier@3.8.2: {}
|
||||||
|
|
||||||
|
prosemirror-changeset@2.4.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-commands@1.7.2:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-dropcursor@1.8.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-gapcursor@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-history@1.5.0:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
rope-sequence: 1.3.4
|
||||||
|
|
||||||
|
prosemirror-inputrules@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-keymap@1.2.3:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
w3c-keyname: 2.2.8
|
||||||
|
|
||||||
|
prosemirror-model@1.25.12:
|
||||||
|
dependencies:
|
||||||
|
orderedmap: 2.1.1
|
||||||
|
|
||||||
|
prosemirror-schema-list@1.5.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
|
prosemirror-state@1.4.4:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-tables@1.8.5:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-keymap: 1.2.3
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
prosemirror-view: 1.42.5
|
||||||
|
|
||||||
|
prosemirror-transform@1.12.1:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
|
||||||
|
prosemirror-view@1.42.5:
|
||||||
|
dependencies:
|
||||||
|
prosemirror-model: 1.25.12
|
||||||
|
prosemirror-state: 1.4.4
|
||||||
|
prosemirror-transform: 1.12.1
|
||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
proxy-from-env@2.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
@@ -4415,6 +4900,8 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||||
|
|
||||||
|
rope-sequence@1.3.4: {}
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
@@ -4760,6 +5247,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
which-boxed-primitive@1.1.1:
|
which-boxed-primitive@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-bigint: 1.1.0
|
is-bigint: 1.1.0
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
minimumReleaseAge: 10080
|
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:
|
allowBuilds:
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
vue-demi: true
|
vue-demi: true
|
||||||
|
overrides:
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
@import './ticket-description.css';
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
|
|||||||
@@ -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 type { DefineComponent } from 'vue';
|
||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import '../css/app.css';
|
import '../css/app.css';
|
||||||
|
import {
|
||||||
|
initializeNativeApp,
|
||||||
|
registerNativeRecovery,
|
||||||
|
} from '@/composables/useNativeApp';
|
||||||
import { createAppearance } from '@/composables/useAppearance';
|
import { createAppearance } from '@/composables/useAppearance';
|
||||||
import { createLocalization } from '@/lib/localization';
|
import { createLocalization } from '@/lib/localization';
|
||||||
|
|
||||||
@@ -21,6 +25,11 @@ createInertiaApp({
|
|||||||
.use(createLocalization(props.initialPage.props.localization))
|
.use(createLocalization(props.initialPage.props.localization))
|
||||||
.use(createAppearance(props.initialPage.props.appearance))
|
.use(createAppearance(props.initialPage.props.appearance))
|
||||||
.mount(el);
|
.mount(el);
|
||||||
|
registerNativeRecovery();
|
||||||
|
window.addEventListener('kanban-native-ready', () => {
|
||||||
|
void initializeNativeApp();
|
||||||
|
});
|
||||||
|
void initializeNativeApp();
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: 'var(--primary)',
|
color: 'var(--primary)',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineComponent, h } from 'vue';
|
||||||
|
import {
|
||||||
|
parseDescription,
|
||||||
|
renderDescriptionNode,
|
||||||
|
supportsVisualDescription,
|
||||||
|
} from '@/lib/ticket-description';
|
||||||
|
|
||||||
|
const props = defineProps<{ description: string }>();
|
||||||
|
const document = computed(() =>
|
||||||
|
supportsVisualDescription(props.description)
|
||||||
|
? parseDescription(props.description)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const Content = defineComponent(
|
||||||
|
() => () =>
|
||||||
|
document.value
|
||||||
|
? h('div', { class: 'ticket-description' }, [
|
||||||
|
renderDescriptionNode(document.value),
|
||||||
|
])
|
||||||
|
: h(
|
||||||
|
'p',
|
||||||
|
{
|
||||||
|
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
|
||||||
|
},
|
||||||
|
props.description,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Content />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
SquareCode,
|
||||||
|
Link,
|
||||||
|
Unlink,
|
||||||
|
Undo2,
|
||||||
|
Redo2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
descriptionExtensions,
|
||||||
|
parseDescription,
|
||||||
|
safeDescriptionLink,
|
||||||
|
supportsVisualDescription,
|
||||||
|
} from '@/lib/ticket-description';
|
||||||
|
import type {
|
||||||
|
DescriptionToolbarAction,
|
||||||
|
TicketDescriptionEditorProps,
|
||||||
|
} from '@/types/ticket-description';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDescriptionEditorProps>();
|
||||||
|
const model = defineModel<string>({ required: true });
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sourceMode = ref(!supportsVisualDescription(model.value));
|
||||||
|
const supported = computed(() => supportsVisualDescription(model.value));
|
||||||
|
const visual = computed(() => !sourceMode.value && supported.value);
|
||||||
|
const linkOpen = ref(false);
|
||||||
|
const linkUrl = ref('');
|
||||||
|
const linkError = ref('');
|
||||||
|
const linkInput = ref<InstanceType<typeof Input> | null>(null);
|
||||||
|
let currentMarkdown = model.value;
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: descriptionExtensions(),
|
||||||
|
content: supported.value ? parseDescription(model.value) : '',
|
||||||
|
editable: !props.disabled,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
id: props.id,
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
'aria-labelledby': `${props.id}-label`,
|
||||||
|
'aria-describedby': `${props.id}-hint ${props.id}-error`,
|
||||||
|
'aria-invalid': String(Boolean(props.error)),
|
||||||
|
'aria-disabled': String(Boolean(props.disabled)),
|
||||||
|
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: instance }) => {
|
||||||
|
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
|
||||||
|
model.value = currentMarkdown;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(model, (value) => {
|
||||||
|
if (value === currentMarkdown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentMarkdown = value;
|
||||||
|
if (supportsVisualDescription(value)) {
|
||||||
|
editor.value?.commands.setContent(parseDescription(value), {
|
||||||
|
emitUpdate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(disabled) => {
|
||||||
|
editor.value?.setEditable(!disabled, false);
|
||||||
|
if (disabled) {
|
||||||
|
linkOpen.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch([() => props.error, () => props.disabled], () => {
|
||||||
|
editor.value?.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
...editor.value.options.editorProps.attributes,
|
||||||
|
'aria-invalid': String(Boolean(props.error)),
|
||||||
|
'aria-disabled': String(Boolean(props.disabled)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions: DescriptionToolbarAction[] = [
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.bold',
|
||||||
|
icon: Bold,
|
||||||
|
active: 'bold',
|
||||||
|
run: (c) => c.toggleBold(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.italic',
|
||||||
|
icon: Italic,
|
||||||
|
active: 'italic',
|
||||||
|
run: (c) => c.toggleItalic(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.strike',
|
||||||
|
icon: Strikethrough,
|
||||||
|
active: 'strike',
|
||||||
|
run: (c) => c.toggleStrike(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading1',
|
||||||
|
icon: Heading1,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 1 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading2',
|
||||||
|
icon: Heading2,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 2 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.heading3',
|
||||||
|
icon: Heading3,
|
||||||
|
active: 'heading',
|
||||||
|
attributes: { level: 3 },
|
||||||
|
run: (c) => c.toggleHeading({ level: 3 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.bulletList',
|
||||||
|
icon: List,
|
||||||
|
active: 'bulletList',
|
||||||
|
run: (c) => c.toggleBulletList(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.orderedList',
|
||||||
|
icon: ListOrdered,
|
||||||
|
active: 'orderedList',
|
||||||
|
run: (c) => c.toggleOrderedList(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.quote',
|
||||||
|
icon: Quote,
|
||||||
|
active: 'blockquote',
|
||||||
|
run: (c) => c.toggleBlockquote(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.code',
|
||||||
|
icon: Code,
|
||||||
|
active: 'code',
|
||||||
|
run: (c) => c.toggleCode(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'descriptionEditor.codeBlock',
|
||||||
|
icon: SquareCode,
|
||||||
|
active: 'codeBlock',
|
||||||
|
run: (c) => c.toggleCodeBlock(),
|
||||||
|
},
|
||||||
|
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
|
||||||
|
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
|
||||||
|
];
|
||||||
|
function runAction(action: DescriptionToolbarAction): void {
|
||||||
|
if (editor.value && !props.disabled) {
|
||||||
|
action.run(editor.value.chain().focus()).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function switchMode(): void {
|
||||||
|
linkOpen.value = false;
|
||||||
|
sourceMode.value = !sourceMode.value;
|
||||||
|
}
|
||||||
|
async function editLink(): Promise<void> {
|
||||||
|
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
|
||||||
|
linkError.value = '';
|
||||||
|
linkOpen.value = true;
|
||||||
|
await nextTick();
|
||||||
|
linkInput.value?.$el?.focus();
|
||||||
|
}
|
||||||
|
function saveLink(): void {
|
||||||
|
if (!editor.value || props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = safeDescriptionLink(linkUrl.value.trim());
|
||||||
|
if (!href) {
|
||||||
|
linkError.value = t('descriptionEditor.invalidLink');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chain = editor.value.chain().focus().extendMarkRange('link');
|
||||||
|
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
|
||||||
|
chain
|
||||||
|
.insertContent({
|
||||||
|
type: 'text',
|
||||||
|
text: href,
|
||||||
|
marks: [{ type: 'link', attrs: { href } }],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
chain.setLink({ href }).run();
|
||||||
|
}
|
||||||
|
linkOpen.value = false;
|
||||||
|
}
|
||||||
|
function closeLink(): void {
|
||||||
|
linkOpen.value = false;
|
||||||
|
editor.value?.commands.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Label
|
||||||
|
:id="`${id}-label`"
|
||||||
|
:for="id"
|
||||||
|
@click="visual && editor?.commands.focus()"
|
||||||
|
>{{ label }}</Label
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="disabled || (!supported && !visual)"
|
||||||
|
:aria-pressed="sourceMode"
|
||||||
|
@click="switchMode"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
visual
|
||||||
|
? t('descriptionEditor.source')
|
||||||
|
: t('descriptionEditor.visual')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
|
||||||
|
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visual"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('descriptionEditor.formatting')"
|
||||||
|
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.label"
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
:variant="
|
||||||
|
action.active &&
|
||||||
|
editor?.isActive(action.active, action.attributes)
|
||||||
|
? 'secondary'
|
||||||
|
: 'ghost'
|
||||||
|
"
|
||||||
|
:title="t(action.label)"
|
||||||
|
:aria-label="t(action.label)"
|
||||||
|
:aria-pressed="
|
||||||
|
action.active
|
||||||
|
? Boolean(
|
||||||
|
editor?.isActive(
|
||||||
|
action.active,
|
||||||
|
action.attributes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!editor ||
|
||||||
|
!action.run(editor.can().chain()).run()
|
||||||
|
"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="runAction(action)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('descriptionEditor.link')"
|
||||||
|
:aria-label="t('descriptionEditor.link')"
|
||||||
|
:aria-pressed="Boolean(editor?.isActive('link'))"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!editor ||
|
||||||
|
!editor.can().setLink({ href: 'https://example.com' })
|
||||||
|
"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="editLink"
|
||||||
|
><Link class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('descriptionEditor.unlink')"
|
||||||
|
:aria-label="t('descriptionEditor.unlink')"
|
||||||
|
:disabled="disabled || !editor?.isActive('link')"
|
||||||
|
@mousedown.prevent
|
||||||
|
@click="
|
||||||
|
editor
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.extendMarkRange('link')
|
||||||
|
.unsetLink()
|
||||||
|
.run()
|
||||||
|
"
|
||||||
|
><Unlink class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="linkOpen && visual"
|
||||||
|
class="grid gap-2 border-b p-3"
|
||||||
|
@keydown.esc.prevent.stop="closeLink"
|
||||||
|
>
|
||||||
|
<Label :for="`${id}-link`">{{
|
||||||
|
t('descriptionEditor.linkUrl')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
:id="`${id}-link`"
|
||||||
|
ref="linkInput"
|
||||||
|
v-model="linkUrl"
|
||||||
|
type="text"
|
||||||
|
:disabled="disabled"
|
||||||
|
:invalid="Boolean(linkError)"
|
||||||
|
:aria-describedby="`${id}-link-error`"
|
||||||
|
placeholder="https://"
|
||||||
|
@keydown.enter.prevent.stop="saveLink"
|
||||||
|
/>
|
||||||
|
<InputError :id="`${id}-link-error`" :message="linkError" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="saveLink"
|
||||||
|
>{{ t('descriptionEditor.applyLink') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="closeLink"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditorContent v-if="visual && editor" :editor="editor" />
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:aria-labelledby="`${id}-label`"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
supported
|
||||||
|
? t('descriptionEditor.hint')
|
||||||
|
: t('descriptionEditor.unsupported')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { ticketStatusLabel } from '@/lib/tickets';
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
@@ -72,10 +73,9 @@ const statusOptions = computed(
|
|||||||
:placeholder="t('ticketForm.titlePlaceholder')"
|
:placeholder="t('ticketForm.titlePlaceholder')"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<TicketFormField
|
<TicketDescriptionEditor
|
||||||
id="description"
|
id="description"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.description')"
|
:label="t('ticketDetail.description')"
|
||||||
:error="errors.description"
|
:error="errors.description"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
watch,
|
watch,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import TicketDescription from '@/components/tickets/TicketDescription.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
@@ -272,16 +273,12 @@ const submit = (): void => {
|
|||||||
</h2></CardHeader
|
</h2></CardHeader
|
||||||
>
|
>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p
|
<TicketDescription
|
||||||
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
|
v-if="ticket.description"
|
||||||
:class="{
|
:description="ticket.description"
|
||||||
'text-muted-foreground': !ticket.description,
|
/>
|
||||||
}"
|
<p v-else class="text-muted-foreground text-sm leading-7">
|
||||||
>
|
{{ t('ticketDetail.noDescription') }}
|
||||||
{{
|
|
||||||
ticket.description ||
|
|
||||||
t('ticketDetail.noDescription')
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useTicketLinkActions } from '@/composables/useTicketLinkActions';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type {
|
||||||
|
TicketDetailRelationshipsProps,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
defineEmits<{ editParent: [] }>();
|
defineEmits<{ editParent: [] }>();
|
||||||
@@ -33,17 +37,17 @@ const canAddLink = computed(
|
|||||||
props.ticketLinkTypes.length > 0 &&
|
props.ticketLinkTypes.length > 0 &&
|
||||||
props.ticketLinkOptions.length > 0,
|
props.ticketLinkOptions.length > 0,
|
||||||
);
|
);
|
||||||
const form = useForm({
|
const form = useForm<TicketLinkFormData>({
|
||||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
link_direction: 'source',
|
||||||
linked_ticket_id: '',
|
linked_ticket_id: '',
|
||||||
});
|
});
|
||||||
const protection = useFormProtection(form, {
|
const protection = useFormProtection(form, {
|
||||||
isDirty: () => props.canEditTicket && form.isDirty,
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
});
|
});
|
||||||
const selectedType = computed(() =>
|
const { actions, selectedAction, selectedActionLabel } = useTicketLinkActions(
|
||||||
props.ticketLinkTypes.find(
|
() => props.ticketLinkTypes,
|
||||||
(type) => String(type.id) === String(form.ticket_link_type_id),
|
form,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const selectedTicket = computed(() =>
|
const selectedTicket = computed(() =>
|
||||||
props.ticketLinkOptions.find(
|
props.ticketLinkOptions.find(
|
||||||
@@ -224,16 +228,14 @@ function submit(): void {
|
|||||||
>
|
>
|
||||||
<TicketFormField
|
<TicketFormField
|
||||||
id="ticket_link_type_id"
|
id="ticket_link_type_id"
|
||||||
v-model="form.ticket_link_type_id"
|
v-model="selectedAction"
|
||||||
kind="select"
|
kind="select"
|
||||||
:label="t('ticketDetail.linkType')"
|
:label="t('ticketDetail.linkAction')"
|
||||||
:options="
|
:options="actions"
|
||||||
ticketLinkTypes.map((type) => ({
|
:error="
|
||||||
value: type.id,
|
form.errors.ticket_link_type_id ||
|
||||||
label: type.label,
|
form.errors.link_direction
|
||||||
}))
|
|
||||||
"
|
"
|
||||||
:error="form.errors.ticket_link_type_id"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -260,22 +262,19 @@ function submit(): void {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p
|
<p
|
||||||
v-if="selectedType && selectedTicket"
|
v-if="selectedActionLabel && selectedTicket"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-xs">{{
|
<span class="font-mono text-xs">{{
|
||||||
ticket.ticket_key
|
ticket.ticket_key
|
||||||
}}</span
|
}}</span
|
||||||
><span>{{ selectedType.label }}</span
|
><span>{{ selectedActionLabel }}</span
|
||||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
class="font-mono text-xs"
|
class="font-mono text-xs"
|
||||||
>{{ selectedTicket.ticket_key }}</span
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{{ t('ticketRelationships.directionHelp') }}
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing || !selectedTicket"
|
:disabled="form.processing || !selectedTicket"
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link, useForm } from '@inertiajs/vue3';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
import { Unlink } from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
|
||||||
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
@@ -20,7 +18,6 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ removed: [] }>();
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
|
||||||
const form = useForm({});
|
const form = useForm({});
|
||||||
const protection = useFormProtection(form);
|
const protection = useFormProtection(form);
|
||||||
function remove(): void {
|
function remove(): void {
|
||||||
@@ -47,27 +44,6 @@ function remove(): void {
|
|||||||
:data-ticket-link="link.id"
|
:data-ticket-link="link.id"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="text-muted-foreground max-w-full whitespace-normal"
|
|
||||||
>
|
|
||||||
<ArrowUpRight
|
|
||||||
v-if="outgoing"
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/><ArrowDownLeft
|
|
||||||
v-else
|
|
||||||
class="size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
t(
|
|
||||||
outgoing
|
|
||||||
? 'ticketRelationships.outgoing'
|
|
||||||
: 'ticketRelationships.incoming',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-muted-foreground font-mono text-xs">{{
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
ticketKey
|
ticketKey
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { computed, readonly, ref } from 'vue';
|
||||||
|
import { isNativeAppState, updateIsBusy } from '@/lib/native-app';
|
||||||
|
import type { NativeAction, NativeAppState } from '@/types/native-app';
|
||||||
|
|
||||||
|
const state = ref<NativeAppState | null>(null);
|
||||||
|
const pending = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
let initializing = false;
|
||||||
|
let refreshing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
action: NativeAction,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
if (typeof window === 'undefined' || window.kanbanNative?.version !== 1) {
|
||||||
|
throw new Error('bridgeUnavailable');
|
||||||
|
}
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
window.kanbanNative.request(action, payload),
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error('bridgeTimeout')),
|
||||||
|
11000,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (refreshing) {
|
||||||
|
return refreshing;
|
||||||
|
}
|
||||||
|
refreshing = (async () => {
|
||||||
|
const result = await request('getState');
|
||||||
|
if (!isNativeAppState(result)) {
|
||||||
|
throw new Error('incompatibleVersion');
|
||||||
|
}
|
||||||
|
state.value = result;
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
await refreshing;
|
||||||
|
} finally {
|
||||||
|
refreshing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initializeNativeApp() {
|
||||||
|
if (
|
||||||
|
initializing ||
|
||||||
|
state.value ||
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
window.kanbanNative?.version !== 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initializing = true;
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
await request('settingsReady');
|
||||||
|
} catch {
|
||||||
|
state.value = null;
|
||||||
|
} finally {
|
||||||
|
initializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeApp() {
|
||||||
|
const busy = computed(
|
||||||
|
() => pending.value || updateIsBusy(state.value?.updates),
|
||||||
|
);
|
||||||
|
async function act(
|
||||||
|
action: NativeAction,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
pending.value ||
|
||||||
|
(updateIsBusy(state.value?.updates) && action !== 'openSettings')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
await request(action, payload);
|
||||||
|
if (action !== 'setWorkspace') {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
error.value =
|
||||||
|
cause instanceof Error ? cause.message : 'bridgeUnavailable';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
pending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
} catch (cause) {
|
||||||
|
error.value =
|
||||||
|
cause instanceof Error ? cause.message : 'bridgeUnavailable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state: readonly(state), busy, error: readonly(error), act, poll };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverNativeWorkspace() {
|
||||||
|
if (!state.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request('openSettings');
|
||||||
|
} catch {
|
||||||
|
/* Native recovery remains available from the app menu or toolbar. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNativeRecovery() {
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
void recoverNativeWorkspace();
|
||||||
|
});
|
||||||
|
document.addEventListener('inertia:exception', () => {
|
||||||
|
void recoverNativeWorkspace();
|
||||||
|
});
|
||||||
|
document.addEventListener('inertia:invalid', (event) => {
|
||||||
|
if (event.detail.response.status >= 400) {
|
||||||
|
void recoverNativeWorkspace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { computed, toValue } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter } from 'vue';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type {
|
||||||
|
TicketLinkActionOption,
|
||||||
|
TicketLinkFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
export function useTicketLinkActions(
|
||||||
|
ticketLinkTypes: MaybeRefOrGetter<TicketLinkTypeModel[]>,
|
||||||
|
form: TicketLinkFormData,
|
||||||
|
) {
|
||||||
|
const actions = computed<TicketLinkActionOption[]>(() =>
|
||||||
|
toValue(ticketLinkTypes).flatMap((type) => {
|
||||||
|
const choices: TicketLinkActionOption[] = [
|
||||||
|
{
|
||||||
|
value: `${type.id}:source`,
|
||||||
|
label: type.label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'source',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
type.counterpart_label &&
|
||||||
|
type.counterpart_label !== type.label
|
||||||
|
) {
|
||||||
|
choices.push({
|
||||||
|
value: `${type.id}:counterpart`,
|
||||||
|
label: type.counterpart_label,
|
||||||
|
ticketLinkTypeId: type.id,
|
||||||
|
direction: 'counterpart',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return choices;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const selectedAction = computed({
|
||||||
|
get: () => `${form.ticket_link_type_id}:${form.link_direction}`,
|
||||||
|
set: (value: string | number | null) => {
|
||||||
|
const action = actions.value.find(
|
||||||
|
(option) => option.value === value,
|
||||||
|
);
|
||||||
|
if (action) {
|
||||||
|
form.ticket_link_type_id = action.ticketLinkTypeId;
|
||||||
|
form.link_direction = action.direction;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedActionLabel = computed(
|
||||||
|
() =>
|
||||||
|
actions.value.find(
|
||||||
|
(action) => action.value === selectedAction.value,
|
||||||
|
)?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { actions, selectedAction, selectedActionLabel };
|
||||||
|
}
|
||||||
@@ -6,11 +6,14 @@ import AppLogo from '@/components/AppLogo.vue';
|
|||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useNativeApp } from '@/composables/useNativeApp';
|
||||||
|
import { edit as editNativeApp } from '@/routes/native-app';
|
||||||
import { useMacDownload } from '@/composables/useMacDownload';
|
import { useMacDownload } from '@/composables/useMacDownload';
|
||||||
import { home } from '@/routes/index';
|
import { home } from '@/routes/index';
|
||||||
|
|
||||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { state: nativeApp } = useNativeApp();
|
||||||
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
const { macDownloadUrl, showMacDownload } = useMacDownload();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -25,6 +28,9 @@ const { macDownloadUrl, showMacDownload } = useMacDownload();
|
|||||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||||
><AppLogo
|
><AppLogo
|
||||||
/></Link>
|
/></Link>
|
||||||
|
<Button v-if="nativeApp" as-child variant="outline" size="sm">
|
||||||
|
<Link :href="editNativeApp()">{{ t('nativeApp.title') }}</Link>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="showMacDownload"
|
v-if="showMacDownload"
|
||||||
as-child
|
as-child
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
|
AppWindow,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Palette,
|
Palette,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useNativeApp } from '@/composables/useNativeApp';
|
||||||
|
import { edit as editNativeApp } from '@/routes/native-app';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||||
@@ -20,6 +23,7 @@ import { edit as editSecurity } from '@/routes/security/index';
|
|||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const { state: nativeApp } = useNativeApp();
|
||||||
const sidebarNavItems = computed<NavItem[]>(() => [
|
const sidebarNavItems = computed<NavItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: t('navigation.profile'),
|
title: t('navigation.profile'),
|
||||||
@@ -41,6 +45,15 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
|||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
|
...(nativeApp.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t('nativeApp.title'),
|
||||||
|
href: editNativeApp(),
|
||||||
|
icon: AppWindow,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
title: t('navigation.appearance'),
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { NativeAppState, NativeUpdates } from '@/types/native-app';
|
||||||
|
|
||||||
|
export function isNativeAppState(value: unknown): value is NativeAppState {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const state = value as NativeAppState;
|
||||||
|
if (
|
||||||
|
state.apiVersion !== 1 ||
|
||||||
|
!['macos', 'ios'].includes(state.platform) ||
|
||||||
|
typeof state.version !== 'string' ||
|
||||||
|
typeof state.workspace !== 'string' ||
|
||||||
|
!Array.isArray(state.capabilities) ||
|
||||||
|
!state.capabilities.includes('workspace')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (new URL(state.workspace).protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!state.capabilities.includes('updates')) {
|
||||||
|
return state.updates === undefined;
|
||||||
|
}
|
||||||
|
if (state.platform !== 'macos' || !state.updates) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const updates = state.updates;
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
'idle',
|
||||||
|
'checking',
|
||||||
|
'available',
|
||||||
|
'current',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
'error',
|
||||||
|
].includes(updates.phase) &&
|
||||||
|
typeof updates.currentVersion === 'string' &&
|
||||||
|
typeof updates.build === 'string' &&
|
||||||
|
(updates.version === null || typeof updates.version === 'string') &&
|
||||||
|
(updates.notes === null || typeof updates.notes === 'string') &&
|
||||||
|
(updates.error === null || typeof updates.error === 'string') &&
|
||||||
|
Number.isFinite(updates.downloaded) &&
|
||||||
|
updates.downloaded >= 0 &&
|
||||||
|
(updates.total === null ||
|
||||||
|
(Number.isFinite(updates.total) && updates.total >= 0)) &&
|
||||||
|
typeof updates.preferences?.automatic === 'boolean' &&
|
||||||
|
[1, 6, 24].includes(updates.preferences.intervalHours)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIsBusy(updates?: NativeUpdates): boolean {
|
||||||
|
return (
|
||||||
|
!!updates &&
|
||||||
|
[
|
||||||
|
'checking',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
].includes(updates.phase)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { JSONContent } from '@tiptap/core';
|
||||||
|
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { h } from 'vue';
|
||||||
|
import type { VNodeChild } from 'vue';
|
||||||
|
|
||||||
|
export function safeDescriptionLink(value: unknown): string | null {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
/[\s\\]/u.test(value) ||
|
||||||
|
[...value].some(
|
||||||
|
(character) =>
|
||||||
|
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descriptionExtensions() {
|
||||||
|
return [
|
||||||
|
StarterKit.configure({
|
||||||
|
underline: false,
|
||||||
|
trailingNode: false,
|
||||||
|
link: {
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: false,
|
||||||
|
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
|
||||||
|
HTMLAttributes: {
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = new MarkdownManager({
|
||||||
|
extensions: descriptionExtensions(),
|
||||||
|
markedOptions: { gfm: true, breaks: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
||||||
|
export function supportsVisualDescription(value: string): boolean {
|
||||||
|
let supported = true;
|
||||||
|
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
|
||||||
|
if (
|
||||||
|
['html', 'table', 'image'].includes(token.type) ||
|
||||||
|
(token.type === 'list_item' && token.task) ||
|
||||||
|
(token.type === 'link' && !safeDescriptionLink(token.href))
|
||||||
|
) {
|
||||||
|
supported = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDescription(value: string): JSONContent {
|
||||||
|
return markdown.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
|
||||||
|
export function renderDescriptionNode(node: JSONContent): VNodeChild {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
let content: VNodeChild = node.text ?? '';
|
||||||
|
for (const mark of node.marks ?? []) {
|
||||||
|
const tag = {
|
||||||
|
bold: 'strong',
|
||||||
|
italic: 'em',
|
||||||
|
strike: 's',
|
||||||
|
code: 'code',
|
||||||
|
}[mark.type];
|
||||||
|
if (tag) {
|
||||||
|
content = h(tag, {}, [content]);
|
||||||
|
} else if (mark.type === 'link') {
|
||||||
|
const href = safeDescriptionLink(mark.attrs?.href);
|
||||||
|
if (href) {
|
||||||
|
content = h(
|
||||||
|
'a',
|
||||||
|
{ href, target: '_blank', rel: 'noopener noreferrer' },
|
||||||
|
[content],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const children = (node.content ?? []).map(renderDescriptionNode);
|
||||||
|
switch (node.type) {
|
||||||
|
case 'doc':
|
||||||
|
return children;
|
||||||
|
case 'paragraph':
|
||||||
|
return h('p', {}, children);
|
||||||
|
case 'heading':
|
||||||
|
return h(
|
||||||
|
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
|
||||||
|
{},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
case 'bulletList':
|
||||||
|
return h('ul', {}, children);
|
||||||
|
case 'orderedList':
|
||||||
|
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
|
||||||
|
case 'listItem':
|
||||||
|
return h('li', {}, children);
|
||||||
|
case 'blockquote':
|
||||||
|
return h('blockquote', {}, children);
|
||||||
|
case 'codeBlock':
|
||||||
|
return h('pre', {}, [h('code', {}, children)]);
|
||||||
|
case 'hardBreak':
|
||||||
|
return h('br');
|
||||||
|
case 'horizontalRule':
|
||||||
|
return h('hr');
|
||||||
|
default:
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,90 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'descriptionEditor.bold': 'Bold',
|
||||||
|
'descriptionEditor.italic': 'Italic',
|
||||||
|
'descriptionEditor.strike': 'Strikethrough',
|
||||||
|
'descriptionEditor.heading1': 'Heading 1',
|
||||||
|
'descriptionEditor.heading2': 'Heading 2',
|
||||||
|
'descriptionEditor.heading3': 'Heading 3',
|
||||||
|
'descriptionEditor.bulletList': 'Bullet list',
|
||||||
|
'descriptionEditor.orderedList': 'Numbered list',
|
||||||
|
'descriptionEditor.quote': 'Quote',
|
||||||
|
'descriptionEditor.code': 'Inline code',
|
||||||
|
'descriptionEditor.codeBlock': 'Code block',
|
||||||
|
'descriptionEditor.undo': 'Undo',
|
||||||
|
'descriptionEditor.redo': 'Redo',
|
||||||
|
'descriptionEditor.link': 'Add or edit link',
|
||||||
|
'descriptionEditor.unlink': 'Remove link',
|
||||||
|
'descriptionEditor.linkUrl': 'Link address',
|
||||||
|
'descriptionEditor.applyLink': 'Apply link',
|
||||||
|
'descriptionEditor.invalidLink':
|
||||||
|
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
|
||||||
|
'descriptionEditor.formatting': 'Description formatting',
|
||||||
|
'descriptionEditor.source': 'Markdown',
|
||||||
|
'descriptionEditor.visual': 'Visual editor',
|
||||||
|
'descriptionEditor.hint':
|
||||||
|
'Format your description using the toolbar or edit its Markdown source.',
|
||||||
|
'descriptionEditor.unsupported':
|
||||||
|
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
|
||||||
|
|
||||||
|
'nativeApp.title': 'App',
|
||||||
|
'nativeApp.description': 'Manage the KanbanGit app on this device.',
|
||||||
|
'nativeApp.workspace': 'Workspace',
|
||||||
|
'nativeApp.server': 'Server address',
|
||||||
|
'nativeApp.serverHelp':
|
||||||
|
'Use an HTTPS installation address without login details, a query or a fragment. This choice is saved on this device.',
|
||||||
|
'nativeApp.switch': 'Change workspace',
|
||||||
|
'nativeApp.confirmSwitch': 'Switch workspace',
|
||||||
|
'nativeApp.switchWarning':
|
||||||
|
'Switching closes your current workspace and discards unsaved inputs. You will sign in separately on the new server.',
|
||||||
|
'nativeApp.cancel': 'Cancel',
|
||||||
|
'nativeApp.unavailable':
|
||||||
|
'App settings are available in a compatible KanbanGit app.',
|
||||||
|
'nativeApp.updates': 'Software updates',
|
||||||
|
'nativeApp.version': 'Installed version {version} (build {build})',
|
||||||
|
'nativeApp.mobileVersion': 'App version {version}',
|
||||||
|
'nativeApp.availableVersion': 'Available version: {version}',
|
||||||
|
'nativeApp.automatic': 'Automatically check for updates',
|
||||||
|
'nativeApp.interval': 'Check interval',
|
||||||
|
'nativeApp.hours': 'Every {hours} hours',
|
||||||
|
'nativeApp.updateHelp':
|
||||||
|
'Checks run at startup and at the selected interval. Installation and restart require your confirmation.',
|
||||||
|
'nativeApp.check': 'Check for updates now',
|
||||||
|
'nativeApp.install': 'Install update',
|
||||||
|
'nativeApp.installRestart': 'Install and restart',
|
||||||
|
'nativeApp.installWarning':
|
||||||
|
'Save your work before continuing. KanbanGit will download and verify the update, then install it and restart. Unsaved inputs will be lost. Your workspace settings and stored sessions are kept.',
|
||||||
|
'nativeApp.progress': 'Update progress',
|
||||||
|
'nativeApp.recovery': 'Open native server settings',
|
||||||
|
'nativeApp.bridgeError':
|
||||||
|
'The app connection is unavailable or incompatible. Open the native server settings to reconnect, or restart the app.',
|
||||||
|
'nativeApp.invalidServer':
|
||||||
|
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
|
||||||
|
'nativeApp.storageError':
|
||||||
|
'The workspace could not be saved. Check local storage permissions and try again.',
|
||||||
|
'nativeApp.networkError':
|
||||||
|
'The update service or download is unreachable. Check your connection and try again.',
|
||||||
|
'nativeApp.metadataError':
|
||||||
|
'The update information is invalid or does not match this Mac. Try again later.',
|
||||||
|
'nativeApp.signatureError':
|
||||||
|
'The update signature could not be verified. Nothing was installed. Try again or contact support.',
|
||||||
|
'nativeApp.verificationError':
|
||||||
|
'The downloaded app does not match its announced version or publisher. Nothing was installed.',
|
||||||
|
'nativeApp.installationError':
|
||||||
|
'The update could not be installed. Move KanbanGit to Applications and check disk space and permissions.',
|
||||||
|
'nativeApp.preferencesError':
|
||||||
|
'Update preferences could not be saved. Check local storage permissions and try again.',
|
||||||
|
'nativeApp.updateBusy':
|
||||||
|
'An update operation is already running. Please wait.',
|
||||||
|
'nativeApp.phase.idle': 'Check for the latest stable version of KanbanGit.',
|
||||||
|
'nativeApp.phase.checking': 'Checking for updates…',
|
||||||
|
'nativeApp.phase.available': 'A new version of KanbanGit is available.',
|
||||||
|
'nativeApp.phase.current': 'KanbanGit is up to date.',
|
||||||
|
'nativeApp.phase.downloading': 'Downloading update…',
|
||||||
|
'nativeApp.phase.verifying': 'Verifying update…',
|
||||||
|
'nativeApp.phase.installing': 'Installing update…',
|
||||||
|
'nativeApp.phase.restarting': 'Restarting KanbanGit…',
|
||||||
|
'nativeApp.phase.error': 'The update could not be completed.',
|
||||||
|
|
||||||
'projectAccess.pageTitle': '{project} · Project access',
|
'projectAccess.pageTitle': '{project} · Project access',
|
||||||
'projectAccess.title': 'Project access',
|
'projectAccess.title': 'Project access',
|
||||||
'projectAccess.description':
|
'projectAccess.description':
|
||||||
@@ -1127,7 +1213,7 @@ export const en = {
|
|||||||
'ticketDetail.linkedTickets': 'Linked tickets',
|
'ticketDetail.linkedTickets': 'Linked tickets',
|
||||||
'ticketDetail.linkedTicketsDescription':
|
'ticketDetail.linkedTicketsDescription':
|
||||||
'Track cross-ticket relationships with project-defined link labels.',
|
'Track cross-ticket relationships with project-defined link labels.',
|
||||||
'ticketDetail.linkType': 'Link type',
|
'ticketDetail.linkAction': 'Action',
|
||||||
'ticketDetail.linkedTicket': 'Linked ticket',
|
'ticketDetail.linkedTicket': 'Linked ticket',
|
||||||
'ticketDetail.saveLink': 'Save link',
|
'ticketDetail.saveLink': 'Save link',
|
||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
@@ -1141,10 +1227,6 @@ export const en = {
|
|||||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
'ticketRelationships.addLink': 'Add or update a link',
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
'ticketRelationships.directionHelp':
|
|
||||||
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
|
||||||
'ticketRelationships.outgoing': 'Outgoing',
|
|
||||||
'ticketRelationships.incoming': 'Incoming',
|
|
||||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
'ticketRelationships.noTypes':
|
'ticketRelationships.noTypes':
|
||||||
'No link types are configured for this project.',
|
'No link types are configured for this project.',
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useNativeApp } from '@/composables/useNativeApp';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
|
import { home } from '@/routes';
|
||||||
|
import { edit } from '@/routes/native-app';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const { state, busy, error, act, poll } = useNativeApp();
|
||||||
|
const page = usePage();
|
||||||
|
const signedIn = computed(() => !!page.props.auth.user);
|
||||||
|
const address = ref('');
|
||||||
|
const confirmSwitch = ref(false);
|
||||||
|
const confirmInstall = ref(false);
|
||||||
|
const updates = computed(() => state.value?.updates);
|
||||||
|
watch(
|
||||||
|
() => updates.value?.version,
|
||||||
|
() => {
|
||||||
|
confirmInstall.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const breadcrumbs = computed(() => [
|
||||||
|
{ title: t('nativeApp.title'), href: edit() },
|
||||||
|
]);
|
||||||
|
watch(
|
||||||
|
() => state.value?.workspace,
|
||||||
|
(workspace) => {
|
||||||
|
if (workspace) {
|
||||||
|
address.value = workspace;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(address, () => {
|
||||||
|
confirmSwitch.value = false;
|
||||||
|
});
|
||||||
|
const knownErrors: Record<string, TranslationKey> = {
|
||||||
|
invalidServer: 'nativeApp.invalidServer',
|
||||||
|
storageError: 'nativeApp.storageError',
|
||||||
|
networkError: 'nativeApp.networkError',
|
||||||
|
metadataError: 'nativeApp.metadataError',
|
||||||
|
signatureError: 'nativeApp.signatureError',
|
||||||
|
verificationError: 'nativeApp.verificationError',
|
||||||
|
installationError: 'nativeApp.installationError',
|
||||||
|
preferencesError: 'nativeApp.preferencesError',
|
||||||
|
updateBusy: 'nativeApp.updateBusy',
|
||||||
|
};
|
||||||
|
const errorMessage = computed(() => {
|
||||||
|
const value = error.value ?? updates.value?.error;
|
||||||
|
return value ? t(knownErrors[value] ?? 'nativeApp.bridgeError') : null;
|
||||||
|
});
|
||||||
|
const progress = computed(() =>
|
||||||
|
updates.value?.total
|
||||||
|
? Math.min(
|
||||||
|
100,
|
||||||
|
Math.round(
|
||||||
|
(updates.value.downloaded / updates.value.total) * 100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let active = true;
|
||||||
|
async function refresh() {
|
||||||
|
if (state.value) {
|
||||||
|
await poll();
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
timer = setTimeout(refresh, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
active = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
async function switchWorkspace() {
|
||||||
|
if (await act('setWorkspace', { server: address.value })) {
|
||||||
|
confirmSwitch.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function installUpdate() {
|
||||||
|
const version = updates.value?.version;
|
||||||
|
if (version && (await act('installUpdate', { version }))) {
|
||||||
|
confirmInstall.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setAutomatic(event: Event) {
|
||||||
|
void act('setUpdatePreferences', {
|
||||||
|
automatic: (event.target as HTMLInputElement).checked,
|
||||||
|
intervalHours: updates.value?.preferences.intervalHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setIntervalHours(event: Event) {
|
||||||
|
void act('setUpdatePreferences', {
|
||||||
|
automatic: updates.value?.preferences.automatic,
|
||||||
|
intervalHours: Number((event.target as HTMLSelectElement).value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="signedIn ? AppLayout : 'main'"
|
||||||
|
:breadcrumbs="breadcrumbs"
|
||||||
|
class="min-h-dvh"
|
||||||
|
>
|
||||||
|
<Head :title="t('nativeApp.title')" />
|
||||||
|
<component
|
||||||
|
:is="signedIn ? SettingsLayout : 'div'"
|
||||||
|
:class="signedIn ? '' : 'mx-auto grid max-w-3xl gap-6 p-6'"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-if="!signedIn"
|
||||||
|
:href="home()"
|
||||||
|
class="text-primary underline"
|
||||||
|
>{{ t('auth.home') }}</Link
|
||||||
|
>
|
||||||
|
<header class="grid gap-2">
|
||||||
|
<h2 class="text-title">{{ t('nativeApp.title') }}</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('nativeApp.description') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<p v-if="!state" role="status">{{ t('nativeApp.unavailable') }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<form class="grid gap-4" @submit.prevent="confirmSwitch = true">
|
||||||
|
<h3 class="text-lg font-semibold">
|
||||||
|
{{ t('nativeApp.workspace') }}
|
||||||
|
</h3>
|
||||||
|
<Label for="native-server">{{
|
||||||
|
t('nativeApp.server')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="native-server"
|
||||||
|
v-model="address"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
autocomplete="url"
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('nativeApp.serverHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy || address === state.workspace"
|
||||||
|
class="justify-self-start"
|
||||||
|
>{{ t('nativeApp.switch') }}</Button
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="confirmSwitch"
|
||||||
|
class="grid gap-3 rounded-lg border p-4"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('nativeApp.switch')"
|
||||||
|
>
|
||||||
|
<p>{{ t('nativeApp.switchWarning') }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="switchWorkspace"
|
||||||
|
>{{ t('nativeApp.confirmSwitch') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="confirmSwitch = false"
|
||||||
|
>{{ t('nativeApp.cancel') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<section
|
||||||
|
v-if="updates"
|
||||||
|
class="grid gap-4 border-t pt-6"
|
||||||
|
aria-labelledby="native-updates"
|
||||||
|
>
|
||||||
|
<h3 id="native-updates" class="text-lg font-semibold">
|
||||||
|
{{ t('nativeApp.updates') }}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
t('nativeApp.version', {
|
||||||
|
version: updates.currentVersion,
|
||||||
|
build: updates.build,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p role="status" aria-live="polite">
|
||||||
|
{{ t(`nativeApp.phase.${updates.phase}`) }}
|
||||||
|
</p>
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
['downloading', 'verifying', 'installing'].includes(
|
||||||
|
updates.phase,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<progress
|
||||||
|
:value="progress"
|
||||||
|
max="100"
|
||||||
|
class="w-full"
|
||||||
|
:aria-label="t('nativeApp.progress')"
|
||||||
|
/>
|
||||||
|
<span v-if="progress !== undefined"
|
||||||
|
>{{ progress }}%</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<Label class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="updates.preferences.automatic"
|
||||||
|
:disabled="busy"
|
||||||
|
@change="setAutomatic"
|
||||||
|
/>
|
||||||
|
{{ t('nativeApp.automatic') }}
|
||||||
|
</Label>
|
||||||
|
<Label for="native-update-interval">{{
|
||||||
|
t('nativeApp.interval')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="native-update-interval"
|
||||||
|
:value="updates.preferences.intervalHours"
|
||||||
|
:disabled="busy"
|
||||||
|
class="bg-background rounded-md border p-2"
|
||||||
|
@change="setIntervalHours"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="hours in [1, 6, 24]"
|
||||||
|
:key="hours"
|
||||||
|
:value="hours"
|
||||||
|
>
|
||||||
|
{{ t('nativeApp.hours', { hours }) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('nativeApp.updateHelp') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
:disabled="busy"
|
||||||
|
class="justify-self-start"
|
||||||
|
@click="act('checkForUpdates')"
|
||||||
|
>{{ t('nativeApp.check') }}</Button
|
||||||
|
>
|
||||||
|
<template v-if="updates.version">
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
t('nativeApp.availableVersion', {
|
||||||
|
version: updates.version,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="updates.notes" class="whitespace-pre-wrap">
|
||||||
|
{{ updates.notes }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
:disabled="busy"
|
||||||
|
class="justify-self-start"
|
||||||
|
@click="confirmInstall = true"
|
||||||
|
>{{ t('nativeApp.install') }}</Button
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="confirmInstall"
|
||||||
|
class="grid gap-3 rounded-lg border p-4"
|
||||||
|
role="group"
|
||||||
|
:aria-label="t('nativeApp.install')"
|
||||||
|
>
|
||||||
|
<p>{{ t('nativeApp.installWarning') }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
:disabled="busy"
|
||||||
|
@click="installUpdate"
|
||||||
|
>{{ t('nativeApp.installRestart') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="confirmInstall = false"
|
||||||
|
>{{ t('nativeApp.cancel') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
<p v-else class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t('nativeApp.mobileVersion', { version: state.version })
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="errorMessage" class="text-destructive" role="alert">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="justify-self-start"
|
||||||
|
@click="act('openSettings')"
|
||||||
|
>{{ t('nativeApp.recovery') }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</component>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NativeBridge } from './native-app';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
kanbanNative?: NativeBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type NativeUpdatePhase =
|
||||||
|
| 'idle'
|
||||||
|
| 'checking'
|
||||||
|
| 'available'
|
||||||
|
| 'current'
|
||||||
|
| 'downloading'
|
||||||
|
| 'verifying'
|
||||||
|
| 'installing'
|
||||||
|
| 'restarting'
|
||||||
|
| 'error';
|
||||||
|
export type NativeUpdates = {
|
||||||
|
phase: NativeUpdatePhase;
|
||||||
|
currentVersion: string;
|
||||||
|
build: string;
|
||||||
|
version: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
downloaded: number;
|
||||||
|
total: number | null;
|
||||||
|
error: string | null;
|
||||||
|
preferences: { automatic: boolean; intervalHours: 1 | 6 | 24 };
|
||||||
|
};
|
||||||
|
export type NativeAppState = {
|
||||||
|
apiVersion: 1;
|
||||||
|
platform: 'macos' | 'ios';
|
||||||
|
version: string;
|
||||||
|
workspace: string;
|
||||||
|
capabilities: string[];
|
||||||
|
updates?: NativeUpdates;
|
||||||
|
};
|
||||||
|
export type NativeAction =
|
||||||
|
| 'getState'
|
||||||
|
| 'settingsReady'
|
||||||
|
| 'openSettings'
|
||||||
|
| 'setWorkspace'
|
||||||
|
| 'checkForUpdates'
|
||||||
|
| 'setUpdatePreferences'
|
||||||
|
| 'installUpdate';
|
||||||
|
export type NativeBridge = {
|
||||||
|
version: number;
|
||||||
|
request: (
|
||||||
|
action: NativeAction,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ChainedCommands } from '@tiptap/core';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
|
||||||
|
export type TicketDescriptionEditorProps = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DescriptionToolbarAction = {
|
||||||
|
label: TranslationKey;
|
||||||
|
icon: Component;
|
||||||
|
active?: string;
|
||||||
|
attributes?: Record<string, unknown>;
|
||||||
|
run: (chain: ChainedCommands) => ChainedCommands;
|
||||||
|
};
|
||||||
@@ -67,3 +67,14 @@ export type TicketBranchFormData = {
|
|||||||
branch_name: string;
|
branch_name: string;
|
||||||
repository_connection_id: string;
|
repository_connection_id: string;
|
||||||
};
|
};
|
||||||
|
export type TicketLinkFormData = {
|
||||||
|
ticket_link_type_id: number | string;
|
||||||
|
link_direction: 'source' | 'counterpart';
|
||||||
|
linked_ticket_id: number | string;
|
||||||
|
};
|
||||||
|
export type TicketLinkActionOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
ticketLinkTypeId: number;
|
||||||
|
direction: TicketLinkFormData['link_direction'];
|
||||||
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::delete('/', [ProjectController::class, 'destroy'])
|
Route::delete('/', [ProjectController::class, 'destroy'])
|
||||||
->middleware('can:access-admin')
|
->middleware('can:admin-project,project')
|
||||||
->name('destroy');
|
->name('destroy');
|
||||||
|
|
||||||
Route::middleware('can:admin-project,project')->group(function (): void {
|
Route::middleware('can:admin-project,project')->group(function (): void {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ use App\Http\Controllers\Settings\SecurityController;
|
|||||||
use App\Http\Controllers\Settings\UserApplicationController;
|
use App\Http\Controllers\Settings\UserApplicationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('settings')->group(function (): void {
|
||||||
|
Route::inertia('app', 'settings/App')->name('native-app.edit');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware('auth')
|
Route::middleware('auth')
|
||||||
->prefix('settings')
|
->prefix('settings')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
|
|||||||
@@ -291,7 +291,14 @@ class ProjectApiTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.key', 'CORE');
|
->assertJsonPath('data.key', 'CORE');
|
||||||
|
|
||||||
$this->syncGlobalPermissions($admin, ['admin']);
|
$this->withApplicationKey($user)
|
||||||
|
->deleteJson(route('api.projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $admin, ['admin']);
|
||||||
|
$this->assertTrue($admin->can('admin-project', $project));
|
||||||
|
|
||||||
$this->withApplicationKey($admin)
|
$this->withApplicationKey($admin)
|
||||||
->deleteJson(route('api.projects.destroy', $project), [
|
->deleteJson(route('api.projects.destroy', $project), [
|
||||||
|
|||||||
@@ -293,6 +293,49 @@ class TicketApiTest extends TestCase
|
|||||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
|
||||||
|
{
|
||||||
|
[$user, $project] = $this->projectContext();
|
||||||
|
$status = $project->ticketStatuses()->firstOrFail();
|
||||||
|
$blockingTicket = $this->ticket($project, $status);
|
||||||
|
$blockedTicket = $this->ticket($project, $status, 2);
|
||||||
|
$linkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Blocks',
|
||||||
|
'counterpart_label' => 'Blocked by',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||||
|
'linked_ticket_id' => $blockingTicket->id,
|
||||||
|
'ticket_link_type_id' => $linkType->id,
|
||||||
|
'link_direction' => 'counterpart',
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
|
||||||
|
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
|
||||||
|
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
|
||||||
|
->assertJsonPath('meta.message', 'Blocked by link was saved.');
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||||
|
'linked_ticket_id' => $blockingTicket->id,
|
||||||
|
'ticket_link_type_id' => $linkType->id,
|
||||||
|
'link_direction' => 'invalid',
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('link_direction');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_ticket_links_and_branch_endpoints_are_available(): void
|
public function test_ticket_links_and_branch_endpoints_are_available(): void
|
||||||
{
|
{
|
||||||
Http::preventStrayRequests();
|
Http::preventStrayRequests();
|
||||||
|
|||||||
@@ -817,6 +817,75 @@ class ProjectControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_admins_can_delete_their_own_projects_and_reuse_the_key(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
|
'name' => 'Core Platform',
|
||||||
|
'key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||||
|
$baseTemplateId = $project->project_template_id;
|
||||||
|
$this->assertTrue($user->can('admin-project', $project));
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.index'));
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
|
||||||
|
$this->assertDatabaseMissing('project_templates', ['id' => $baseTemplateId]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
|
'name' => 'New Core Platform',
|
||||||
|
'key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('projects', [
|
||||||
|
'key' => 'CORE',
|
||||||
|
'name' => 'New Core Platform',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_deleting_a_project_preserves_its_published_template_and_linked_projects(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
$project = Project::factory()->create(['key' => 'CORE']);
|
||||||
|
$otherProject = Project::factory()->create(['key' => 'OTHER']);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $project->id,
|
||||||
|
'is_published' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update(['project_template_id' => $template->id]);
|
||||||
|
$otherProject->update(['project_template_id' => $template->id]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->delete(route('projects.destroy', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.index'));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_templates', [
|
||||||
|
'id' => $template->id,
|
||||||
|
'source_project_id' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('projects', [
|
||||||
|
'id' => $otherProject->id,
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure project deletion requires the exact project key confirmation.
|
* Ensure project deletion requires the exact project key confirmation.
|
||||||
*/
|
*/
|
||||||
@@ -841,16 +910,18 @@ class ProjectControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure non-admin users cannot delete projects.
|
* Ensure users without project administration cannot delete projects.
|
||||||
*/
|
*/
|
||||||
public function test_non_admin_users_cannot_delete_projects(): void
|
public function test_project_readers_cannot_delete_projects(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$otherProject = Project::factory()->create();
|
||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['admin']);
|
$this->syncProjectPermissions($otherProject, $user, ['admin']);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->delete(route('projects.destroy', $project), [
|
->delete(route('projects.destroy', $project), [
|
||||||
|
|||||||
@@ -83,6 +83,74 @@ class ProjectTicketLinkControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create(['key' => 'CORE']);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
|
||||||
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||||
|
'label' => 'Blocks',
|
||||||
|
'counterpart_label' => 'Blocked by',
|
||||||
|
]);
|
||||||
|
$blockingTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 1,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
$blockedTicket = Ticket::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'CORE-2',
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_type_id' => $taskType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||||
|
'linked_ticket_id' => $blockingTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
'link_direction' => 'counterpart',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
|
||||||
|
->assertSessionHas('status', 'Blocked by link was saved.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ticket_links', [
|
||||||
|
'source_ticket_id' => $blockingTicket->id,
|
||||||
|
'target_ticket_id' => $blockedTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'ticket_id' => $blockedTicket->id,
|
||||||
|
'summary' => 'Linked CORE-1 as Blocked by.',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'ticket_id' => $blockingTicket->id,
|
||||||
|
'summary' => 'Linked CORE-2 as Blocks.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||||
|
'linked_ticket_id' => $blockingTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
'link_direction' => 'source',
|
||||||
|
])
|
||||||
|
->assertSessionHas('status', 'Blocks link was saved.');
|
||||||
|
|
||||||
|
$this->assertSame(1, TicketLink::query()->count());
|
||||||
|
$this->assertDatabaseHas('ticket_links', [
|
||||||
|
'source_ticket_id' => $blockedTicket->id,
|
||||||
|
'target_ticket_id' => $blockingTicket->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
||||||
*/
|
*/
|
||||||
@@ -144,6 +212,15 @@ class ProjectTicketLinkControllerTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||||
->assertSessionHasErrors('linked_ticket_id');
|
->assertSessionHasErrors('linked_ticket_id');
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||||
|
'linked_ticket_id' => $foreignTicket->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
'link_direction' => 'sideways',
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('link_direction');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NativeAppSettingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_app_settings_are_accessible_before_and_after_login_without_server_side_device_state(): void
|
||||||
|
{
|
||||||
|
$this->withoutVite();
|
||||||
|
$this->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/App')->where('auth.user', null)->missing('workspace')->missing('updates'));
|
||||||
|
$this->actingAs(User::factory()->unverified()->create(['must_change_password_on_next_login' => true]))
|
||||||
|
->get(route('native-app.edit'))->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/App')->missing('workspace')->missing('updates'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_native_capabilities_rendering_and_actions(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/native-app.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Native app settings checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketDescriptionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_description_formatting_and_safe_rendering(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
|
||||||
|
{
|
||||||
|
$this->withoutVite();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view', 'write']);
|
||||||
|
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
|
||||||
|
$payload = [
|
||||||
|
'title' => 'Formatted description',
|
||||||
|
'description' => $description,
|
||||||
|
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
|
||||||
|
->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$ticket = $project->tickets()->sole();
|
||||||
|
$this->assertSame($description, $ticket->description);
|
||||||
|
$this->get(route('projects.tickets.show', [$project, $ticket]))
|
||||||
|
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
|
||||||
|
|
||||||
|
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => $updated, 'is_blocked' => false,
|
||||||
|
])->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$this->assertSame($updated, $ticket->fresh()->description);
|
||||||
|
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
|
||||||
|
])->assertSessionHasErrors('description');
|
||||||
|
$this->assertSame($updated, $ticket->fresh()->description);
|
||||||
|
|
||||||
|
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||||
|
...$payload, 'description' => '', 'is_blocked' => false,
|
||||||
|
])->assertSessionHasNoErrors()->assertRedirect();
|
||||||
|
$this->assertNull($ticket->fresh()->description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { initializeNativeApp, useNativeApp, registerNativeRecovery } =
|
||||||
|
await server.ssrLoadModule('/resources/js/composables/useNativeApp.ts');
|
||||||
|
const { isNativeAppState } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/native-app.ts',
|
||||||
|
);
|
||||||
|
const App = (
|
||||||
|
await server.ssrLoadModule('/resources/js/pages/settings/App.vue')
|
||||||
|
).default;
|
||||||
|
const Appearance = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/Appearance.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (signedIn = false, component = App) => {
|
||||||
|
const result = await createInertiaApp({
|
||||||
|
page: {
|
||||||
|
component: 'settings/App',
|
||||||
|
url: '/settings/app',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: signedIn
|
||||||
|
? { name: 'Test', email: 'test@example.test' }
|
||||||
|
: null,
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance('system')),
|
||||||
|
});
|
||||||
|
return result.body;
|
||||||
|
};
|
||||||
|
await initializeNativeApp();
|
||||||
|
assert.match(await render(), /compatible KanbanGit app/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await render(true, Appearance),
|
||||||
|
/href="\/settings\/app"/,
|
||||||
|
);
|
||||||
|
let calls = [];
|
||||||
|
let current = {
|
||||||
|
apiVersion: 1,
|
||||||
|
platform: 'ios',
|
||||||
|
version: '1.0.0',
|
||||||
|
workspace: 'https://example.test/app/',
|
||||||
|
capabilities: ['workspace'],
|
||||||
|
};
|
||||||
|
const bridge = {
|
||||||
|
version: 2,
|
||||||
|
request: async (action, payload) => {
|
||||||
|
calls.push([action, payload]);
|
||||||
|
return structuredClone(current);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.window = { kanbanNative: bridge };
|
||||||
|
await initializeNativeApp();
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
bridge.version = 1;
|
||||||
|
current.apiVersion = 2;
|
||||||
|
await initializeNativeApp();
|
||||||
|
assert.equal(useNativeApp().state.value, null);
|
||||||
|
current.apiVersion = 1;
|
||||||
|
await initializeNativeApp();
|
||||||
|
assert.equal(calls.at(-1)[0], 'settingsReady');
|
||||||
|
delete globalThis.window;
|
||||||
|
let html = await render();
|
||||||
|
assert.match(html, /value="https:\/\/example.test\/app\/"/);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
html,
|
||||||
|
/Check for updates now|Install update|native-update-interval/,
|
||||||
|
);
|
||||||
|
assert.match(await render(true, Appearance), /href="\/settings\/app"/);
|
||||||
|
current = {
|
||||||
|
...current,
|
||||||
|
platform: 'macos',
|
||||||
|
capabilities: ['workspace', 'updates'],
|
||||||
|
updates: {
|
||||||
|
phase: 'available',
|
||||||
|
currentVersion: '1.0.0',
|
||||||
|
build: '1',
|
||||||
|
version: '1.1.0',
|
||||||
|
notes: '<script>bad()</script>',
|
||||||
|
downloaded: 0,
|
||||||
|
total: null,
|
||||||
|
error: null,
|
||||||
|
preferences: { automatic: true, intervalHours: 6 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
globalThis.window = { kanbanNative: bridge };
|
||||||
|
await useNativeApp().poll();
|
||||||
|
delete globalThis.window;
|
||||||
|
html = await render(true);
|
||||||
|
assert.match(html, /Check for updates now/);
|
||||||
|
assert.match(html, /Install update/);
|
||||||
|
assert.match(html, /<script>bad/);
|
||||||
|
assert.doesNotMatch(html, /<script>bad/);
|
||||||
|
assert.match(html, /id="native-update-interval"[^>]*value="6"/);
|
||||||
|
for (const phase of [
|
||||||
|
'checking',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
'error',
|
||||||
|
'current',
|
||||||
|
]) {
|
||||||
|
current.updates.phase = phase;
|
||||||
|
current.updates.downloaded = 40;
|
||||||
|
current.updates.total = 100;
|
||||||
|
globalThis.window = { kanbanNative: bridge };
|
||||||
|
await useNativeApp().poll();
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'checking',
|
||||||
|
'downloading',
|
||||||
|
'verifying',
|
||||||
|
'installing',
|
||||||
|
'restarting',
|
||||||
|
].includes(phase)
|
||||||
|
) {
|
||||||
|
const before = calls.length;
|
||||||
|
assert.equal(await useNativeApp().act('checkForUpdates'), false);
|
||||||
|
assert.equal(calls.length, before);
|
||||||
|
}
|
||||||
|
delete globalThis.window;
|
||||||
|
html = await render();
|
||||||
|
if (phase === 'downloading') {
|
||||||
|
assert.match(html, /40%/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const invalid of [
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ ...current, apiVersion: 2 },
|
||||||
|
{ ...current, platform: 'unsupported' },
|
||||||
|
{ ...current, updates: { ...current.updates, phase: 'madeUp' } },
|
||||||
|
]) {
|
||||||
|
assert.equal(isNativeAppState(invalid), false);
|
||||||
|
}
|
||||||
|
globalThis.window = { kanbanNative: bridge };
|
||||||
|
calls = [];
|
||||||
|
const listeners = {};
|
||||||
|
globalThis.window.addEventListener = (name, callback) => {
|
||||||
|
listeners[name] = callback;
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
addEventListener: (name, callback) => {
|
||||||
|
listeners[name] = callback;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
registerNativeRecovery();
|
||||||
|
for (const name of ['offline', 'inertia:exception', 'inertia:invalid']) {
|
||||||
|
listeners[name]({ detail: { response: { status: 503 } } });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(calls.at(-1)[0], 'openSettings');
|
||||||
|
}
|
||||||
|
const count = calls.length;
|
||||||
|
listeners['inertia:invalid']({ detail: { response: { status: 200 } } });
|
||||||
|
assert.equal(calls.length, count);
|
||||||
|
delete globalThis.document;
|
||||||
|
calls = [];
|
||||||
|
await useNativeApp().act('setUpdatePreferences', {
|
||||||
|
automatic: false,
|
||||||
|
intervalHours: 24,
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls[0], [
|
||||||
|
'setUpdatePreferences',
|
||||||
|
{ automatic: false, intervalHours: 24 },
|
||||||
|
]);
|
||||||
|
await useNativeApp().act('setWorkspace', { server: 'https://new.test/' });
|
||||||
|
assert.deepEqual(calls.at(-1), [
|
||||||
|
'setWorkspace',
|
||||||
|
{ server: 'https://new.test/' },
|
||||||
|
]);
|
||||||
|
let resolve;
|
||||||
|
bridge.request = () =>
|
||||||
|
new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
const first = useNativeApp().act('setWorkspace', {
|
||||||
|
server: 'https://new.test/',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await useNativeApp().act('setWorkspace', {
|
||||||
|
server: 'https://duplicate.test/',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
resolve(null);
|
||||||
|
await first;
|
||||||
|
bridge.request = async () => {
|
||||||
|
throw new Error('invalidServer');
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
await useNativeApp().act('setWorkspace', {
|
||||||
|
server: 'http://invalid.test',
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
delete globalThis.window;
|
||||||
|
assert.match(await render(), /Enter a valid HTTPS/);
|
||||||
|
console.log('Native app settings checks passed.');
|
||||||
|
} finally {
|
||||||
|
delete globalThis.window;
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { createApp, h, nextTick, reactive, ref } from 'vue';
|
||||||
|
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
|
||||||
|
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
|
||||||
|
const original = 'First line\nSecond line\n\n**Existing bold**';
|
||||||
|
const state = reactive({
|
||||||
|
value: original,
|
||||||
|
disabled: false,
|
||||||
|
error: '',
|
||||||
|
result: 'Running browser checks…',
|
||||||
|
updates: 0,
|
||||||
|
});
|
||||||
|
const component = ref(null);
|
||||||
|
const app = createApp({
|
||||||
|
render: () =>
|
||||||
|
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
|
||||||
|
h(
|
||||||
|
'h1',
|
||||||
|
{ class: 'text-xl font-semibold' },
|
||||||
|
'Ticket description editor',
|
||||||
|
),
|
||||||
|
h('p', { id: 'test-result', role: 'status' }, state.result),
|
||||||
|
h(TicketDescriptionEditor, {
|
||||||
|
ref: component,
|
||||||
|
id: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
modelValue: state.value,
|
||||||
|
disabled: state.disabled,
|
||||||
|
error: state.error,
|
||||||
|
'onUpdate:modelValue': (value) => {
|
||||||
|
state.value = value;
|
||||||
|
state.updates++;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
|
||||||
|
h(TicketDescription, { description: state.value }),
|
||||||
|
]),
|
||||||
|
}).use(createLocalization());
|
||||||
|
app.mount('#app');
|
||||||
|
const tick = async () => {
|
||||||
|
await nextTick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
};
|
||||||
|
const assert = (condition, message) => {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const button = (name) =>
|
||||||
|
[...document.querySelectorAll('button')].find(
|
||||||
|
(element) =>
|
||||||
|
element.getAttribute('aria-label') === name ||
|
||||||
|
element.textContent.trim() === name,
|
||||||
|
);
|
||||||
|
const click = async (name) => {
|
||||||
|
const element = button(name);
|
||||||
|
assert(element && !element.disabled, `Button available: ${name}`);
|
||||||
|
element.click();
|
||||||
|
await tick();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await tick();
|
||||||
|
const editor = component.value.$.setupState.editor;
|
||||||
|
assert(editor, 'Editor mounted');
|
||||||
|
assert(
|
||||||
|
state.value === original && state.updates === 0,
|
||||||
|
'Mount preserves exact source',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description')
|
||||||
|
.getAttribute('contenteditable') === 'true',
|
||||||
|
'Visual textbox is editable',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
editor.getText().includes('First line\nSecond line'),
|
||||||
|
'Legacy newlines retained',
|
||||||
|
);
|
||||||
|
state.value = 'Format this';
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
|
||||||
|
await click(name);
|
||||||
|
assert(
|
||||||
|
button(name).getAttribute('aria-pressed') === 'true',
|
||||||
|
`${name} active`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
state.value.includes('**') && state.value.includes('~~'),
|
||||||
|
'Formatting emits Markdown',
|
||||||
|
);
|
||||||
|
await click('Undo');
|
||||||
|
assert(!editor.isActive('strike'), 'Undo restores prior state');
|
||||||
|
await click('Redo');
|
||||||
|
assert(editor.isActive('strike'), 'Redo restores formatting');
|
||||||
|
await click('Markdown');
|
||||||
|
const source = document.querySelector('textarea#description');
|
||||||
|
assert(source.value === state.value, 'Source matches the editor output');
|
||||||
|
source.value = '## From source\n\n- One\n- Two';
|
||||||
|
source.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Visual editor');
|
||||||
|
assert(
|
||||||
|
document.querySelector('#description h2')?.textContent ===
|
||||||
|
'From source',
|
||||||
|
'Source updates visual editor',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelectorAll('#description li').length === 2,
|
||||||
|
'Lists render after source switch',
|
||||||
|
);
|
||||||
|
for (const [name, selector] of [
|
||||||
|
['Bullet list', 'ul li'],
|
||||||
|
['Numbered list', 'ol li'],
|
||||||
|
['Quote', 'blockquote'],
|
||||||
|
['Inline code', 'code'],
|
||||||
|
['Code block', 'pre code'],
|
||||||
|
['Heading 2', 'h2'],
|
||||||
|
['Heading 3', 'h3'],
|
||||||
|
]) {
|
||||||
|
state.value = `Check ${name}`;
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click(name);
|
||||||
|
assert(
|
||||||
|
document.querySelector(`#description ${selector}`),
|
||||||
|
`${name} renders`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.value = 'Link text';
|
||||||
|
await tick();
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click('Add or edit link');
|
||||||
|
assert(
|
||||||
|
document.activeElement.id === 'description-link',
|
||||||
|
'Link field receives focus',
|
||||||
|
);
|
||||||
|
const input = document.querySelector('#description-link');
|
||||||
|
input.value = 'javascript:alert(1)';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Apply link');
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description-link-error')
|
||||||
|
.textContent.includes('Enter a valid'),
|
||||||
|
'Unsafe link rejected',
|
||||||
|
);
|
||||||
|
input.value = 'https://example.test/docs';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await tick();
|
||||||
|
await click('Apply link');
|
||||||
|
assert(
|
||||||
|
state.value === '[Link text](https://example.test/docs)',
|
||||||
|
'Selection becomes a Markdown link',
|
||||||
|
);
|
||||||
|
await click('Remove link');
|
||||||
|
assert(state.value === 'Link text', 'Link can be removed');
|
||||||
|
state.disabled = true;
|
||||||
|
state.error = 'Description rejected.';
|
||||||
|
await tick();
|
||||||
|
assert(
|
||||||
|
document
|
||||||
|
.querySelector('#description')
|
||||||
|
.getAttribute('contenteditable') === 'false',
|
||||||
|
'Processing disables editing',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
document.querySelector('#description').getAttribute('aria-invalid') ===
|
||||||
|
'true',
|
||||||
|
'Server error reflected on editor',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
button('Bold').disabled && button('Markdown').disabled,
|
||||||
|
'Processing disables controls',
|
||||||
|
);
|
||||||
|
assert(state.value === 'Link text', 'Validation retains draft');
|
||||||
|
state.disabled = false;
|
||||||
|
state.error = '';
|
||||||
|
state.value = 'Reset draft';
|
||||||
|
await tick();
|
||||||
|
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
|
||||||
|
editor.commands.selectAll();
|
||||||
|
await click('Heading 1');
|
||||||
|
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
|
||||||
|
editor.commands.clearContent();
|
||||||
|
await tick();
|
||||||
|
assert(state.value === '', 'Clearing editor saves an empty description');
|
||||||
|
state.value = '| A | B |\n| - | - |\n| a | b |';
|
||||||
|
await tick();
|
||||||
|
assert(
|
||||||
|
document.querySelector('textarea#description').value === state.value,
|
||||||
|
'Unsupported content retained as source',
|
||||||
|
);
|
||||||
|
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
|
||||||
|
state.value =
|
||||||
|
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
|
||||||
|
await tick();
|
||||||
|
state.result =
|
||||||
|
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
||||||
|
} catch (error) {
|
||||||
|
state.result = `FAILED: ${error.message}`;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
window.addEventListener('pagehide', () => app.unmount(), { once: true });
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response) => {
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Description fixture ready on port 4186');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
safeDescriptionLink,
|
||||||
|
supportsVisualDescription,
|
||||||
|
parseDescription,
|
||||||
|
renderDescriptionNode,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/ticket-description.ts',
|
||||||
|
);
|
||||||
|
const { default: Description } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketDescription.vue',
|
||||||
|
);
|
||||||
|
const { default: Editor } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const render = (description) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(Description, { description }) }),
|
||||||
|
);
|
||||||
|
const markdown =
|
||||||
|
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
||||||
|
assert.equal(supportsVisualDescription(markdown), true);
|
||||||
|
const rendered = await render(markdown);
|
||||||
|
for (const snippet of [
|
||||||
|
'<h1>Title</h1>',
|
||||||
|
'<strong>Bold</strong>',
|
||||||
|
'<em>italic</em>',
|
||||||
|
'<s>strike</s>',
|
||||||
|
'<ul>',
|
||||||
|
'Nested',
|
||||||
|
'<ol start="3">',
|
||||||
|
'<blockquote>',
|
||||||
|
'<code>inline</code>',
|
||||||
|
'<tag>',
|
||||||
|
'rel="noopener noreferrer"',
|
||||||
|
'<hr>',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
rendered.includes(snippet),
|
||||||
|
`Missing formatted output: ${snippet}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.match(await render('First\nSecond'), /First<br>Second/);
|
||||||
|
assert.match(
|
||||||
|
await render('München 🚀 & details'),
|
||||||
|
/München 🚀 & details/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render('<img src=x onerror=alert(1)>'),
|
||||||
|
/<img/,
|
||||||
|
);
|
||||||
|
for (const value of [
|
||||||
|
'https://example.test/a?b=1&c=2',
|
||||||
|
'http://example.test',
|
||||||
|
'mailto:test@example.test',
|
||||||
|
'/projects/1',
|
||||||
|
'#section',
|
||||||
|
]) {
|
||||||
|
assert.equal(safeDescriptionLink(value), value);
|
||||||
|
}
|
||||||
|
for (const value of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,test',
|
||||||
|
'vbscript:test',
|
||||||
|
'//evil.test',
|
||||||
|
'/\\evil.test',
|
||||||
|
'\u0000https://example.test',
|
||||||
|
'https://',
|
||||||
|
'https://exa mple.test',
|
||||||
|
'java\nscript:alert(1)',
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
|
assert.equal(safeDescriptionLink(value), null);
|
||||||
|
}
|
||||||
|
for (const value of [
|
||||||
|
'<script>alert(1)</script>',
|
||||||
|
'<img src=x onerror=alert(1)>',
|
||||||
|
'| A | B |\n| - | - |\n| a | b |',
|
||||||
|
'',
|
||||||
|
'- [x] Done',
|
||||||
|
'[unsafe](javascript:alert%281%29)',
|
||||||
|
]) {
|
||||||
|
assert.equal(supportsVisualDescription(value), false, value);
|
||||||
|
const html = await render(value);
|
||||||
|
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
|
||||||
|
assert.ok(
|
||||||
|
html.includes(
|
||||||
|
value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>'),
|
||||||
|
),
|
||||||
|
'Original source is retained',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maliciousNode = {
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '<script>bad</script>',
|
||||||
|
marks: [
|
||||||
|
{ type: 'link', attrs: { href: 'javascript:bad()' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const safe = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h('div', {}, [renderDescriptionNode(maliciousNode)]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(safe, /<script|<a/);
|
||||||
|
assert.match(safe, /<script>/);
|
||||||
|
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
|
||||||
|
let updates = 0;
|
||||||
|
const initial = await renderToString(
|
||||||
|
createSSRApp({
|
||||||
|
render: () =>
|
||||||
|
h(Editor, {
|
||||||
|
id: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
modelValue: markdown,
|
||||||
|
error: 'Invalid description.',
|
||||||
|
disabled: true,
|
||||||
|
'onUpdate:modelValue': () => updates++,
|
||||||
|
}),
|
||||||
|
}).use(createLocalization()),
|
||||||
|
);
|
||||||
|
assert.match(initial, /id="description"/);
|
||||||
|
assert.match(initial, /aria-invalid="true"/);
|
||||||
|
assert.match(initial, /id="description-error"/);
|
||||||
|
assert.match(initial, /disabled/);
|
||||||
|
assert.equal(
|
||||||
|
updates,
|
||||||
|
0,
|
||||||
|
'Mounting must not rewrite existing descriptions',
|
||||||
|
);
|
||||||
|
console.log('Ticket description checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import tailwind from '@tailwindcss/vite';
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
import { createSSRApp, h } from 'vue';
|
import { createSSRApp, h, reactive, ref } from 'vue';
|
||||||
import { renderToString } from 'vue/server-renderer';
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
const serve = process.argv.includes('--serve');
|
const serve = process.argv.includes('--serve');
|
||||||
@@ -398,6 +398,11 @@ if (serve) {
|
|||||||
} else {
|
} else {
|
||||||
params.delete('noLinks');
|
params.delete('noLinks');
|
||||||
params.delete('linkRejected');
|
params.delete('linkRejected');
|
||||||
|
if (data.link_direction === 'counterpart') {
|
||||||
|
params.set('incoming', '1');
|
||||||
|
} else {
|
||||||
|
params.delete('incoming');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -458,6 +463,40 @@ if (serve) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
const { useTicketLinkActions } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useTicketLinkActions.ts',
|
||||||
|
);
|
||||||
|
const linkTypes = ref([
|
||||||
|
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
||||||
|
{ id: 2, label: 'Relates to', counterpart_label: 'Relates to' },
|
||||||
|
]);
|
||||||
|
const linkForm = reactive({
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'source',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
const { actions, selectedAction, selectedActionLabel } =
|
||||||
|
useTicketLinkActions(linkTypes, linkForm);
|
||||||
|
assert.deepEqual(
|
||||||
|
actions.value.map((action) => action.label),
|
||||||
|
['Blocks', 'Blocked by', 'Relates to'],
|
||||||
|
);
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocks');
|
||||||
|
selectedAction.value = '1:counterpart';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Blocked by');
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...linkForm },
|
||||||
|
{
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'counterpart',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
selectedAction.value = '2:source';
|
||||||
|
assert.equal(linkForm.ticket_link_type_id, 2);
|
||||||
|
assert.equal(linkForm.link_direction, 'source');
|
||||||
|
selectedAction.value = 'unknown';
|
||||||
|
assert.equal(selectedActionLabel.value, 'Relates to');
|
||||||
const { createLocalization } = await server.ssrLoadModule(
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
'/resources/js/lib/localization.ts',
|
'/resources/js/lib/localization.ts',
|
||||||
);
|
);
|
||||||
@@ -486,6 +525,24 @@ if (serve) {
|
|||||||
return result.body.split('id="main-content"')[1];
|
return result.body.split('id="main-content"')[1];
|
||||||
};
|
};
|
||||||
const html = await render();
|
const html = await render();
|
||||||
|
assert.ok(!html.includes('id="link_direction"'));
|
||||||
|
assert.ok(html.includes('Blocked by'));
|
||||||
|
const linkFields = [
|
||||||
|
...html.matchAll(/<fieldset\b[^>]*>[\s\S]*?<\/fieldset>/g),
|
||||||
|
].find(([fieldset]) =>
|
||||||
|
fieldset.includes('id="ticket_link_type_id"'),
|
||||||
|
)?.[0];
|
||||||
|
assert.ok(linkFields);
|
||||||
|
assert.equal((linkFields.match(/<select\b/g) ?? []).length, 2);
|
||||||
|
assert.match(
|
||||||
|
linkFields,
|
||||||
|
/<option value="1:source"[^>]*>Blocks<\/option>/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
linkFields,
|
||||||
|
/<option value="1:counterpart"[^>]*>Blocked by<\/option>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(linkFields, /Blocks \/ Blocked by/);
|
||||||
for (const text of [
|
for (const text of [
|
||||||
ticket.title,
|
ticket.title,
|
||||||
'Keep every existing ticket action available.',
|
'Keep every existing ticket action available.',
|
||||||
@@ -563,9 +620,9 @@ if (serve) {
|
|||||||
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
/<a(?=[^>]*href="https:\/\/git\.example\.test\/Engineering\/platform\/pulls\/12")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>/,
|
||||||
);
|
);
|
||||||
assert.match(html, /Save link/);
|
assert.match(html, /Save link/);
|
||||||
assert.match(html, /Outgoing/);
|
assert.doesNotMatch(html, /Outgoing/);
|
||||||
const incoming = await render('?incoming');
|
const incoming = await render('?incoming');
|
||||||
assert.match(incoming, /Incoming/);
|
assert.doesNotMatch(incoming, /Incoming/);
|
||||||
assert.match(incoming, /Blocked by/);
|
assert.match(incoming, /Blocked by/);
|
||||||
assert.match(await render('?noTypes'), /No link types are configured/);
|
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ try {
|
|||||||
'Platform delivery',
|
'Platform delivery',
|
||||||
'Child tickets',
|
'Child tickets',
|
||||||
'Nested child with a long title that must wrap on mobile',
|
'Nested child with a long title that must wrap on mobile',
|
||||||
'Incoming',
|
|
||||||
'Blocked by',
|
|
||||||
]) {
|
]) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await section()
|
await section()
|
||||||
@@ -52,6 +50,13 @@ try {
|
|||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.equal(
|
||||||
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.isVisible(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
const collapse = section().getByRole('button', {
|
const collapse = section().getByRole('button', {
|
||||||
name: 'Collapse children of ENG-41',
|
name: 'Collapse children of ENG-41',
|
||||||
});
|
});
|
||||||
@@ -68,7 +73,7 @@ try {
|
|||||||
);
|
);
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||||
await control('Link type').focus();
|
await control('Action').focus();
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await control('Linked ticket').evaluate(
|
await control('Linked ticket').evaluate(
|
||||||
@@ -118,9 +123,13 @@ try {
|
|||||||
await save().click();
|
await save().click();
|
||||||
assert.deepEqual((await request).postDataJSON(), {
|
assert.deepEqual((await request).postDataJSON(), {
|
||||||
ticket_link_type_id: 1,
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'source',
|
||||||
linked_ticket_id: 41,
|
linked_ticket_id: 41,
|
||||||
});
|
});
|
||||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocks', { exact: true })
|
||||||
|
.waitFor();
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => document.querySelector('#linked_ticket_id')?.value === '',
|
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||||
);
|
);
|
||||||
@@ -141,6 +150,27 @@ try {
|
|||||||
document.activeElement?.textContent.includes('Linked tickets'),
|
document.activeElement?.textContent.includes('Linked tickets'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await visit('?noLinks');
|
||||||
|
await control('Action').selectOption('1:counterpart');
|
||||||
|
await control('Linked ticket').selectOption('41');
|
||||||
|
await section()
|
||||||
|
.locator('p[aria-live="polite"]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
request = page.waitForRequest(
|
||||||
|
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||||
|
);
|
||||||
|
await save().click();
|
||||||
|
assert.deepEqual((await request).postDataJSON(), {
|
||||||
|
ticket_link_type_id: 1,
|
||||||
|
link_direction: 'counterpart',
|
||||||
|
linked_ticket_id: 41,
|
||||||
|
});
|
||||||
|
await section()
|
||||||
|
.locator('[data-ticket-link]')
|
||||||
|
.getByText('Blocked by', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
|
||||||
await visit('?rejectLink');
|
await visit('?rejectLink');
|
||||||
await control('Linked ticket').selectOption('41');
|
await control('Linked ticket').selectOption('41');
|
||||||
await save().click();
|
await save().click();
|
||||||
@@ -148,7 +178,7 @@ try {
|
|||||||
.getByText('Choose another ticket.', { exact: true })
|
.getByText('Choose another ticket.', { exact: true })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
assert.equal(await control('Linked ticket').inputValue(), '41');
|
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||||
for (const label of ['Link type', 'Linked ticket']) {
|
for (const label of ['Action', 'Linked ticket']) {
|
||||||
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||||
}
|
}
|
||||||
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||||
|
|||||||
Reference in New Issue
Block a user