Compare commits
19
Commits
macos-v0.2.5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba12e65e81 | ||
|
|
85b9409e3c | ||
|
|
f862a6a226 | ||
|
|
c8f494fe7c | ||
|
|
f7c3873c19 | ||
|
|
d8e1659f4b | ||
|
|
704fe5d241 | ||
|
|
73f6ab6918 | ||
|
|
6d4a823fe5 | ||
|
|
6054690607 | ||
|
|
723285f820 | ||
|
|
393d074bb7 | ||
|
|
3ec59ef367 | ||
|
|
10f88f6029 | ||
|
|
3b110f08ae | ||
|
|
1a022c257f | ||
|
|
ce36060ba1 | ||
|
|
f4434a3203 | ||
|
|
912cfc28c2 |
@@ -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
|
|
||||||
|
|||||||
@@ -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,13 @@ class DeleteProject
|
|||||||
{
|
{
|
||||||
$projectName = $project->name;
|
$projectName = $project->name;
|
||||||
|
|
||||||
$project->delete();
|
DB::transaction(function () use ($project): void {
|
||||||
|
$baseTemplate = $project->baseTemplate()->first();
|
||||||
|
|
||||||
|
$project->ticketStatuses()->delete();
|
||||||
|
$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.5",
|
"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": "Use WebKit's interactive back and forward gestures on iOS and macOS while retaining vertical scroll bounds."
|
"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.5"
|
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.5"
|
version = "0.2.6"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ fn build_navigation() {
|
|||||||
use std::{env, path::PathBuf, process::Command};
|
use std::{env, path::PathBuf, process::Command};
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=src/navigation.m");
|
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 output = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||||
let object = output.join("navigation.o");
|
let object = output.join("navigation.o");
|
||||||
|
let settings_object = output.join("settings.o");
|
||||||
let archive = output.join("libkanban_navigation.a");
|
let archive = output.join("libkanban_navigation.a");
|
||||||
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
let arch = match env::var("CARGO_CFG_TARGET_ARCH").unwrap().as_str() {
|
||||||
"aarch64" => "arm64",
|
"aarch64" => "arm64",
|
||||||
@@ -37,10 +39,31 @@ fn build_navigation() {
|
|||||||
.status()
|
.status()
|
||||||
.expect("Xcode clang is required")
|
.expect("Xcode clang is required")
|
||||||
.success());
|
.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")
|
assert!(Command::new("xcrun")
|
||||||
.args(["ar", "crs"])
|
.args(["ar", "crs"])
|
||||||
.arg(&archive)
|
.arg(&archive)
|
||||||
.arg(&object)
|
.arg(&object)
|
||||||
|
.arg(&settings_object)
|
||||||
.status()
|
.status()
|
||||||
.expect("Xcode ar is required")
|
.expect("Xcode ar is required")
|
||||||
.success());
|
.success());
|
||||||
|
|||||||
@@ -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,6 +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 settingsAvailable = false
|
||||||
|
private var toolbarHeight: NSLayoutConstraint!
|
||||||
private var appearance: UISegmentedControl!
|
private var appearance: UISegmentedControl!
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
@@ -165,7 +167,9 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
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 = [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),
|
||||||
@@ -201,6 +205,7 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
timeout?.cancel()
|
timeout?.cancel()
|
||||||
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
|
||||||
@@ -209,7 +214,11 @@ final class WorkspaceViewController: UIViewController, WKNavigationDelegate, WKU
|
|||||||
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
let scriptURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-navigation", withExtension: "js")!
|
||||||
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
let script = try! String(contentsOf: scriptURL, encoding: .utf8)
|
||||||
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
configuration.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||||
// Remote content receives no native message handlers or Tauri bridge.
|
let settingsURL = Bundle(for: WorkspaceViewController.self).url(forResource: "workspace-settings", withExtension: "js")!
|
||||||
|
let settingsScript = try! String(contentsOf: settingsURL, encoding: .utf8)
|
||||||
|
configuration.userContentController.addUserScript(WKUserScript(source: settingsScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
|
||||||
|
configuration.userContentController.addScriptMessageHandler(NativeSettingsHandler(controller: self), contentWorld: .page, name: "kanbanSettings")
|
||||||
|
setSettingsAvailable(false)
|
||||||
let webview = WKWebView(frame: .zero, configuration: configuration)
|
let webview = WKWebView(frame: .zero, configuration: configuration)
|
||||||
webview.navigationDelegate = self
|
webview.navigationDelegate = self
|
||||||
webview.uiDelegate = self
|
webview.uiDelegate = self
|
||||||
@@ -269,10 +278,48 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
guard server?.allows(url) == true else {
|
guard server?.allows(url) == true else {
|
||||||
@@ -299,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()
|
||||||
}
|
}
|
||||||
@@ -338,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,6 +135,83 @@ final class WorkspaceTests: XCTestCase {
|
|||||||
"Changing workspaces must retain native gesture navigation")
|
"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
|
@MainActor
|
||||||
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
private func waitFor(_ webview: WKWebView, _ expression: String) async throws {
|
||||||
for _ in 0..<100 {
|
for _ in 0..<100 {
|
||||||
@@ -201,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)
|
||||||
XCTAssertEqual(controller.workspace?.configuration.userContentController.userScripts.count, 1)
|
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)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
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 */; };
|
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 */; };
|
||||||
@@ -27,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 */
|
||||||
|
|
||||||
@@ -45,12 +47,14 @@
|
|||||||
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>"; };
|
||||||
@@ -65,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>"; };
|
||||||
@@ -156,6 +161,8 @@
|
|||||||
5F71F06AB639140AB9714425 /* metadata.rs */,
|
5F71F06AB639140AB9714425 /* metadata.rs */,
|
||||||
8C0F963909020429B3D257D5 /* navigation.m */,
|
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;
|
||||||
@@ -193,6 +200,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
7676FB8CDCD1B07BCE413319 /* workspace-navigation.js */,
|
||||||
|
BD66652CEDB88ADBAD0A87BA /* workspace-settings.js */,
|
||||||
);
|
);
|
||||||
name = ui;
|
name = ui;
|
||||||
path = ../../../ui;
|
path = ../../../ui;
|
||||||
@@ -334,6 +342,7 @@
|
|||||||
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 */,
|
99291BB0C1DE7714FB32F554 /* workspace-navigation.js in Resources */,
|
||||||
|
99D76D98A2416CAA713186C2 /* workspace-settings.js in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -342,6 +351,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
0EC5CB2D7F774DBDCA69BCD8 /* workspace-navigation.js in Resources */,
|
||||||
|
EAD1D5A29503456CEED3B324 /* workspace-settings.js in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -441,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 = 5;
|
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;
|
||||||
@@ -457,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.5;
|
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;
|
||||||
@@ -618,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 = 5;
|
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;
|
||||||
@@ -634,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.5;
|
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.5"
|
MARKETING_VERSION: "0.2.6"
|
||||||
CURRENT_PROJECT_VERSION: "5"
|
CURRENT_PROJECT_VERSION: "6"
|
||||||
configs:
|
configs:
|
||||||
debug:
|
debug:
|
||||||
CODE_SIGN_IDENTITY: Apple Development
|
CODE_SIGN_IDENTITY: Apple Development
|
||||||
@@ -43,6 +43,7 @@ targets:
|
|||||||
sources:
|
sources:
|
||||||
- path: Sources
|
- path: Sources
|
||||||
- path: ../../../ui/workspace-navigation.js
|
- 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
|
||||||
@@ -116,6 +117,7 @@ targets:
|
|||||||
platform: iOS
|
platform: iOS
|
||||||
sources:
|
sources:
|
||||||
- path: ../../../ui/workspace-navigation.js
|
- 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
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -237,6 +246,8 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
install_workspace_navigation(webview.inner().cast());
|
install_workspace_navigation(webview.inner().cast());
|
||||||
})
|
})
|
||||||
.map_err(|_| "connectionError")?;
|
.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 let WindowEvent::CloseRequested { api, .. } = event {
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||||
@@ -256,6 +267,54 @@ fn open_workspace(app: &AppHandle, input: &str) -> Result<(), &'static str> {
|
|||||||
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" => {
|
||||||
@@ -464,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,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.5",
|
"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": "10",
|
"bundleVersion": "11",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"iOS": {
|
"iOS": {
|
||||||
"minimumSystemVersion": "17.0",
|
"minimumSystemVersion": "17.0",
|
||||||
"developmentTeam": "ZZX2KZGTHU",
|
"developmentTeam": "ZZX2KZGTHU",
|
||||||
"bundleVersion": "5"
|
"bundleVersion": "6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ fn main() {
|
|||||||
.setup(move |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("update-preferences.json"), r#"{"automatic":false,"intervalHours":6}"#)?;
|
||||||
if needs_setup {
|
if needs_setup {
|
||||||
if startup == "invalid" {
|
if startup == "invalid" {
|
||||||
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
std::fs::write(directory.join("active-server"), "invalid configuration")?;
|
||||||
@@ -118,9 +119,25 @@ 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';
|
||||||
@@ -129,7 +146,7 @@ fn main() {
|
|||||||
if (getComputedStyle(document.documentElement).overscrollBehaviorY !== 'none' || getComputedStyle(document.documentElement).overscrollBehaviorX !== 'auto') throw new Error('Scroll boundary policy missing');
|
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#"
|
||||||
@@ -161,12 +178,10 @@ 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 {
|
||||||
@@ -240,6 +255,19 @@ fn main() {
|
|||||||
closed.load(Ordering::SeqCst),
|
closed.load(Ordering::SeqCst),
|
||||||
"The close button must quit the application"
|
"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),
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|||||||
@@ -805,6 +805,9 @@ class ProjectControllerTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('board_columns', [
|
$this->assertDatabaseMissing('board_columns', [
|
||||||
'id' => $boardColumn->id,
|
'id' => $boardColumn->id,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseMissing('ticket_statuses', [
|
||||||
|
'id' => $ticketStatus->id,
|
||||||
|
]);
|
||||||
$this->assertDatabaseMissing('tickets', [
|
$this->assertDatabaseMissing('tickets', [
|
||||||
'id' => $ticket->id,
|
'id' => $ticket->id,
|
||||||
]);
|
]);
|
||||||
@@ -817,6 +820,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 +913,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