Compare commits

..
58 Commits
Author SHA1 Message Date
Twyco ba7e174e82 Merge pull request 'Feature/kan 31 ticket create edit forms' (#126) from feature/KAN-31-ticket-create-edit-forms into main
CI/CD / Publish immutable image (push) Successful in 4m13s
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 8m40s
Reviewed-on: #126
2026-09-12 11:34:10 +00:00
Eric d646f8b537 feat: KAN-30
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-12 13:33:53 +02:00
Twyco 509d7abf82 Merge pull request 'KAN-30 Responsive ticket detail view' (#125) from feature/KAN-30-ticket-detail-layout into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #125
2026-09-12 06:22:31 +00:00
Twyco 5cc1840592 feat(KAN-30): rebuild responsive ticket detail view 2026-09-12 06:22:31 +00:00
Twyco 11b06d9b8c Merge pull request 'KAN-29 Backlog and ticket tree' (#124) from feature/KAN-29-backlog-ticket-tree into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #124
2026-09-12 06:22:20 +00:00
Twyco b76b4adaec feat(KAN-29): rework backlog ticket tree
CI/CD / preconditions (pull_request) Successful in 1m1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m3s
2026-09-12 06:18:47 +00:00
Twyco c8fa76e08a fix Docker
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m43s
CI/CD / Publish immutable image (push) Successful in 3m51s
2026-09-12 02:43:19 +02:00
Twyco 87ae19f972 docker build npm fix
CI/CD / preconditions (push) Successful in 1m0s
CI/CD / test_and_check_app (push) Successful in 7m18s
CI/CD / Publish immutable image (push) Successful in 3m24s
2026-09-12 02:41:42 +02:00
Twyco a97451edfa fix
CI/CD / preconditions (push) Successful in 59s
CI/CD / test_and_check_app (push) Successful in 7m40s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:23:44 +02:00
Twyco 17b3e02bd8 fix
CI/CD / preconditions (push) Successful in 1m5s
CI/CD / test_and_check_app (push) Successful in 8m46s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-12 02:06:10 +02:00
Twyco d58fbd21ad fix: enable pnpm
CI/CD / preconditions (push) Successful in 57s
CI/CD / test_and_check_app (push) Successful in 8m40s
CI/CD / Publish immutable image (push) Failing after 1m23s
2026-09-11 18:40:54 +02:00
Twyco 002de1b293 fix: use pnpm in docker
CI/CD / preconditions (push) Successful in 59s
CI/CD / Publish immutable image (push) Failing after 1m25s
CI/CD / test_and_check_app (push) Successful in 8m24s
2026-09-11 16:25:02 +00:00
Twyco 707ebe6e48 switch to pnpm
CI/CD / preconditions (push) Successful in 2s
CI/CD / test_and_check_app (push) Successful in 13m54s
CI/CD / Publish immutable image (push) Failing after 1m27s
2026-09-11 17:39:12 +02:00
Twyco 4e29fc4526 Merge pull request 'fix: adjusted drag and drop' (#122) from feature/KAN-28-drag-and-drop-status into main
CI/CD / test_and_check_app (push) Successful in 5m42s
CI/CD / Publish immutable image (push) Successful in 2m30s
Reviewed-on: #122
2026-09-11 14:40:58 +00:00
Eric fab1e9c7a8 fix: adjusted drag and drop
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (push) Successful in 6m29s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Successful in 6m52s
2026-09-11 15:55:13 +02:00
Twyco ccd199a695 Merge pull request 'KAN-27 Responsive board' (#121) from feature/KAN-27-responsive-board into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
Reviewed-on: #121
2026-09-11 12:21:06 +00:00
Twyco f7b50e8715 feat(KAN-27): rebuild responsive project board
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:56 +02:00
Twyco 5147c8ed8d Merge pull request 'KAN-25 Personal dashboard' (#119) from feature/KAN-25-personal-dashboard into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #119
2026-09-11 12:20:42 +00:00
Twyco 3914fd5432 feat(KAN-25): build personal dashboard
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:27 +02:00
Twyco 4d6da2c5f5 Merge pull request 'KAN-26 Ticket cards and lists' (#120) from feature/KAN-26-ticket-cards-and-lists into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #120
2026-09-11 12:20:12 +00:00
Twyco 313e2d2396 feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:20:02 +02:00
Twyco 810d1850ae Merge pull request 'KAN-24 Project creation' (#118) from feature/KAN-24-project-creation into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #118
2026-09-11 12:19:50 +00:00
Twyco cd7fae3841 feat(KAN-24): rebuild project creation flow
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
2026-09-11 14:19:32 +02:00
Twyco 32e3496248 Merge pull request 'KAN-23 Project overview' (#117) from feature/KAN-23-project-overview into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #117
2026-09-11 12:19:16 +00:00
Twyco 0e78be0eae feat(KAN-23): rebuild project overview and entry pages
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:19:07 +02:00
Twyco 17caabbb3e Merge pull request 'KAN-22 Password confirmation pages' (#116) from feature/KAN-22-password-confirmation-pages into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 37s
Reviewed-on: #116
2026-09-11 12:18:52 +00:00
Twyco 3f4945c20d feat(KAN-22): rebuild password confirmation flows
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:18:45 +02:00
Twyco 65bf75ed77 Merge pull request 'KAN-21 Login, verification, and two-factor authentication' (#115) from feature/KAN-21-login-verification-two-factor into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #115
2026-09-11 12:18:31 +00:00
Twyco 802294d6a2 feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (pull_request) Canceled after 6s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:18:23 +02:00
Twyco 5e52e4fe78 Merge pull request 'KAN-20 Dialogs, feedback, and input protection' (#114) from feature/KAN-20-dialogs-feedback-input-protection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #114
2026-09-11 12:18:04 +00:00
Twyco f4a8bdd134 feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
2026-09-11 14:17:10 +02:00
Twyco 850ae337ca Merge pull request 'KAN-19 Responsive app navigation' (#113) from feature/KAN-19-responsive-app-navigation into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 6s
Reviewed-on: #113
2026-09-11 12:16:54 +00:00
Twyco 4adda6e53b feat(KAN-19): add responsive permission-aware navigation
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:16:43 +02:00
Twyco de6f4e5b2f Merge pull request 'KAN-18 Basic UI components' (#112) from feature/KAN-18-basic-ui-components into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #112
2026-09-11 12:14:09 +00:00
Twyco 2e2ddd5b74 feat(KAN-18): redesign foundational UI components
CI/CD / test_and_check_app (push) Canceled after 6s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-11 14:13:49 +02:00
Twyco c58196293f Merge pull request 'KAN-17 Emerald design system' (#111) from feature/KAN-17-emerald-design-system into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #111
2026-09-11 12:11:50 +00:00
Twyco 8657d75682 feat(KAN-17): add Emerald design system and resilient themes 2026-09-11 12:11:50 +00:00
Twyco 71d9d1b63e feat(KAN-16): add English localization foundation 2026-09-11 12:11:50 +00:00
Twyco 68995a9f4c Merge pull request 'Codex/mcp list ticket types' (#110) from codex/mcp-list-ticket-types into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 1m29s
Reviewed-on: #110
2026-09-11 12:05:54 +00:00
Twyco 3e998a483e remove ui docs
CI/CD / test_and_check_app (pull_request) Successful in 4m53s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / test_and_check_app (push) Successful in 4m38s
CI/CD / Publish immutable image (push) Skipped
2026-09-11 12:05:39 +00:00
Twyco ceffe4f4a4 Merge pull request 'Add MCP tool to list project ticket types' (#109) from codex/mcp-list-ticket-types into main
CI/CD / Publish immutable image (push) Successful in 2m31s
CI/CD / test_and_check_app (push) Successful in 4m2s
Reviewed-on: #109
2026-09-09 07:44:51 +00:00
Twyco ce01b8ec55 Add MCP tool to list project ticket types
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s
2026-09-09 07:44:41 +00:00
Twyco bd774e20f6 Merge pull request 'Add MCP ticket creation with full fields and parent support' (#108) from codex/mcp-create-ticket into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 42s
Reviewed-on: #108
2026-09-09 07:44:33 +00:00
Twyco d9cb8d0903 Add MCP ticket creation with full fields and parent support
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
2026-09-09 07:40:25 +00:00
Twyco 19e8ba89bf chroe: audit fix
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 2m34s
2026-09-09 09:40:10 +02:00
Twyco 642e49d7b0 feat: added ui docs
CI/CD / test_and_check_app (push) Failing after 2m11s
CI/CD / Publish immutable image (push) Skipped
2026-09-09 08:15:37 +02:00
Twyco 1d8fff1d51 formatted
CI/CD / test_and_check_app (push) Successful in 3m53s
CI/CD / Publish immutable image (push) Successful in 1m33s
2026-09-08 14:24:04 +02:00
Twyco bd7be4e41e Merge pull request 'epic/kan-3' (#95) from epic/kan-3 into main
CI/CD / test_and_check_app (push) Failing after 3m1s
CI/CD / Publish immutable image (push) Skipped
Reviewed-on: #95
2026-09-08 10:27:25 +00:00
Nico Schröder 9895fc36db fix: make project template inheritance granular and safe
CI/CD / test_and_check_app (push) Failing after 3m8s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 3m8s
CI/CD / Publish immutable image (pull_request) Skipped
2026-09-08 10:25:52 +00:00
Twyco fed6a282a4 Fix KAN-3 project template flows 2026-09-08 10:25:52 +00:00
Koda db8855a056 Move remaining project metadata ownership to templates 2026-09-08 10:25:52 +00:00
Koda 04b0d5e316 Add editable project template flow 2026-09-08 10:25:52 +00:00
Koda 49e887753c Add template switch and reset flows for existing projects 2026-09-08 10:25:52 +00:00
Koda b8a1d65122 Add template context to API and MCP project payloads 2026-09-08 10:25:52 +00:00
Koda 831014f7fe Show template inheritance overrides 2026-09-08 10:25:52 +00:00
Koda fef82cb88f Make projects depend on templates 2026-09-08 10:25:52 +00:00
Koda 40f9b473fc Add reusable project templates 2026-09-08 10:25:52 +00:00
Koda 5abfae2736 Add project duplication flow 2026-09-08 10:25:52 +00:00
271 changed files with 27692 additions and 11915 deletions
+67 -13
View File
@@ -1,18 +1,72 @@
name: test_and_check_app
description: Build the test image and run all application checks
name: "Lint and Test API"
description: "Run Pint and the Pest test suite for the Laravel API"
runs:
using: composite
steps:
- name: Test and check app
shell: bash
run: |
set -Eeuo pipefail
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
with:
php-version: "8.5"
extensions: mbstring, pdo_sqlite
coverage: none
tools: composer:v2
docker buildx build \
--file Dockerfile \
--target test \
--tag "local/kanbangit-test:${GITHUB_SHA}" \
--progress plain \
--load \
.
- name: Verify Composer lockfile
run: |
if [ ! -f composer.lock ]; then
echo "::error::api/composer.lock not found"
exit 1
fi
shell: bash
- name: Get Composer cache directory
id: composer_cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache Composer packages
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ${{ steps.composer_cache.outputs.dir }}
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/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: Build Vite app
run: pnpm run build
shell: bash
- name: Run ci check
run: composer run ci:check
shell: bash
+19 -2
View File
@@ -2,7 +2,13 @@ name: CI/CD
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
permissions:
contents: read
@@ -11,9 +17,19 @@ env:
REGISTRY: git.twyco.de
jobs:
test_and_check_app:
name: test_and_check_app
preconditions:
if: >
!(contains(github.event.head_commit.message, '[no-ci]') || contains(github.event.head_commit.message, '[skip-ci]'))
runs-on: ubuntu-latest
steps:
- name: Conditions met
run: |
echo "Conditions for CI/CD are met. Proceeding with the workflow."
test_and_check_app:
runs-on: ubuntu-latest
needs:
- preconditions
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
@@ -27,6 +43,7 @@ jobs:
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
needs:
- preconditions
- test_and_check_app
runs-on: ubuntu-latest
steps:
+3 -2
View File
@@ -55,8 +55,9 @@ COPY --from=node_binary /usr/local/ /usr/local/
COPY . /var/www
RUN composer install --prefer-dist --no-interaction --no-progress \
&& npm ci \
&& npm run build
&& corepack enable pnpm \
&& pnpm install --frozen-lockfile \
&& pnpm run build
FROM build AS test
@@ -2,6 +2,8 @@
namespace App\Actions\Notifications;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Enums\DiscordAllowedMentionType;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
{
public function __construct(
private readonly ListProjectNotificationActions $listProjectNotificationActions,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
]),
'templateVariables' => $this->templateVariables(),
];
}
@@ -2,11 +2,19 @@
namespace App\Actions\ProjectBoard;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\TicketStatus;
use Illuminate\Support\Arr;
class UpdateTicketStatusTransitions
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
/**
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
*/
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
->all();
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
$this->projectTemplateOverrideManager->mark(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
$this->refreshProjectBaseTemplate->handle(
$ticketStatus->project,
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
);
return $ticketStatus->fresh();
}
@@ -2,11 +2,19 @@
namespace App\Actions\ProjectTicketMetadata;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\Project;
use Illuminate\Support\Arr;
class UpdateProjectTicketMetadataSettings
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
/**
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
*/
@@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings
'show_author' => Arr::get($validated, 'show_author', false),
'show_story_points' => Arr::get($validated, 'show_story_points', false),
]);
$this->projectTemplateOverrideManager->mark(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
$this->refreshProjectBaseTemplate->handle(
$project,
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
);
return $project->fresh();
}
@@ -47,6 +47,7 @@ class BuildProjectBacklogPayload
'tickets' => TicketResource::collection(
Ticket::query()
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -29,6 +29,7 @@ class BuildProjectBoardPayload
'ticketStatuses',
'tickets' => fn ($query) => $query
->with([
'assignee',
'ticketPriority',
'ticketStatus.allowedTargetStatuses.boardColumn',
'ticketStatus.boardColumn',
@@ -9,6 +9,10 @@ use App\Models\Project;
class BuildProjectBoardSettingsPayload
{
public function __construct(
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
)->resolve(),
'project' => ProjectResource::make($project)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
]),
'ticketStatuses' => TicketStatusResource::collection(
$project->ticketStatuses()
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
class BuildProjectTicketMetadataSettingsPayload
{
public function __construct(
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
* @return array<string, mixed>
*/
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
'project' => ProjectResource::make($project)->resolve(),
'error' => $error,
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
]),
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
+65 -5
View File
@@ -6,25 +6,85 @@ use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class CreateProject
{
public function __construct(
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly PermissionService $permissionService,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/**
* @param array{key: string, name: string} $attributes
* @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array<int, string>|null} $attributes
*/
public function handle(array $attributes, User $actor): Project
{
$project = Project::query()->create($attributes);
return DB::transaction(function () use ($attributes, $actor): Project {
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
$baseTemplate = null;
$sourceTemplate = null;
$this->initializeProjectTicketMetadata->handle($project);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
if ($creationMode === 'template' && $sourceTemplateId !== null) {
$sourceTemplate = ProjectTemplate::query()
->published()
->findOrFail((int) $sourceTemplateId);
return $project;
$project->forceFill([
'project_template_id' => $sourceTemplate->id,
'template_overrides' => [],
])->saveQuietly();
} else {
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
'template_overrides' => [],
])->saveQuietly();
}
$project = $project->fresh() ?? $project;
$this->projectTemplateOverrideManager->withoutRecording(
fn () => $this->initializeProjectTicketMetadata->handle($project),
);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
$sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id'));
$this->duplicateProjectConfiguration->handle(
$sourceProject,
$project,
array_values(Arr::get($attributes, 'copy_areas', [])),
);
}
if ($creationMode === 'template' && $sourceTemplate !== null) {
$this->duplicateProjectConfiguration->apply(
$project->fresh() ?? $project,
$sourceTemplate->configuration_snapshot ?? [],
);
}
if ($baseTemplate !== null) {
$baseTemplate->forceFill([
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
$project->fresh() ?? $project,
DuplicateProjectConfiguration::copyableAreas(),
),
])->save();
}
return $project->fresh() ?? $project;
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
class CreateProjectBaseTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(Project $project): ProjectTemplate
{
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
return ProjectTemplate::query()->create([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => "{$project->name} base template",
'slug' => strtolower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => $copyAreas,
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
class CreateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
{
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
return ProjectTemplate::query()->create([
'project_id' => null,
'source_project_id' => $sourceProject->id,
'name' => $attributes['name'],
'slug' => $attributes['slug'],
'is_published' => true,
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
]);
}
}
@@ -0,0 +1,339 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
class DuplicateProjectConfiguration
{
public const COPY_PROJECT_SETTINGS = 'project_settings';
public const COPY_BOARD_COLUMNS = 'board_columns';
public const COPY_TICKET_STATUSES = 'ticket_statuses';
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
public const COPY_TICKET_TYPES = 'ticket_types';
public const COPY_TICKET_PRIORITIES = 'ticket_priorities';
public const COPY_TICKET_FIELDS = 'ticket_fields';
public const COPY_TICKET_LINK_TYPES = 'ticket_link_types';
public const COPY_REPOSITORY_CONNECTIONS = 'repository_connections';
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
public const COPY_BOARD_WORKFLOW = 'board_workflow';
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/** @return list<string> */
public static function copyableAreas(): array
{
return [
self::COPY_PROJECT_SETTINGS,
self::COPY_BOARD_COLUMNS,
self::COPY_TICKET_STATUSES,
self::COPY_STATUS_TRANSITIONS,
self::COPY_TICKET_TYPES,
self::COPY_TICKET_PRIORITIES,
self::COPY_TICKET_FIELDS,
self::COPY_TICKET_LINK_TYPES,
self::COPY_REPOSITORY_CONNECTIONS,
self::COPY_NOTIFICATION_ACTIONS,
];
}
/** @return list<string> */
public static function acceptedCopyAreas(): array
{
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
}
/** @return list<array{value: string, label: string, description: string}> */
public static function copyAreaOptions(): array
{
return [
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
];
}
/** @param list<string> $copyAreas @return list<string> */
public function normalizeCopyAreas(array $copyAreas): array
{
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
$copyAreas = [
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
self::COPY_BOARD_COLUMNS,
self::COPY_TICKET_STATUSES,
self::COPY_STATUS_TRANSITIONS,
];
}
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
}
/** @param array<string, mixed> $configuration @return array<string, mixed> */
public function normalizeConfiguration(array $configuration): array
{
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
if (is_array($workflow)) {
$statuses = $workflow['ticket_statuses'] ?? [];
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
$statuses,
);
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
$statuses,
);
unset($configuration[self::COPY_BOARD_WORKFLOW]);
}
return $configuration;
}
/** @param list<string> $copyAreas @return array<string, mixed> */
public function export(Project $sourceProject, array $copyAreas): array
{
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
if ($selectedAreas === []) {
return [];
}
$sourceProject->load([
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
]);
$configuration = [];
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
$configuration[self::COPY_PROJECT_SETTINGS] = [
'show_author' => $sourceProject->show_author,
'show_story_points' => $sourceProject->show_story_points,
];
}
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
static fn ($column, int $index): array => [$column->id => $index],
);
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
static fn ($status, int $index): array => [$status->id => $index],
);
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
)->all();
}
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => [
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
'name' => $status->name,
'position' => $status->position,
],
)->all();
}
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
static fn (int $id): ?int => $statusIndexes->get($id),
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
)->all();
}
$simpleAreas = [
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
'provider' => $item->provider->value, 'base_url' => $item->base_url,
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
'repository_url' => $item->repository_url,
],
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
'filters' => $item->filters ?? [],
],
];
$relations = [
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
];
foreach ($simpleAreas as $area => $mapper) {
if (in_array($area, $selectedAreas, true)) {
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
}
}
return $configuration;
}
/**
* @param array<string, mixed> $configuration
* @param array<int|string, int|string> $statusReplacements
* @return list<string> areas which retained referenced local values
*/
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
{
$configuration = $this->normalizeConfiguration($configuration);
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
$blockedAreas = [];
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
}
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
$blocked = $this->syncModels(
$targetProject->boardColumns()->get(),
$configuration[self::COPY_BOARD_COLUMNS],
fn (array $item) => $targetProject->boardColumns()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
);
if ($blocked) {
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
}
}
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
$columns = $targetProject->boardColumns()->get()->values();
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
$columnIndex = $item['board_column_index'] ?? null;
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
unset($item['board_column_index']);
return $item;
})->all();
$blocked = $this->syncModels(
$targetProject->ticketStatuses()->get(),
$items,
fn (array $item) => $targetProject->ticketStatuses()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
function (Model $status) use ($statusReplacements): bool {
if (! $status->tickets()->exists()) {
return true;
}
$replacementId = $statusReplacements[$status->id] ?? null;
if ($replacementId === null) {
return false;
}
$status->tickets()->update(['ticket_status_id' => $replacementId]);
return true;
},
);
if ($blocked) {
$blockedAreas[] = self::COPY_TICKET_STATUSES;
}
}
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
$statuses = $targetProject->ticketStatuses()->get()->values();
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
$status = $statuses->get($index);
if ($status !== null) {
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
}
}
}
$relations = [
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
];
foreach ($relations as $area => [$relation, $canDelete]) {
if (! isset($configuration[$area])) {
continue;
}
$blocked = $this->syncModels(
$targetProject->{$relation}()->get(),
$configuration[$area],
fn (array $item) => $targetProject->{$relation}()->create($item),
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
$canDelete,
);
if ($blocked) {
$blockedAreas[] = $area;
}
}
foreach ($targetProject->configurationRelations() as $relation) {
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
}
return array_values(array_unique($blockedAreas));
});
foreach ($blockedAreas as $area) {
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
}
return $blockedAreas;
}
/** @param list<string> $copyAreas */
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
{
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
}
/**
* @param EloquentCollection<int, Model> $models
* @param array<int, array<string, mixed>> $items
*/
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
{
foreach ($items as $index => $item) {
$model = $models->get($index);
$model === null ? $create($item) : $update($model, $item);
}
$blocked = false;
foreach ($models->slice(count($items)) as $model) {
if ($canDelete !== null && ! $canDelete($model)) {
$blocked = true;
continue;
}
$model->deleteQuietly();
}
return $blocked;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class ProjectTemplateOverrideManager
{
private static int $suppressionDepth = 0;
public function mark(Project $project, string $area): void
{
if (self::$suppressionDepth > 0) {
return;
}
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
return;
}
$overrides = array_values(array_unique([
...($project->template_overrides ?? []),
$area,
]));
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
}
/**
* @param list<string> $areas
*/
public function clear(Project $project, array $areas): void
{
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
}
public function withoutRecording(callable $callback): mixed
{
self::$suppressionDepth++;
try {
return $callback();
} finally {
self::$suppressionDepth--;
}
}
public function isRecording(): bool
{
return self::$suppressionDepth === 0;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
class RefreshProjectBaseTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
public function handle(Project $project, string $area): void
{
$project->loadMissing('projectTemplate');
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
return;
}
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
$project->projectTemplate->configuration_snapshot ?? [],
);
$project->projectTemplate->forceFill([
'configuration_snapshot' => array_replace(
$snapshot,
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
),
])->saveQuietly();
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
class ResolveProjectTemplateState
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
) {}
/**
* @param list<string>|null $areas
* @return array<string, mixed>|null
*/
public function handle(Project $project, ?array $areas = null): ?array
{
$project->loadMissing('projectTemplate');
/** @var ProjectTemplate|null $projectTemplate */
$projectTemplate = $project->projectTemplate;
if ($projectTemplate === null) {
return null;
}
$selectedAreas = $areas === null
? DuplicateProjectConfiguration::copyableAreas()
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$templateConfiguration = collect($normalizedTemplateConfiguration)
->only($selectedAreas)
->all();
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
$persistedOverrides = $projectTemplate->is_published
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
: [];
$overriddenAreas = [];
$inheritedAreas = [];
foreach ($selectedAreas as $area) {
$label = $this->labelForArea($area);
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
if (! $hasTemplateValue) {
continue;
}
if (! in_array($area, $persistedOverrides, true)) {
$inheritedAreas[] = [
'label' => $label,
'value' => $area,
];
continue;
}
$overriddenAreas[] = [
'label' => $label,
'value' => $area,
];
}
$overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value'));
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
return [
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
'inheritedAreas' => $inheritedAreas,
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
'inheritsFromTemplate' => $projectTemplate->is_published,
'overriddenAreas' => $overriddenAreas,
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
'templateConfiguration' => $templateConfiguration,
'templateId' => $projectTemplate->id,
'templateName' => $projectTemplate->name,
'templateSlug' => $projectTemplate->slug,
];
}
/**
* @param array<string, mixed> $templateConfiguration
* @param list<string> $persistedOverrides
* @return array<string, mixed>
*/
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
{
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
return [];
}
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
if (! is_array($templateStatuses)) {
return [];
}
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$replacementOptions = $statuses->take(count($templateStatuses))->map(
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
)->all();
$removedStatuses = $statuses->slice(count($templateStatuses))
->filter(static fn ($status): bool => $status->tickets_count > 0)
->map(static fn ($status): array => [
'id' => $status->id,
'name' => $status->name,
'ticketCount' => $status->tickets_count,
])
->values()
->all();
return $removedStatuses === [] ? [] : [
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
'removedStatuses' => $removedStatuses,
'replacementOptions' => $replacementOptions,
],
];
}
private function labelForArea(string $area): string
{
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
if ($option['value'] === $area) {
return $option['label'];
}
}
return $area;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplate
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
/**
* @param array{name: string, copy_areas?: array<int, string>} $attributes
*/
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
{
$projectTemplate->loadMissing('sourceProject');
$sourceProject = $projectTemplate->sourceProject;
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$projectTemplate->configuration_snapshot ?? [],
);
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
->values()
->all();
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
$projectTemplate->forceFill([
'name' => $attributes['name'],
'copy_areas' => $normalizedCopyAreas,
'configuration_snapshot' => $nextConfiguration,
])->save();
Project::query()
->where('project_template_id', $projectTemplate->id)
->orderBy('id')
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
foreach ($linkedProjects as $linkedProject) {
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
$overrides = $linkedProject->template_overrides ?? [];
$replacementConfiguration = [];
foreach ($affectedAreas as $area) {
if (in_array($area, $overrides, true)) {
continue;
}
if (! array_key_exists($area, $nextConfiguration)) {
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
continue;
}
if (! array_key_exists($area, $previousConfiguration)
&& array_key_exists($area, $currentConfiguration)
&& $currentConfiguration[$area] !== []) {
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
continue;
}
$replacementConfiguration[$area] = $nextConfiguration[$area];
}
if ($replacementConfiguration !== []) {
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
}
}
});
});
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Support\Facades\DB;
class UpdateProjectTemplateLink
{
public function __construct(
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
) {}
public function switch(Project $project, ProjectTemplate $template): void
{
DB::transaction(function () use ($project, $template): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
);
$localAreas = array_values(array_diff(
DuplicateProjectConfiguration::copyableAreas(),
array_keys($configuration),
));
$project->forceFill([
'project_template_id' => $template->id,
'template_overrides' => $localAreas,
])->saveQuietly();
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
$this->projectTemplateOverrideManager->clear(
$project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
}
/**
* @param list<string> $areas
* @param array<int|string, int|string> $statusReplacements
*/
public function reset(Project $project, array $areas, array $statusReplacements = []): void
{
$template = $project->projectTemplate;
if ($template === null) {
return;
}
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
))->only($areas)->all();
$blockedAreas = $this->duplicateProjectConfiguration->apply(
$project,
$configuration,
$statusReplacements,
);
$this->projectTemplateOverrideManager->clear(
$project->fresh(),
array_values(array_diff(array_keys($configuration), $blockedAreas)),
);
});
}
}
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderManager;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Http\Resources\AvailableGitRepositorySourceResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\RepositoryConnectionResource;
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
) {}
/**
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
'project' => ProjectResource::make($project)->resolve(),
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
'status' => $status,
'templateState' => $this->resolveProjectTemplateState->handle($project, [
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
]),
];
}
+27 -4
View File
@@ -19,6 +19,7 @@ class CreateProjectTicket
public function __construct(
private readonly BuildTicketCreatedPayload $buildTicketCreatedPayload,
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
) {}
/**
@@ -29,7 +30,15 @@ class CreateProjectTicket
* text: string
* }>,
* assignee_user_id?: int|null,
* author_user_id?: int|null,
* blocked_comment?: string|null,
* description: string|null,
* field_values?: array<int, array{
* ticket_field_definition_id: int,
* value_number?: float|int|string|null,
* value_user_id?: int|string|null
* }>,
* is_blocked?: bool,
* parent_ticket_id?: int|null,
* story_points?: int|null,
* ticket_priority_id: int,
@@ -47,14 +56,17 @@ class CreateProjectTicket
$nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id)
->max('sequence_number') + 1;
$ticketStatus = TicketStatus::query()
->where('project_id', $project->id)
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$isBlocked = (bool) Arr::get($attributes, 'is_blocked', false);
$ticket = Ticket::query()->create([
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
'author_user_id' => $actor?->id,
'author_user_id' => Arr::get($attributes, 'author_user_id', $actor?->id),
'blocked_comment' => $isBlocked ? Arr::get($attributes, 'blocked_comment') : null,
'description' => Arr::get($attributes, 'description'),
'is_blocked' => $isBlocked,
'parent_ticket_id' => Arr::get($attributes, 'parent_ticket_id'),
'project_id' => $project->id,
'sequence_number' => $nextSequenceNumber,
@@ -68,6 +80,17 @@ class CreateProjectTicket
AcceptanceCriteriaChecklistMapper::syncTicket($ticket, $acceptanceCriteria);
$fieldValues = Arr::get($attributes, 'field_values', []);
if ($fieldValues !== []) {
$this->syncProjectTicketFieldValues->handle(
$ticket,
$project->ticketFieldDefinitions()->get()->keyBy('id'),
collect(),
$fieldValues,
);
}
$occurredAt = CarbonImmutable::now();
if ($actor !== null) {
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+76 -1
View File
@@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\BuildProjectBacklogPayload;
use App\Actions\Projects\CreateProject;
use App\Actions\Projects\DeleteProject;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ListVisibleProjects;
use App\Actions\Projects\ResolveProjectTemplateState;
use App\Actions\Projects\UpdateProjectTemplateLink;
use App\Enums\Permission;
use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\ResetProjectTemplateRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SwitchProjectTemplateRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
use App\Http\Resources\PermissionOptionResource;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Http\Resources\TeamResource;
use App\Http\Resources\UserResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
@@ -30,8 +37,11 @@ class ProjectController extends Controller
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
private readonly CreateProject $createProject,
private readonly DeleteProject $deleteProject,
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
private readonly ListVisibleProjects $listVisibleProjects,
private readonly PermissionService $permissionService,
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
) {}
public function index(Request $request): Response
@@ -51,7 +61,23 @@ class ProjectController extends Controller
403,
);
return Inertia::render('projects/Create');
$sourceTemplates = ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get();
$selectedTemplateId = $request->integer('template');
return Inertia::render('projects/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'sourceProjects' => ProjectResource::collection(
$this->listVisibleProjects->handle($request->user()),
)->resolve(),
'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(),
'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId)
? $selectedTemplateId
: null,
]);
}
public function store(StoreProjectRequest $request): RedirectResponse
@@ -73,6 +99,7 @@ class ProjectController extends Controller
return Inertia::render('projects/Show', [
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => ProjectResource::make($project)->resolve(),
'templateState' => $this->resolveProjectTemplateState->handle($project),
]);
}
@@ -95,10 +122,58 @@ class ProjectController extends Controller
{
return Inertia::render('projects/settings/General', [
'project' => ProjectResource::make($project)->resolve(),
'sourceTemplates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->orderBy('name')
->get(),
)->resolve(),
'status' => session('status'),
'templateState' => $this->resolveProjectTemplateState->handle($project),
]);
}
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$template = ProjectTemplate::query()
->published()
->findOrFail((int) Arr::get($validated, 'source_template_id'));
$this->updateProjectTemplateLink->switch($project, $template);
return to_route('projects.settings.general', $project)
->with('status', "{$project->name} now uses the {$template->name} template.");
}
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
{
$validated = $request->validated();
$template = $project->projectTemplate;
abort_if($template === null, 422, 'This project has no linked template to reset.');
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
);
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
$statusReplacements = Arr::get($validated, 'status_replacements', []);
$this->updateProjectTemplateLink->reset(
$project->load('projectTemplate'),
$areas,
$statusReplacements,
);
$status = Arr::has($validated, 'areas')
? "Selected settings were reset to the {$template->name} template."
: "{$project->name} was reset to the {$template->name} template baseline.";
return back()->with('status', $status);
}
public function access(Request $request, Project $project): Response
{
$user = $request->user();
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Projects\CreateProjectTemplate;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\UpdateProjectTemplate;
use App\Http\Requests\StoreProjectTemplateRequest;
use App\Http\Requests\UpdateProjectTemplateRequest;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectTemplateResource;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProjectTemplateController extends Controller
{
public function __construct(
private readonly CreateProjectTemplate $createProjectTemplate,
private readonly UpdateProjectTemplate $updateProjectTemplate,
) {}
public function index(Request $request): Response
{
return Inertia::render('project-templates/Index', [
'status' => session('status'),
'templates' => ProjectTemplateResource::collection(
ProjectTemplate::query()
->published()
->with('sourceProject')
->withCount('linkedProjects')
->orderBy('name')
->get(),
)->resolve(),
]);
}
public function edit(ProjectTemplate $projectTemplate): Response
{
abort_if(! $projectTemplate->is_published, 404);
$projectTemplate->load('sourceProject')->loadCount('linkedProjects');
return Inertia::render('project-templates/Edit', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'template' => ProjectTemplateResource::make($projectTemplate)->resolve(),
]);
}
public function create(Project $project): Response
{
return Inertia::render('project-templates/Create', [
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
'project' => ProjectResource::make($project)->resolve(),
]);
}
public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse
{
$template = $this->createProjectTemplate->handle($project, $request->validated());
return to_route('project-templates.index')
->with('status', "Template {$template->name} was created.");
}
public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse
{
abort_if(! $projectTemplate->is_published, 404);
$template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated());
return to_route('project-templates.index')
->with('status', "Template {$template->name} was updated.");
}
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
{
abort_if(! $projectTemplate->is_published, 404);
$templateName = $projectTemplate->name;
$projectTemplate->delete();
return to_route('project-templates.index')
->with('status', "Template {$templateName} was deleted.");
}
}
+5 -1
View File
@@ -16,7 +16,11 @@ class HandleAppearance
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
$appearance = $request->cookie('appearance');
$appearance = in_array($appearance, ['light', 'dark', 'system'], true) ? $appearance : 'system';
$request->attributes->set('appearance', $appearance);
View::share('appearance', $appearance);
return $next($request);
}
@@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -46,6 +47,11 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'appearance' => $request->attributes->get('appearance', 'system'),
'localization' => [
'locale' => app()->getLocale(),
'timeZone' => config('app.timezone'),
],
'auth' => [
'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
@@ -55,6 +61,36 @@ class HandleInertiaRequests extends Middleware
'user' => $user,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'navigation' => fn (): array => [
'project' => $this->projectNavigation($request),
],
];
}
/**
* @return array{id: int, name: string, key: string, canManage: bool, canCreateTickets: bool}|null
*/
private function projectNavigation(Request $request): ?array
{
$project = $request->route('project');
$user = $request->user();
if (! $project instanceof Project || $user === null) {
return null;
}
$permissions = $this->permissionService->effectivePermissions($user, $project);
if (! in_array(Permission::ProjectRead, $permissions, true)) {
return null;
}
return [
'id' => $project->id,
'name' => $project->name,
'key' => $project->key,
'canManage' => in_array(Permission::ProjectAdmin, $permissions, true),
'canCreateTickets' => in_array(Permission::ProjectWrite, $permissions, true),
];
}
}
@@ -29,7 +29,9 @@ class MoveProjectTicketRequest extends FormRequest
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
@@ -34,7 +34,9 @@ class DestroyProjectTicketTypeRequest extends FormRequest
'replacement_ticket_type_id' => [
'nullable',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
@@ -34,7 +34,9 @@ class MoveProjectTicketRequest extends FormRequest
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
];
@@ -0,0 +1,107 @@
<?php
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class ResetProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'areas' => ['sometimes', 'array', 'min:1'],
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
'confirmation_key' => [
'required_without:areas',
'string',
Rule::in([$project->key]),
],
'status_replacements' => ['sometimes', 'array'],
'status_replacements.*' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
],
];
}
/** @return array<int, callable(Validator): void> */
public function after(): array
{
return [
function (Validator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
/** @var Project $project */
$project = $this->route('project');
$validated = $validator->validated();
$areas = array_values(Arr::get(
$validated,
'areas',
DuplicateProjectConfiguration::copyableAreas(),
));
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
return;
}
$project->loadMissing('projectTemplate');
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$templateStatuses = Arr::get(
$configuration,
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
[],
);
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
$replacements = Arr::get($validated, 'status_replacements', []);
foreach ($statuses->slice(count($templateStatuses)) as $status) {
if ($status->tickets_count === 0) {
continue;
}
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
if (! $allowedReplacementIds->contains($replacementId)) {
$validator->errors()->add(
"status_replacements.{$status->id}",
"Choose a replacement status for {$status->name}.",
);
}
}
},
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
'areas.*.in' => 'Choose a valid template area to reset.',
];
}
}
+59
View File
@@ -2,6 +2,10 @@
namespace App\Http\Requests;
use App\Actions\Permissions\PermissionService;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Enums\Permission;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Arr;
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
protected function prepareForValidation(): void
{
$key = Arr::get($this->all(), 'key');
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
if ($key !== null) {
$this->merge([
'key' => strtoupper(trim((string) $key)),
]);
}
$this->merge([
'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch',
'copy_areas' => is_array($copyAreas)
? array_values($copyAreas)
: ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]),
]);
}
/**
@@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest
public function rules(): array
{
return [
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
'key' => [
'required',
'string',
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
Rule::unique('projects', 'key'),
],
'name' => ['required', 'string', 'max:255'],
'source_project_id' => [
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'),
'nullable',
'integer',
Rule::exists('projects', 'id'),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === null || $value === '') {
return;
}
$project = Project::query()->find($value);
if ($project === null) {
return;
}
$permissionService = app(PermissionService::class);
if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) {
$fail('Select a project you can access as the duplication source.');
}
},
],
'source_template_id' => [
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
'nullable',
'integer',
Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
],
'copy_areas' => ['nullable', 'array'],
'copy_areas.*' => [
'string',
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'source_project_id.required' => 'Select a source project to duplicate from.',
'source_template_id.required' => 'Select a template to create the project from.',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StoreProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
}
protected function prepareForValidation(): void
{
if ($this->filled('slug')) {
$this->merge([
'slug' => Str::slug((string) $this->input('slug')),
]);
}
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
];
}
}
@@ -2,8 +2,10 @@
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,18 @@ class StoreProjectTicketBranchRequest extends FormRequest
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
}
@@ -66,17 +66,23 @@ class StoreProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_status_id' => [
'required',
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'],
+3 -1
View File
@@ -38,7 +38,9 @@ class StoreTicketLinkRequest extends FormRequest
'ticket_link_type_id' => [
'required',
'integer',
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_link_types', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
@@ -31,7 +31,9 @@ class StoreTicketStatusRequest extends FormRequest
'board_column_id' => [
'nullable',
'integer',
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
Rule::exists('board_columns', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'name' => ['required', 'string', 'max:255'],
];
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SwitchProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'confirmation_key' => [
'required',
'string',
Rule::in([$project->key]),
],
'source_template_id' => [
'required',
'integer',
Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true),
Rule::notIn(array_filter([$project->project_template_id])),
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.',
'source_template_id.not_in' => 'Choose a different published template to switch this project.',
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProjectTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can('create-project') ?? false;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var ProjectTemplate|null $projectTemplate */
$projectTemplate = $this->route('projectTemplate');
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
return [
'name' => ['required', 'string', 'max:255'],
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.',
];
}
}
@@ -68,7 +68,9 @@ class UpdateProjectTicketRequest extends FormRequest
'field_values.*.ticket_field_definition_id' => [
'required',
'integer',
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
Rule::exists('ticket_field_definitions', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'field_values.*.value_number' => ['nullable', 'numeric'],
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
@@ -81,12 +83,16 @@ class UpdateProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'ticket_type_id' => [
'required',
'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'min:0'],
@@ -34,7 +34,9 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [
'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
@@ -2,8 +2,10 @@
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ValidateProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,18 @@ class ValidateProjectTicketBranchRequest extends FormRequest
*/
public function rules(): array
{
/** @var Project $project */
$project = $this->route('project');
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where('project_id', $project->id),
),
],
];
}
}
+25
View File
@@ -2,8 +2,10 @@
namespace App\Http\Resources;
use App\Actions\Projects\ResolveProjectTemplateState;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class ProjectResource extends JsonResource
{
@@ -14,10 +16,33 @@ class ProjectResource extends JsonResource
*/
public function toArray(Request $request): array
{
$this->resource->loadMissing('projectTemplate');
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
return [
'id' => $this->id,
'name' => $this->name,
'key' => $this->key,
'project_template_id' => $this->project_template_id,
'project_template' => $this->projectTemplate === null
? null
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
'template_state' => $templateState === null
? null
: [
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
'template_id' => Arr::get($templateState, 'templateId'),
'template_name' => Arr::get($templateState, 'templateName'),
'template_slug' => Arr::get($templateState, 'templateSlug'),
],
'show_author' => $this->show_author,
'show_story_points' => $this->show_story_points,
'created_at' => $this->created_at?->toISOString(),
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Resources;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\ProjectTemplate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Arr;
class ProjectTemplateResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
if ($this->resource instanceof ProjectTemplate) {
$configuration = app(DuplicateProjectConfiguration::class);
return [
'id' => $this->id,
'project_id' => $this->project_id,
'source_project_id' => $this->source_project_id,
'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null
? ProjectResource::make($this->sourceProject)->resolve()
: null,
'name' => $this->name,
'slug' => $this->slug,
'is_published' => $this->is_published,
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
'configuration_snapshot' => $configuration->normalizeConfiguration(
$this->configuration_snapshot ?? [],
),
'linked_project_count' => $this->whenCounted('linkedProjects'),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
return [
'id' => Arr::get($this->resource, 'id'),
'project_id' => Arr::get($this->resource, 'project_id'),
'source_project_id' => Arr::get($this->resource, 'source_project_id'),
'source_project' => Arr::get($this->resource, 'source_project'),
'name' => Arr::get($this->resource, 'name'),
'slug' => Arr::get($this->resource, 'slug'),
'is_published' => Arr::get($this->resource, 'is_published', true),
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
'linked_project_count' => Arr::get($this->resource, 'linked_project_count'),
'created_at' => Arr::get($this->resource, 'created_at'),
'updated_at' => Arr::get($this->resource, 'updated_at'),
];
}
}
+5 -1
View File
@@ -2,9 +2,11 @@
namespace App\Mcp\Servers;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListMyAssignedTickets;
use App\Mcp\Tools\ListTicketsByStatus;
use App\Mcp\Tools\ListTicketTypes;
use App\Mcp\Tools\MoveTicketStatus;
use App\Mcp\Tools\UpdateTicketAssignee;
use App\Mcp\Tools\UpdateTicketDescription;
@@ -15,7 +17,7 @@ use Laravel\Mcp\Server\Attributes\Version;
#[Name('KanbanGit MCP')]
#[Version('0.1.0')]
#[Instructions('Use these tools to read and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially ticket_id and ticket_status_id.')]
#[Instructions('Use these tools to read, create, and update KanbanGit tickets with the authenticated application key owner permissions. Prefer stable IDs when available, especially project_id, ticket_id, ticket_type_id, and ticket_status_id. Use list_ticket_types to discover the available ticket types in a project. Use create_ticket with parent_ticket_id to create a child ticket.')]
class KanbanGitServer extends Server
{
protected array $tools = [
@@ -25,6 +27,8 @@ class KanbanGitServer extends Server
UpdateTicketDescription::class,
UpdateTicketAssignee::class,
MoveTicketStatus::class,
CreateTicket::class,
ListTicketTypes::class,
];
protected array $resources = [
+20
View File
@@ -120,6 +120,24 @@ class KanbanGitMcp
return $project;
}
/**
* @throws AuthorizationException
*/
public function projectForWrite(User $user, int $projectId): Project
{
$project = Project::query()->find($projectId);
if ($project === null) {
throw ValidationException::withMessages([
'project_id' => 'Project was not found.',
]);
}
$this->ensureCanWrite($user, $project);
return $project;
}
/**
* @param array<string, mixed> $validated
* @return Collection<int, TicketStatus>|Response
@@ -189,10 +207,12 @@ class KanbanGitMcp
public function ticketPayload(Ticket $ticket): array
{
$ticket->load([
'acceptanceCriteria',
'assignee',
'author',
'fieldValues.ticketFieldDefinition',
'fieldValues.userValue',
'parentTicket',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Mcp\Tools;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Enums\Permission;
use App\Mcp\Support\KanbanGitMcp;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Name('create_ticket')]
#[Description('Create a ticket with all editable ticket fields, including its type, initial status, priority, assignee, author, description, story points, acceptance criteria, blocked state, and custom field values. Set parent_ticket_id to create a child of an existing ticket in the same project whose type allows children. Requires project write permission; overriding the author requires project admin permission. Ticket IDs, keys, sequence numbers, timestamps, and worklog entries are generated automatically.')]
#[IsDestructive(false)]
class CreateTicket extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
private readonly CreateProjectTicket $createProjectTicket,
private readonly PermissionService $permissionService,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$projectInput = $request->validate([
'project_id' => ['required', 'integer'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForWrite($user, (int) Arr::get($projectInput, 'project_id'));
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'ticket_type_id' => ['required', 'integer', Rule::exists('ticket_types', 'id')->where('project_id', $project->id)],
'ticket_status_id' => ['required', 'integer', Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id)],
'ticket_priority_id' => ['required', 'integer', Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id)],
'parent_ticket_id' => ['nullable', 'integer', Rule::exists('tickets', 'id')->where('project_id', $project->id)],
'assignee_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'author_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'story_points' => ['nullable', 'integer', 'min:0'],
'is_blocked' => ['sometimes', 'boolean'],
'blocked_comment' => ['nullable', 'string'],
'acceptance_criteria' => ['nullable', 'array', 'list'],
'acceptance_criteria.*' => ['array:text,checked,indent'],
'acceptance_criteria.*.text' => ['required', 'string'],
'acceptance_criteria.*.checked' => ['sometimes', 'boolean'],
'acceptance_criteria.*.indent' => ['sometimes', 'integer', 'min:0', 'max:'.AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL],
'field_values' => ['sometimes', 'array', 'list'],
'field_values.*' => ['array:ticket_field_definition_id,value_number,value_user_id'],
'field_values.*.ticket_field_definition_id' => [
'required', 'integer', 'distinct',
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
],
'field_values.*.value_number' => ['nullable', 'numeric'],
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
]);
$this->validateTicketConstraints($project, $user, $validated);
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
return Response::structured([
'data' => $this->kanbanGitMcp->ticketPayload($ticket->fresh()),
'meta' => [
'message' => "{$ticket->ticket_key} was created.",
],
]);
}
/**
* @param array<string, mixed> $validated
*/
private function validateTicketConstraints(Project $project, User $user, array $validated): void
{
$errors = [];
$parentTicketId = Arr::get($validated, 'parent_ticket_id');
if ($parentTicketId !== null) {
$parentTicket = Ticket::query()
->where('project_id', $project->id)
->with('ticketType')
->find($parentTicketId);
if (! $parentTicket?->ticketType?->is_parent) {
$errors['parent_ticket_id'] = 'Only tickets with a parent-enabled type can be selected as a parent.';
}
}
if (Arr::get($validated, 'is_blocked', false) && blank(Arr::get($validated, 'blocked_comment'))) {
$errors['blocked_comment'] = 'Blocked tickets require a blocker comment.';
}
if (
Arr::exists($validated, 'author_user_id')
&& (int) Arr::get($validated, 'author_user_id') !== $user->id
&& ! $this->permissionService->allows($user, Permission::ProjectAdmin, $project)
) {
$errors['author_user_id'] = 'Only project admins can change the author.';
}
$fieldValues = Arr::get($validated, 'field_values', []);
if ($fieldValues !== []) {
$fieldDefinitions = $project->ticketFieldDefinitions()->get()->keyBy('id');
foreach ($fieldValues as $index => $fieldValue) {
$fieldDefinition = $fieldDefinitions->get((int) Arr::get($fieldValue, 'ticket_field_definition_id'));
$incompatibleValue = $fieldDefinition->field_type === 'user' ? 'value_number' : 'value_user_id';
if (Arr::get($fieldValue, $incompatibleValue) !== null) {
$errors["field_values.{$index}.{$incompatibleValue}"] = 'The value must match the custom field type.';
}
}
}
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()->description('Project ID.')->required(),
'title' => $schema->string()->max(255)->description('Ticket title.')->required(),
'description' => $schema->string()->nullable()->description('Ticket description.'),
'ticket_type_id' => $schema->integer()->description('Ticket type ID belonging to the project. Explicitly selects the type, including types that allow child tickets.')->required(),
'ticket_status_id' => $schema->integer()->description('Initial ticket status ID belonging to the project.')->required(),
'ticket_priority_id' => $schema->integer()->description('Ticket priority ID belonging to the project.')->required(),
'parent_ticket_id' => $schema->integer()->nullable()->description('Existing parent ticket ID in the same project. Its type must allow children. Omit or pass null for a top-level ticket.'),
'assignee_user_id' => $schema->integer()->nullable()->description('User ID to assign. Omit or pass null to leave unassigned.'),
'author_user_id' => $schema->integer()->nullable()->description('Defaults to the application key owner when omitted. Only project admins may choose another author or pass null for no author.'),
'story_points' => $schema->integer()->min(0)->nullable()->description('Non-negative story point estimate.'),
'is_blocked' => $schema->boolean()->default(false)->description('Whether the ticket is blocked. Requires a nonblank blocked_comment when true.'),
'blocked_comment' => $schema->string()->nullable()->description('Blocker reason. Stored only when is_blocked is true.'),
'acceptance_criteria' => $schema->array()->nullable()
->items($schema->object([
'text' => $schema->string()->description('Checklist item text.')->required(),
'checked' => $schema->boolean()->default(false),
'indent' => $schema->integer()->min(0)->max(AcceptanceCriteriaChecklistMapper::MAX_INDENT_LEVEL)->default(0),
])->withoutAdditionalProperties())
->description('Ordered acceptance checklist. Indent 0 is a root item; larger values nest under preceding items.'),
'field_values' => $schema->array()
->items($schema->object([
'ticket_field_definition_id' => $schema->integer()->description('Custom field definition ID belonging to the project. Each definition may appear once.')->required(),
'value_number' => $schema->number()->nullable()->description('Value for a numeric field.'),
'value_user_id' => $schema->integer()->nullable()->description('User ID for a user field.'),
])->withoutAdditionalProperties())
->description('Custom field values. Supply the value matching each definition type; omit or pass null for an empty value.'),
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Mcp\Tools;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\TicketTypeResource;
use App\Mcp\Support\KanbanGitMcp;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Arr;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('list_ticket_types')]
#[Description('List all ticket types in a visible project in their configured order, including IDs, names, and is_parent. Use a returned ID as ticket_type_id when creating a ticket. Types with is_parent=true allow tickets of that type to have child tickets; set parent_ticket_id on create_ticket to attach a child to an existing parent ticket.')]
#[IsReadOnly]
class ListTicketTypes extends Tool
{
public function __construct(
private readonly KanbanGitMcp $kanbanGitMcp,
) {}
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate([
'project_id' => ['required', 'integer'],
]);
$user = $this->kanbanGitMcp->user($request);
$project = $this->kanbanGitMcp->projectForRead($user, (int) Arr::get($validated, 'project_id'));
return Response::structured([
'data' => [
'project' => ProjectResource::make($project)->resolve(),
'ticket_types' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(),
],
]);
}
/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'project_id' => $schema->integer()
->description('Project ID. Requires permission to view this project.')
->required(),
];
}
}
+11 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\BoardColumnFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,12 +11,21 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $boardColumn): void {
$boardColumn->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
@@ -0,0 +1,41 @@
<?php
namespace App\Models\Concerns;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait SyncsProjectTemplateOwnership
{
protected function syncProjectTemplateOwnership(): void
{
$projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id');
if ($projectTemplateId === null && $projectId !== null) {
$projectTemplateId = Project::query()
->whereKey($projectId)
->value('project_template_id');
if ($projectTemplateId !== null) {
$this->setAttribute('project_template_id', $projectTemplateId);
}
}
if ($projectId === null && $projectTemplateId !== null) {
$projectId = ProjectTemplate::query()
->whereKey($projectTemplateId)
->value('project_id');
if ($projectId !== null) {
$this->setAttribute('project_id', $projectId);
}
}
}
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class);
}
}
+11
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\NotificationActionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'message_template',
'name',
'project_id',
'project_template_id',
])]
#[Hidden(['delivery_url'])]
class NotificationAction extends Model
@@ -30,6 +32,8 @@ class NotificationAction extends Model
/** @use HasFactory<NotificationActionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @return array<string, string>
*/
@@ -45,6 +49,13 @@ class NotificationAction extends Model
];
}
protected static function booted(): void
{
static::saving(function (self $notificationAction): void {
$notificationAction->syncProjectTemplateOwnership();
});
}
public function scopeEnabled(Builder $query): Builder
{
return $query->where('enabled', true);
+30 -1
View File
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
class Project extends Model
{
/** @use HasFactory<ProjectFactory> */
@@ -22,6 +24,7 @@ class Project extends Model
return [
'show_author' => 'boolean',
'show_story_points' => 'boolean',
'template_overrides' => 'array',
];
}
@@ -44,6 +47,17 @@ class Project extends Model
return $this->morphMany(PermissionAssignment::class, 'scope');
}
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
}
public function baseTemplate(): HasOne
{
return $this->hasOne(ProjectTemplate::class)
->where('is_published', false);
}
public function boardColumns(): HasMany
{
return $this->hasMany(BoardColumn::class)
@@ -95,6 +109,21 @@ class Project extends Model
->orderBy('id');
}
/** @return list<string> */
public function configurationRelations(): array
{
return [
'boardColumns',
'notificationActions',
'repositoryConnections',
'ticketFieldDefinitions',
'ticketLinkTypes',
'ticketPriorities',
'ticketStatuses',
'ticketTypes',
];
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class)
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Database\Factories\ProjectTemplateFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
class ProjectTemplate extends Model
{
/** @use HasFactory<ProjectTemplateFactory> */
use HasFactory;
protected function casts(): array
{
return [
'copy_areas' => 'array',
'configuration_snapshot' => 'array',
'is_published' => 'boolean',
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function sourceProject(): BelongsTo
{
return $this->belongsTo(Project::class, 'source_project_id');
}
public function linkedProjects(): HasMany
{
return $this->hasMany(Project::class, 'project_template_id');
}
}
+11
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\RepositoryConnectionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'base_url',
'owner_name',
'project_id',
'project_template_id',
'provider',
'provider_repository_id',
'repository_full_name',
@@ -25,6 +27,8 @@ class RepositoryConnection extends Model
/** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @return array<string, string>
*/
@@ -35,6 +39,13 @@ class RepositoryConnection extends Model
];
}
protected static function booted(): void
{
static::saving(function (self $repositoryConnection): void {
$repositoryConnection->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
+11 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model
{
/**
@@ -23,6 +24,15 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketFieldDefinition): void {
$ticketFieldDefinition->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
+11 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketLinkTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketLinkType): void {
$ticketLinkType->syncProjectTemplateOwnership();
});
}
public function counterpartLabel(): string
{
return $this->counterpart_label ?: $this->label;
+17 -1
View File
@@ -2,20 +2,36 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketPriorityFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketPriority): void {
$ticketPriority->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class);
}
}
+11 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketStatusFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,15 +12,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model
{
/** @use HasFactory<TicketStatusFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketStatus): void {
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
$ticketStatus->project_template_id = BoardColumn::query()
->whereKey($ticketStatus->board_column_id)
->value('project_template_id');
}
$ticketStatus->syncProjectTemplateOwnership();
$ticketStatus->slug = Str::slug($ticketStatus->name);
});
}
+11 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,12 +10,21 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketType): void {
$ticketType->syncProjectTemplateOwnership();
});
}
protected function casts(): array
{
return [
@@ -0,0 +1,66 @@
<?php
namespace App\Observers;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\Projects\ProjectTemplateOverrideManager;
use App\Actions\Projects\RefreshProjectBaseTemplate;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\RepositoryConnection;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use Illuminate\Database\Eloquent\Model;
class ProjectConfigurationObserver
{
public function __construct(
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
) {}
public function saved(Model $model): void
{
$this->markOverride($model);
}
public function deleted(Model $model): void
{
$this->markOverride($model);
}
private function markOverride(Model $model): void
{
if (! $this->projectTemplateOverrideManager->isRecording()) {
return;
}
$project = $model->project()->first();
if ($project === null) {
return;
}
$area = match (true) {
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
default => null,
};
if ($area === null) {
return;
}
$this->refreshProjectBaseTemplate->handle($project, $area);
$this->projectTemplateOverrideManager->mark($project, $area);
}
}
+21
View File
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
use App\Actions\Notifications\TicketStatusChanged;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\BoardColumn;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Team;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use App\Observers\ProjectConfigurationObserver;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Date;
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
foreach ([
BoardColumn::class,
NotificationAction::class,
RepositoryConnection::class,
TicketFieldDefinition::class,
TicketLinkType::class,
TicketPriority::class,
TicketStatus::class,
TicketType::class,
] as $model) {
$model::observe(ProjectConfigurationObserver::class);
}
if ($this->app->environment() !== 'local') {
URL::forceScheme('https');
+6 -6
View File
@@ -47,15 +47,15 @@
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
"pnpm install",
"pnpm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"@php artisan dev"
],
"dev:ssr": [
"npm run build:ssr",
"pnpm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
@@ -67,9 +67,9 @@
],
"ci:check": [
"Composer\\Config::disableProcessTimeout",
"npm audit --audit-level=high",
"npm run lint:check",
"npm run types:check",
"pnpm audit --audit-level=high",
"pnpm run lint:check",
"pnpm run types:check",
"@test"
],
"test": [
+1
View File
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
'key' => Str::upper(fake()->unique()->lexify('?????')),
'show_author' => true,
'show_story_points' => true,
'template_overrides' => [],
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<ProjectTemplate>
*/
class ProjectTemplateFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'source_project_id' => Project::factory(),
'name' => Str::title(fake()->unique()->words(3, true)),
'slug' => fake()->unique()->slug(),
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
'configuration_snapshot' => [
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
'show_author' => true,
'show_story_points' => true,
],
],
];
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('project_templates', function (Blueprint $table): void {
$table->id();
$table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete();
$table->string('name');
$table->string('slug')->unique();
$table->json('copy_areas');
$table->json('configuration_snapshot');
$table->timestamps();
$table->index(['source_project_id', 'name']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('project_templates');
}
};
@@ -0,0 +1,54 @@
<?php
use App\Actions\Projects\CreateProjectBaseTemplate;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('project_templates', function (Blueprint $table): void {
$table->foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete();
$table->boolean('is_published')->default(true)->after('slug');
});
Schema::table('projects', function (Blueprint $table): void {
$table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete();
});
$projects = Project::query()->whereNull('project_template_id')->get();
$createProjectBaseTemplate = app(CreateProjectBaseTemplate::class);
foreach ($projects as $project) {
$baseTemplate = $createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
ProjectTemplate::query()
->whereNull('project_id')
->update(['is_published' => true]);
}
public function down(): void
{
Project::query()->update(['project_template_id' => null]);
ProjectTemplate::query()->where('is_published', false)->delete();
Schema::table('projects', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
Schema::table('project_templates', function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_id');
$table->dropColumn('is_published');
});
}
};
@@ -0,0 +1,120 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* @var list<string>
*/
private array $tables = [
'board_columns',
'ticket_field_definitions',
'ticket_link_types',
'ticket_priorities',
'ticket_statuses',
'ticket_types',
'repository_connections',
'notification_actions',
];
public function up(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->foreignId('project_template_id')
->nullable()
->after('project_id')
->constrained('project_templates')
->nullOnDelete();
});
}
$projects = DB::table('projects')
->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id')
->select([
'projects.id',
'projects.key',
'projects.name',
'projects.project_template_id',
'project_templates.project_id as template_project_id',
'project_templates.is_published as template_is_published',
])
->get();
foreach ($projects as $project) {
$projectTemplateId = $project->project_template_id;
if ($projectTemplateId === null || (
! (bool) $project->template_is_published
&& (int) ($project->template_project_id ?? 0) !== (int) $project->id
)) {
$projectTemplateId = DB::table('project_templates')->insertGetId([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => $project->name.' base template',
'slug' => Str::lower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => json_encode([
'project_settings',
'board_workflow',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
'repository_connections',
'notification_actions',
], JSON_THROW_ON_ERROR),
'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('projects')
->where('id', $project->id)
->update([
'project_template_id' => $projectTemplateId,
'updated_at' => now(),
]);
}
foreach ($this->tables as $table) {
DB::table($table)
->where('project_id', $project->id)
->update(['project_template_id' => $projectTemplateId]);
}
$projectModel = Project::query()->find($project->id);
if ($projectModel !== null && ! (bool) $project->template_is_published) {
DB::table('project_templates')
->where('id', $projectTemplateId)
->update([
'configuration_snapshot' => json_encode(
app(DuplicateProjectConfiguration::class)->export(
$projectModel,
DuplicateProjectConfiguration::copyableAreas(),
),
JSON_THROW_ON_ERROR,
),
'updated_at' => now(),
]);
}
}
}
public function down(): void
{
foreach (array_reverse($this->tables) as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
}
}
};
@@ -0,0 +1,63 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->json('template_overrides')->default('[]')->after('project_template_id');
});
$configuration = app(DuplicateProjectConfiguration::class);
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
$template->forceFill([
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
'configuration_snapshot' => $configuration->normalizeConfiguration(
$template->configuration_snapshot ?? [],
),
])->saveQuietly();
});
Project::query()
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
->with('projectTemplate')
->eachById(function (Project $project) use ($configuration): void {
$templateConfiguration = $configuration->normalizeConfiguration(
$project->projectTemplate?->configuration_snapshot ?? [],
);
$areas = array_values(array_unique([
...array_keys($templateConfiguration),
...DuplicateProjectConfiguration::copyableAreas(),
]));
$currentConfiguration = $configuration->export($project, $areas);
$overrides = collect($areas)
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
->values()
->all();
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('template_overrides');
});
}
};
+1
View File
@@ -25,6 +25,7 @@ const prettierOptions = {
const eslintConfig = [
{
ignores: [
'bootstrap/ssr/**',
'**/dist/**',
'node_modules/**',
'.github/**',
-7268
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -2,6 +2,13 @@
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.26.0+sha512.fc0e2bf890b9f983611f1ab68c0637bce914390653699f83c1a78b005ed25f2c81e77920c8fd8eee2ecf0b58b28cdcb00a84d97f69bcf7c56b2f344710238664",
"onFail": "error"
}
},
"scripts": {
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
@@ -39,6 +46,7 @@
"vue-tsc": "^2.2.4"
},
"dependencies": {
"@inertiajs/core": "2.3.18",
"@inertiajs/vue3": "^2.3.7",
"@vueuse/core": "^12.8.2",
"class-variance-authority": "^0.7.1",
-1
View File
@@ -19,7 +19,6 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
+4832
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
minimumReleaseAge: 10080
allowBuilds:
unrs-resolver: true
vue-demi: true
Binary file not shown.
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+87 -122
View File
@@ -1,56 +1,98 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './theme.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source not '../../bootstrap/ssr';
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:where(.dark, .dark *));
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/InterVariable-Italic.woff2') format('woff2');
}
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--radius-sm: 0.125rem;
--radius-md: var(--radius);
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--spacing-page: 1.5rem;
--spacing-column: 1rem;
--spacing-card: 0.75rem;
--spacing-stack: 0.5rem;
--spacing-control: 2.75rem;
--text-headline: 1.5rem;
--text-headline--line-height: 2rem;
--text-headline--font-weight: 600;
--text-headline--letter-spacing: -0.02em;
--text-headline-mobile: 1.25rem;
--text-headline-mobile--line-height: 1.75rem;
--text-headline-mobile--font-weight: 600;
--text-headline-mobile--letter-spacing: -0.02em;
--text-title: 1.125rem;
--text-title--line-height: 1.5rem;
--text-title--font-weight: 600;
--text-title--letter-spacing: -0.01em;
--text-body: 0.875rem;
--text-body--line-height: 1.25rem;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.125rem;
--text-label: 0.6875rem;
--text-label--line-height: 1rem;
--text-label--font-weight: 700;
--text-label--letter-spacing: 0.05em;
--shadow-card-hover: 0 4px 12px rgb(0 0 0 / 0.08);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@@ -61,112 +103,35 @@
--color-sidebar-ring: var(--sidebar-ring);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@layer utilities {
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
@layer base {
* {
@apply border-border outline-ring/50;
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background font-sans text-body text-foreground antialiased;
font-optical-sizing: auto;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
::selection {
background: var(--brand);
color: var(--brand-foreground);
}
}
/* Preserve completion events while removing visible nonessential motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+96
View File
@@ -0,0 +1,96 @@
/* Fluid Emerald / Kinetic Kanban: shared by the first paint and the app bundle. */
:root {
color-scheme: light;
--radius: 0.25rem;
--background: #f7f9fb;
--foreground: #191c1e;
--card: #ffffff;
--card-foreground: #191c1e;
--popover: #ffffff;
--popover-foreground: #191c1e;
--primary: #006e2f;
--primary-foreground: #ffffff;
--secondary: #d0e1fb;
--secondary-foreground: #38485d;
--muted: #f2f4f6;
--muted-foreground: #505f76;
--accent: #dcfce7;
--accent-foreground: #005321;
--destructive: #ba1a1a;
--destructive-foreground: #ffffff;
--border: #e2e8f0;
--input: #6d7b6c;
--ring: #006e2f;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #dcfce7;
--success-foreground: #005321;
--warning: #fef3c7;
--warning-foreground: #78350f;
--info: #dbeafe;
--info-foreground: #1e40af;
--chart-1: #006e2f;
--chart-2: #505f76;
--chart-3: #9e4036;
--chart-4: #92400e;
--chart-5: #1d4ed8;
--sidebar-background: #f2f4f6;
--sidebar-foreground: #191c1e;
--sidebar-primary: #006e2f;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dcfce7;
--sidebar-accent-foreground: #005321;
--sidebar-border: #e2e8f0;
--sidebar-ring: #006e2f;
--sidebar: #f2f4f6;
}
.dark {
color-scheme: dark;
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--popover: #1e293b;
--popover-foreground: #f1f5f9;
--primary: #4ae176;
--primary-foreground: #002109;
--secondary: #334155;
--secondary-foreground: #e2e8f0;
--muted: #1e293b;
--muted-foreground: #b7c8e1;
--accent: #173b2b;
--accent-foreground: #6bff8f;
--destructive: #fca5a5;
--destructive-foreground: #450a0a;
--border: #334155;
--input: #8293aa;
--ring: #4ae176;
--brand: #22c55e;
--brand-foreground: #002109;
--success: #173b2b;
--success-foreground: #6bff8f;
--warning: #422006;
--warning-foreground: #fde68a;
--info: #172554;
--info-foreground: #bfdbfe;
--chart-1: #4ae176;
--chart-2: #b7c8e1;
--chart-3: #ffb4a9;
--chart-4: #fbbf24;
--chart-5: #60a5fa;
--sidebar-background: #111c30;
--sidebar-foreground: #f1f5f9;
--sidebar-primary: #4ae176;
--sidebar-primary-foreground: #002109;
--sidebar-accent: #173b2b;
--sidebar-accent-foreground: #6bff8f;
--sidebar-border: #334155;
--sidebar-ring: #4ae176;
--sidebar: #111c30;
}
html {
background-color: var(--background);
color: var(--foreground);
}
+5 -5
View File
@@ -3,7 +3,8 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import '../css/app.css';
import { initializeTheme } from '@/composables/useAppearance';
import { createAppearance } from '@/composables/useAppearance';
import { createLocalization } from '@/lib/localization';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -17,12 +18,11 @@ createInertiaApp({
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization(props.initialPage.props.localization))
.use(createAppearance(props.initialPage.props.appearance))
.mount(el);
},
progress: {
color: '#4B5563',
color: 'var(--primary)',
},
});
// This will set light / dark mode on page load...
initializeTheme();
+4 -4
View File
@@ -2,15 +2,15 @@
import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
errors: string[];
title?: string;
};
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
});
const props = defineProps<Props>();
const { t } = useLocalization();
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
@@ -18,7 +18,7 @@ const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertTitle>{{ title ?? t('common.error') }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
+11 -10
View File
@@ -1,16 +1,17 @@
<script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { Columns3 } from 'lucide-vue-next';
</script>
<template>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-md"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate font-semibold leading-tight"
>Laravel Starter Kit</span
<span class="flex min-w-0 items-center gap-3">
<span
class="bg-brand text-brand-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
</div>
<Columns3 class="size-5" aria-hidden="true" />
</span>
<span
class="text-title truncate font-semibold group-data-[collapsible=icon]:hidden"
>KanbanGit</span
>
</span>
</template>
+86 -80
View File
@@ -1,18 +1,10 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import {
BookOpen,
Folder,
FolderGit2,
LayoutGrid,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { Link, router } from '@inertiajs/vue3';
import { Settings2, X } from 'lucide-vue-next';
import { computed, onMounted, onUnmounted } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import { Button } from '@/components/ui/button';
import {
Sidebar,
SidebarContent,
@@ -21,84 +13,98 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { dashboard } from '@/routes';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
import { edit as profile } from '@/routes/profile';
import { show } from '@/routes/projects';
const page = usePage();
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projects',
href: projectsIndex(),
icon: Folder,
},
];
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
const { t } = useLocalization();
const { currentUrl } = useCurrentUrl();
const { project, workspaceItems, projectItems, projectSettingsItems } =
useAppNavigation();
const { isMobile, setOpenMobile } = useSidebar();
const accountItems = computed(() => [
{
title: t('navigation.settings'),
href: profile(),
icon: Settings2,
isActive: currentUrl.value.startsWith('/settings/'),
},
]);
let removeNavigationListener: (() => void) | undefined;
onMounted(() => {
removeNavigationListener = router.on('navigate', () =>
setOpenMobile(false),
);
});
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
onUnmounted(() => removeNavigationListener?.());
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Sidebar id="app-navigation" collapsible="icon" variant="sidebar">
<SidebarHeader class="border-sidebar-border gap-4 border-b p-3">
<div class="flex items-center gap-2">
<SidebarMenu class="min-w-0">
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
as-child
tooltip="KanbanGit"
>
<Link :href="dashboard()" aria-label="KanbanGit"
><AppLogo
/></Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Button
v-if="isMobile"
variant="ghost"
size="icon"
:aria-label="t('navigation.close')"
@click="setOpenMobile(false)"
>
<X aria-hidden="true" />
</Button>
</div>
<Link
v-if="project"
:href="show(project.id)"
class="bg-sidebar-accent flex min-w-0 items-center gap-3 rounded-md p-3 group-data-[collapsible=icon]:hidden"
>
<span
class="text-label bg-brand/15 text-primary shrink-0 rounded-md px-2 py-1"
>{{ project.key }}</span
>
<span class="truncate font-semibold" :title="project.name">{{
project.name
}}</span>
</Link>
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
<nav :aria-label="t('navigation.main')">
<NavMain
:items="workspaceItems"
:label="t('navigation.workspace')"
/>
<NavMain
v-if="projectItems.length"
:items="projectItems"
:label="t('navigation.project')"
/>
<NavMain
v-if="projectSettingsItems.length"
:items="projectSettingsItems"
:label="t('navigation.projectSettings')"
/>
</nav>
</SidebarContent>
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
<SidebarFooter class="border-sidebar-border border-t p-0">
<NavMain :items="accountItems" :label="t('navigation.account')" />
</SidebarFooter>
</Sidebar>
<slot />
</template>
+131 -13
View File
@@ -1,27 +1,145 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Monitor, Moon, Plus, Sun } from 'lucide-vue-next';
import { computed } from 'vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { UserAvatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { useAppNavigation } from '@/composables/useAppNavigation';
import {
normalizeAppearance,
useAppearance,
} from '@/composables/useAppearance';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
import { create as createProject } from '@/routes/projects';
import { index as board } from '@/routes/projects/board';
import { create as createTicket } from '@/routes/projects/tickets';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
withDefaults(defineProps<{ breadcrumbs?: BreadcrumbItem[] }>(), {
breadcrumbs: () => [],
});
const page = usePage();
const { project } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const { appearance, updateAppearance } = useAppearance();
const themeIcon = computed(
() => ({ light: Sun, dark: Moon, system: Monitor })[appearance.value],
);
const createAction = computed(() => {
if (project.value?.canCreateTickets) {
return {
title: t('navigation.createTicket'),
href: createTicket(project.value.id, {
query: {
source: isCurrentUrl(board(project.value.id))
? 'board'
: 'backlog',
},
}),
};
}
if (!project.value && page.props.auth.permissions.projectCreate) {
return { title: t('navigation.createProject'), href: createProject() };
}
return null;
});
</script>
<template>
<header
class="border-sidebar-border/70 group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear md:px-4"
class="bg-background md:px-page sticky top-0 z-20 flex min-h-16 min-w-0 shrink-0 items-center justify-between gap-2 border-b px-3 md:gap-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
<div class="flex min-w-0 flex-1 items-center gap-2">
<SidebarTrigger class="shrink-0" />
<Breadcrumbs v-if="breadcrumbs.length" :breadcrumbs="breadcrumbs" />
<span v-else class="truncate font-semibold">KanbanGit</span>
</div>
<div class="flex shrink-0 items-center gap-1 sm:gap-2">
<Button
v-if="createAction"
as-child
:aria-label="createAction.title"
class="px-3"
>
<Link :href="createAction.href"
><Plus aria-hidden="true" /><span
class="hidden sm:inline"
>{{ createAction.title }}</span
></Link
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="t('appearance.label')"
><component :is="themeIcon" aria-hidden="true"
/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{{
t('appearance.label')
}}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
:model-value="appearance"
@update:model-value="
(value) =>
updateAppearance(normalizeAppearance(value))
"
>
<DropdownMenuRadioItem value="light"
><Sun aria-hidden="true" />{{
t('appearance.light')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="dark"
><Moon aria-hidden="true" />{{
t('appearance.dark')
}}</DropdownMenuRadioItem
>
<DropdownMenuRadioItem value="system"
><Monitor aria-hidden="true" />{{
t('appearance.system')
}}</DropdownMenuRadioItem
>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
:aria-label="
t('navigation.userMenu', {
name: page.props.auth.user.name,
})
"
data-test="sidebar-menu-button"
>
<UserAvatar :user="page.props.auth.user" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-60">
<UserMenuContent :user="page.props.auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</template>
+15 -9
View File
@@ -1,33 +1,39 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance';
import { useLocalization } from '@/composables/useLocalization';
const { appearance, updateAppearance } = useAppearance();
const { t } = useLocalization();
const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'System' },
{ value: 'light', Icon: Sun, label: 'appearance.light' },
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
] as const;
</script>
<template>
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
role="group"
:aria-label="t('appearance.label')"
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
type="button"
:aria-pressed="appearance === value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
appearance === value
? 'shadow-xs bg-white dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
<span>{{ t(label) }}</span>
</button>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import FormFeedback from '@/components/FormFeedback.vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
import { login } from '@/routes/index';
const props = defineProps<{
state: FormFeedbackState;
responseStatus?: number | null;
loadingTitle?: string;
successTitle?: string;
}>();
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const expired = computed(
() => props.state === 'server' && props.responseStatus === 419,
);
const limited = computed(
() => props.state === 'server' && props.responseStatus === 429,
);
const title = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredTitle');
}
if (limited.value) {
return t('auth.feedback.limitedTitle');
}
if (props.state === 'loading') {
return props.loadingTitle ?? t('auth.feedback.pending');
}
if (props.state === 'success') {
return props.successTitle ?? t('auth.feedback.completed');
}
if (props.state === 'validation') {
return t('auth.feedback.validationTitle');
}
return undefined;
});
const description = computed(() => {
if (expired.value) {
return t('auth.feedback.expiredDescription');
}
if (limited.value) {
return t('auth.feedback.limitedDescription');
}
if (props.state === 'validation') {
return t('auth.feedback.validationDescription');
}
if (props.state === 'network') {
return t('auth.feedback.networkDescription');
}
if (props.state === 'server') {
return t('auth.feedback.serverDescription');
}
if (props.state === 'cancelled') {
return t('auth.feedback.cancelledDescription');
}
return undefined;
});
</script>
<template>
<div class="gap-card grid" v-show="state !== 'idle'">
<FormFeedback
:state="state"
:title="title"
:description="description"
:retryable="!expired"
@retry="$emit('retry')"
/>
<Button v-if="expired" as-child variant="outline">
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
</Button>
</div>
</template>
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
import type {
BacklogTicketTreeEmits,
BacklogTicketTreeProps,
} from '@/types/backlog';
import type { TicketModel } from '@/types/models/ticket-model';
const props = defineProps<BacklogTicketTreeProps>();
const emit = defineEmits<BacklogTicketTreeEmits>();
const { t } = useLocalization();
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
</script>
<template>
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.ticket.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.ticket.id)"
:aria-controls="`backlog-children-${node.ticket.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.ticket.id)
? 'backlog.tree.expand'
: 'backlog.tree.collapse',
{ key: node.ticket.ticket_key },
)
"
@click="emit('toggle', node.ticket.id)"
>
<ChevronRight
v-if="collapsedTicketIds.has(node.ticket.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="grid min-w-0 gap-2">
<Badge
v-if="!node.matchesFilters"
variant="secondary"
class="w-fit"
>
{{ t('backlog.filters.parentContext') }}
</Badge>
<TicketCard
:project-id="projectId"
:ticket="node.ticket"
layout="list"
:class="{ 'opacity-80': !node.matchesFilters }"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
move.form({
project: projectId,
ticket: node.ticket.id,
})
"
v-slot="{ processing, errors }"
:error-bag="`backlog-ticket-${node.ticket.id}`"
:options="{ preserveScroll: true }"
:on-before="() => pendingTicketId === null"
class="grid min-w-0 gap-2"
@start="emit('moveStart', node.ticket.id)"
@finish="emit('moveFinish', node.ticket.id)"
>
<input
type="hidden"
name="redirect_to"
value="backlog"
/>
<label
:for="`backlog-ticket-status-${node.ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>
{{
t('backlog.moveTicket', {
key: node.ticket.ticket_key,
})
}}
</label>
<select
:id="`backlog-ticket-status-${node.ticket.id}`"
name="ticket_status_id"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
:aria-invalid="
!!errors.ticket_status_id || undefined
"
:aria-describedby="
errors.ticket_status_id
? `backlog-ticket-status-error-${node.ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
node.ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t('tickets.noStatus'),
)
}}
</option>
</select>
<InputError
:id="`backlog-ticket-status-error-${node.ticket.id}`"
:message="errors.ticket_status_id"
/>
<Button
type="submit"
variant="outline"
:disabled="
pendingTicketId !== null ||
processing ||
allowedTargetStatuses(node.ticket)
.length === 0
"
>
{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}
</Button>
<p
v-if="
allowedTargetStatuses(node.ticket)
.length === 0
"
class="text-caption text-muted-foreground"
>
{{ t('board.noMovesDescription') }}
</p>
</Form>
</template>
</TicketCard>
</div>
</div>
<div
v-if="
node.children.length > 0 &&
!collapsedTicketIds.has(node.ticket.id)
"
:id="`backlog-children-${node.ticket.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<BacklogTicketTree
:nodes="node.children"
:project-id="projectId"
:can-edit-tickets="canEditTickets"
:collapsed-ticket-ids="collapsedTicketIds"
:pending-ticket-id="pendingTicketId"
@toggle="emit('toggle', $event)"
@move-start="emit('moveStart', $event)"
@move-finish="emit('moveFinish', $event)"
/>
</div>
</li>
</ul>
</template>
+25 -6
View File
@@ -9,29 +9,48 @@ import {
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
breadcrumbs: BreadcrumbItemType[];
};
defineProps<Props>();
const { t } = useLocalization();
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<Breadcrumb class="min-w-0" :aria-label="t('navigation.breadcrumbs')">
<BreadcrumbList class="flex-nowrap">
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<BreadcrumbItem
:class="[
'min-w-0',
index !== breadcrumbs.length - 1
? 'hidden md:inline-flex'
: '',
]"
>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
<BreadcrumbPage class="truncate" :title="item.title">{{
item.title
}}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link>
<Link
:href="item.href"
class="max-w-40 truncate"
:title="item.title"
>{{ item.title }}</Link
>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
<BreadcrumbSeparator
v-if="index !== breadcrumbs.length - 1"
class="hidden md:block"
/>
</template>
</BreadcrumbList>
</Breadcrumb>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { CircleAlert, CircleCheck, Inbox, LoaderCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import { useLocalization } from '@/composables/useLocalization';
const props = withDefaults(
defineProps<{
state: 'empty' | 'loading' | 'error' | 'success';
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: false },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const icons = {
empty: Inbox,
loading: LoaderCircle,
error: CircleAlert,
success: CircleCheck,
};
const defaultTitles = {
empty: 'feedback.empty',
loading: 'common.loading',
error: 'common.error',
success: 'feedback.saved',
} as const;
const heading = computed(() => props.title ?? t(defaultTitles[props.state]));
</script>
<template>
<div
:role="state === 'error' ? 'alert' : 'status'"
:aria-busy="state === 'loading' || undefined"
:data-feedback="state"
class="gap-card bg-card p-card text-body text-card-foreground flex min-w-0 items-start rounded-md border"
:class="{
'border-destructive/40': state === 'error',
'border-success/40': state === 'success',
}"
>
<component
:is="icons[state]"
aria-hidden="true"
class="mt-0.5 size-5 shrink-0"
:class="{
'text-primary animate-spin motion-reduce:animate-none':
state === 'loading',
'text-destructive': state === 'error',
'text-success-foreground': state === 'success',
'text-muted-foreground': state === 'empty',
}"
/>
<div class="gap-stack grid min-w-0 flex-1 break-words">
<p class="font-semibold">{{ heading }}</p>
<p v-if="description" class="text-muted-foreground">
{{ description }}
</p>
<div v-if="$slots.default"><slot /></div>
<Button
v-if="retryable && state === 'error'"
type="button"
variant="outline"
class="justify-self-start"
@click="$emit('retry')"
>{{ t('common.retry') }}</Button
>
</div>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { useLocalization } from '@/composables/useLocalization';
import type { FormFeedbackState } from '@/lib/form-feedback';
const props = withDefaults(
defineProps<{
state: FormFeedbackState;
title?: string;
description?: string;
retryable?: boolean;
}>(),
{ retryable: true },
);
defineEmits<{ retry: [] }>();
const { t } = useLocalization();
const feedback = useTemplateRef<HTMLDivElement>('feedback');
watch(
() => props.state,
async (state) => {
if (['validation', 'network', 'server', 'cancelled'].includes(state)) {
await nextTick();
if (props.state === state) {
feedback.value?.focus();
}
}
},
);
const titles = {
idle: 'feedback.saved',
loading: 'forms.saving',
success: 'feedback.saved',
validation: 'forms.validationTitle',
network: 'forms.networkTitle',
server: 'forms.serverTitle',
cancelled: 'forms.cancelledTitle',
} as const;
const descriptions = {
validation: 'forms.validationDescription',
network: 'forms.networkDescription',
server: 'forms.serverDescription',
cancelled: 'forms.cancelledDescription',
} as const;
const description = computed(() =>
props.state in descriptions
? t(descriptions[props.state as keyof typeof descriptions])
: undefined,
);
</script>
<template>
<div
ref="feedback"
v-show="state !== 'idle'"
:data-form-state="state"
tabindex="-1"
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
>
<FeedbackState
v-if="state !== 'idle'"
:state="
state === 'loading' || state === 'success' ? state : 'error'
"
:title="props.title ?? t(titles[state])"
:description="props.description ?? description"
:retryable="
props.retryable !== false &&
(state === 'network' ||
state === 'server' ||
state === 'cancelled')
"
@retry="$emit('retry')"
/>
</div>
</template>
+2 -2
View File
@@ -5,8 +5,8 @@ defineProps<{
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
<div v-show="message" role="alert">
<p class="text-body text-destructive">
{{ message }}
</p>
</div>
+29 -6
View File
@@ -12,23 +12,46 @@ import type { NavItem } from '@/types';
defineProps<{
items: NavItem[];
label: string;
}>();
const { isCurrentUrl } = useCurrentUrl();
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarGroup class="px-3 py-2">
<SidebarGroupLabel class="text-label text-muted-foreground uppercase">{{
label
}}</SidebarGroupLabel>
<SidebarMenu role="list" :aria-label="label">
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="isCurrentUrl(item.href)"
:is-active="
item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
)
"
:tooltip="item.title"
class="min-h-control data-[active=true]:border-brand border-l-2 border-transparent px-3 data-[active=true]:font-semibold group-data-[collapsible=icon]:min-h-8"
>
<Link :href="item.href">
<component :is="item.icon" />
<Link
:href="item.href"
:aria-current="
(item.isActive ??
isCurrentUrl(
item.href,
undefined,
item.match === 'section',
))
? 'page'
: undefined
"
>
<component :is="item.icon" aria-hidden="true" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
+11 -4
View File
@@ -2,6 +2,7 @@
import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue';
import { useLocalization } from '@/composables/useLocalization';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
const props = defineProps<{
class?: HTMLAttributes['class'];
disabled?: boolean;
}>();
const model = defineModel<string | number | null>();
const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef');
const { t } = useLocalization();
defineExpose({
$el: inputRef,
@@ -28,7 +31,8 @@ defineExpose({
ref="inputRef"
v-model="model"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
:class="cn('pr-12', props.class)"
:disabled="disabled"
v-bind="$attrs"
/>
<button
@@ -36,11 +40,14 @@ defineExpose({
@click="showPassword = !showPassword"
:class="
cn(
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
:aria-label="
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
"
:aria-pressed="showPassword"
:disabled="disabled"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, Settings2 } from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppNavigation } from '@/composables/useAppNavigation';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { useLocalization } from '@/composables/useLocalization';
const { projectItems, projectSettingsItems } = useAppNavigation();
const { isCurrentUrl } = useCurrentUrl();
const { t } = useLocalization();
const settingsActive = computed(() =>
projectSettingsItems.value.some((item) => isCurrentUrl(item.href)),
);
</script>
<template>
<div
v-if="projectItems.length"
class="bg-card md:px-page flex min-w-0 items-center gap-2 border-b px-4"
>
<nav
:aria-label="t('navigation.project')"
class="flex min-w-0 flex-1 gap-4 overflow-x-auto"
>
<Link
v-for="item in projectItems"
:key="item.title"
:href="item.href"
:aria-current="isCurrentUrl(item.href) ? 'page' : undefined"
:class="[
'min-h-control text-body flex shrink-0 items-center gap-2 border-b-2 px-1 py-3 font-medium outline-offset-[-3px]',
isCurrentUrl(item.href)
? 'border-brand text-primary'
: 'text-muted-foreground hover:border-border hover:text-foreground border-transparent',
]"
>
<component
:is="item.icon"
class="size-4"
aria-hidden="true"
/>{{ item.title }}
</Link>
</nav>
<DropdownMenu v-if="projectSettingsItems.length">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
:aria-label="t('navigation.projectSettings')"
:class="{
'bg-accent text-accent-foreground': settingsActive,
}"
>
<Settings2 aria-hidden="true" /><span
class="hidden sm:inline"
>{{ t('navigation.projectSettings') }}</span
><ChevronDown class="hidden sm:block" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="item in projectSettingsItems"
:key="item.title"
as-child
>
<Link
:href="item.href"
:aria-current="
isCurrentUrl(item.href) ? 'page' : undefined
"
:class="{ 'bg-accent': isCurrentUrl(item.href) }"
>
<component :is="item.icon" aria-hidden="true" />{{
item.title
}}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
+75 -27
View File
@@ -1,45 +1,93 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ChevronDown, ChevronRight } from 'lucide-vue-next';
import { reactive } from 'vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTreeListProps } from '@/types/ticket-tree';
defineProps<{
nodes: TicketTreeNodeModel[];
projectId: number;
}>();
defineProps<TicketTreeListProps>();
const { t } = useLocalization();
const collapsedTicketIds = reactive(new Set<number>());
function toggleTicket(ticketId: number): void {
if (collapsedTicketIds.has(ticketId)) {
collapsedTicketIds.delete(ticketId);
} else {
collapsedTicketIds.add(ticketId);
}
}
</script>
<template>
<ul class="space-y-3">
<li v-for="node in nodes" :key="node.id">
<div class="border-border rounded-xl border px-4 py-4">
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
<ul class="grid min-w-0 gap-3">
<li v-for="node in nodes" :key="node.id" class="min-w-0">
<div class="grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] gap-2">
<div class="flex justify-center pt-1">
<button
v-if="node.children.length > 0"
type="button"
class="border-border bg-card text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/35 flex size-10 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2"
:aria-expanded="!collapsedTicketIds.has(node.id)"
:aria-controls="`ticket-tree-children-${node.id}`"
:aria-label="
t(
collapsedTicketIds.has(node.id)
? 'tickets.tree.expand'
: 'tickets.tree.collapse',
{ key: node.ticket_key },
)
"
class="text-foreground hover:text-primary font-medium transition"
@click="toggleTicket(node.id)"
>
{{ node.ticket_key }}
</Link>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
<ChevronRight
v-if="collapsedTicketIds.has(node.id)"
class="size-4"
aria-hidden="true"
/>
<ChevronDown v-else class="size-4" aria-hidden="true" />
</button>
<span
v-else
class="bg-border mt-4 size-1.5 rounded-full"
aria-hidden="true"
/>
</div>
<div class="text-foreground mt-2 text-sm">
{{ node.title }}
</div>
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
"
class="bg-card text-card-foreground hover:border-primary focus-visible:ring-ring/35 gap-stack group grid min-w-0 rounded-md border p-4 transition-[border-color,box-shadow] focus-visible:outline-none focus-visible:ring-2"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline">
{{ node.ticket_key }}
</Badge>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<span
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ node.title }}
</span>
</Link>
</div>
<div
v-if="node.children.length > 0"
class="border-border mt-3 border-l pl-4"
v-if="
node.children.length > 0 && !collapsedTicketIds.has(node.id)
"
:id="`ticket-tree-children-${node.id}`"
class="border-border ml-[1.375rem] mt-3 border-l pl-2 sm:pl-4"
>
<TicketTreeList
:nodes="node.children"
+12 -23
View File
@@ -1,33 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
import type { User } from '@/types';
type Props = {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<{ user: User; showEmail?: boolean }>(), {
showEmail: false,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script>
<template>
<UserAvatar />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="text-muted-foreground truncate text-xs">{{
user.email
}}</span>
<div class="flex min-w-0 items-center gap-2">
<UserAvatar :user="user" />
<div class="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span
v-if="showEmail"
class="text-muted-foreground truncate text-xs"
>{{ user.email }}</span
>
</div>
</div>
</template>
+4 -2
View File
@@ -11,6 +11,7 @@ import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index';
import type { User } from '@/types';
import { useLocalization } from '@/composables/useLocalization';
type Props = {
user: User;
@@ -21,6 +22,7 @@ const handleLogout = () => {
};
defineProps<Props>();
const { t } = useLocalization();
</script>
<template>
@@ -34,7 +36,7 @@ defineProps<Props>();
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" />
Settings
{{ t('navigation.settings') }}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -48,7 +50,7 @@ defineProps<Props>();
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
Log out
{{ t('navigation.logout') }}
</Link>
</DropdownMenuItem>
</template>
@@ -0,0 +1,177 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
import { computed } from 'vue';
const props = defineProps<{
areas: string[];
projectId: number;
templateState?: ProjectTemplateState | null;
}>();
const form = useForm<{
areas: string[];
status_replacements: Record<string, number | undefined>;
}>({
areas: [],
status_replacements: {},
});
const activeArea = computed(() => form.areas[0]);
const inheritedAreas = computed(
() =>
props.templateState?.inheritedAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const overriddenAreas = computed(
() =>
props.templateState?.overriddenAreas.filter((area) =>
props.areas.includes(area.value),
) ?? [],
);
const statusRequirement = computed(
() => props.templateState?.resetRequirements?.ticket_statuses,
);
const canReset = (area: ProjectTemplateStateArea): boolean => {
if (area.value !== 'ticket_statuses') {
return true;
}
return (
statusRequirement.value?.removedStatuses.every(
(status) =>
form.status_replacements[String(status.id)] !== undefined,
) ?? true
);
};
const resetArea = (area: ProjectTemplateStateArea): void => {
if (!canReset(area)) {
return;
}
form.areas = [area.value];
form.post(ProjectController.resetTemplate.url(props.projectId), {
preserveScroll: true,
onSuccess: () => form.reset(),
});
};
</script>
<template>
<Card
v-if="
templateState?.inheritsFromTemplate &&
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
"
>
<CardHeader>
<CardTitle>
Template inheritance: {{ templateState.templateName }}
</CardTitle>
<CardDescription>
Every setting area inherits independently. Local changes stay
unchanged when the template is updated until that area is reset.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap gap-2">
<Badge
v-for="area in inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
>
{{ area.label }} · inherited
</Badge>
</div>
<div
v-for="area in overriddenAreas"
:key="`overridden-${area.value}`"
class="space-y-3 rounded-lg border p-4"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<Badge>{{ area.label }} · template overridden</Badge>
<Button
type="button"
size="sm"
variant="outline"
:disabled="form.processing || !canReset(area)"
@click="resetArea(area)"
>
{{
activeArea === area.value && form.processing
? 'Resetting…'
: 'Reset to template'
}}
</Button>
</div>
<div
v-if="area.value === 'ticket_statuses' && statusRequirement"
class="space-y-3"
>
<p class="text-muted-foreground text-sm">
Choose where tickets in local statuses should move
before those statuses are removed.
</p>
<div
v-for="status in statusRequirement.removedStatuses"
:key="status.id"
class="grid gap-2 sm:grid-cols-2 sm:items-center"
>
<Label :for="`replacement-${status.id}`">
{{ status.name }} ({{ status.ticketCount }} tickets)
</Label>
<div class="grid gap-1">
<select
:id="`replacement-${status.id}`"
v-model="
form.status_replacements[String(status.id)]
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
>
<option :value="undefined" disabled>
Select replacement status
</option>
<option
v-for="option in statusRequirement.replacementOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<InputError
:message="
form.errors[
`status_replacements.${status.id}`
]
"
/>
</div>
</div>
</div>
<InputError :message="form.errors.areas" />
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ArrowUpRight } from 'lucide-vue-next';
import TicketMetadata from '@/components/tickets/TicketMetadata.vue';
import { Badge } from '@/components/ui/badge';
import { useLocalization } from '@/composables/useLocalization';
import { show as showTicket } from '@/routes/projects/tickets';
import type {
TicketPresentationEmits,
TicketPresentationProps,
} from '@/types/ticket-presentation';
withDefaults(defineProps<TicketPresentationProps>(), {
layout: 'card',
showDescription: true,
showStatus: true,
});
const emit = defineEmits<TicketPresentationEmits>();
const { t } = useLocalization();
</script>
<template>
<article
:class="[
'bg-card text-card-foreground min-w-0 rounded-md border',
layout === 'card' ? 'shadow-sm' : '',
ticket.is_blocked ? 'border-warning' : 'border-border',
]"
>
<div
:class="
layout === 'list'
? 'flex min-w-0 flex-col lg:flex-row lg:items-stretch'
: ''
"
>
<Link
:href="showTicket({ project: projectId, ticket: ticket.id })"
:aria-label="
t('tickets.open', {
key: ticket.ticket_key,
title: ticket.title,
})
"
:class="[
'focus-visible:ring-ring/35 gap-card min-h-control hover:bg-accent/35 group flex min-w-0 flex-1 flex-col p-4 transition-[background-color,box-shadow] focus-visible:outline-none focus-visible:ring-2',
layout === 'list'
? 'rounded-t-md lg:rounded-l-md lg:rounded-tr-none'
: 'rounded-t-md',
]"
@click="emit('open', ticket.id)"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>
{{ ticket.ticket_key }}
</Badge>
<ArrowUpRight
class="text-muted-foreground group-hover:text-primary size-4 shrink-0"
aria-hidden="true"
/>
</div>
<h3
class="text-body group-hover:text-primary wrap-anywhere min-w-0 font-semibold"
>
{{ ticket.title }}
</h3>
<TicketMetadata :ticket="ticket" :show-status="showStatus" />
<p
v-if="showDescription && ticket.description"
class="text-caption text-muted-foreground wrap-anywhere line-clamp-3"
>
{{ ticket.description }}
</p>
<p
v-if="ticket.is_blocked && ticket.blocked_comment"
class="text-caption text-warning-foreground bg-warning wrap-anywhere rounded-sm px-3 py-2"
>
{{
t('tickets.blockerComment', {
comment: ticket.blocked_comment,
})
}}
</p>
</Link>
<div
v-if="$slots.actions"
:class="[
'border-t p-4',
layout === 'list'
? 'lg:flex lg:w-80 lg:items-center lg:border-l lg:border-t-0'
: '',
]"
>
<slot name="actions" />
</div>
</div>
</article>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
withDefaults(
defineProps<{
id: string;
label: string;
kind?: 'text' | 'number' | 'textarea' | 'select';
options?: { value: string | number; label: string }[];
error?: string;
hint?: string;
disabled?: boolean;
required?: boolean;
autofocus?: boolean;
placeholder?: string;
rows?: number;
min?: number;
step?: string;
}>(),
{ kind: 'text', rows: 5 },
);
const model = defineModel<string | number | null>({ required: true });
const controlClass =
'min-h-control border-input bg-card focus-visible:border-ring focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 text-base shadow-xs outline-none transition-[border-color,box-shadow] hover:border-primary/45 focus-visible:ring-2 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-60 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/25 md:text-body';
</script>
<template>
<div class="grid min-w-0 content-start gap-2">
<Label :for="id">{{ label }}</Label>
<select
v-if="kind === 'select'"
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:required="required"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="kind === 'textarea'"
:id="id"
v-model="model"
:name="id"
:rows="rows"
:disabled="disabled"
:required="required"
:placeholder="placeholder"
:aria-invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
:class="controlClass"
/>
<Input
v-else
:id="id"
v-model="model"
:name="id"
:type="kind"
:disabled="disabled"
:required="required"
:autofocus="autofocus"
:placeholder="placeholder"
:min="min"
:step="step"
:maxlength="kind === 'text' ? 255 : undefined"
:invalid="Boolean(error)"
:aria-describedby="`${id}-hint ${id}-error`"
/>
<p
:id="`${id}-hint`"
class="text-muted-foreground text-xs empty:hidden"
>
{{ hint }}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More