Compare commits
19
Commits
v0.1.0
..
0f125f17a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f125f17a3 | ||
|
|
5e7be28daf | ||
|
|
d250f0b380 | ||
|
|
c19cf34505 | ||
|
|
37b2e6034f | ||
|
|
84e427ef5d | ||
|
|
95b53ba74b | ||
|
|
a8952d7867 | ||
|
|
2390daf2de | ||
|
|
b1357cf854 | ||
|
|
2c229c9b39 | ||
|
|
9a7dd7fb59 | ||
|
|
39a039cd1e | ||
|
|
e38c77198d | ||
|
|
ee9b9a2765 | ||
|
|
57711b9659 | ||
|
|
5e6426067a | ||
|
|
58d2edcacd | ||
|
|
3b17c9ee14 |
@@ -1,96 +0,0 @@
|
|||||||
---
|
|
||||||
name: mcp-development
|
|
||||||
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# MCP Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
Register MCP servers in `routes/ai.php`:
|
|
||||||
|
|
||||||
<!-- Register MCP Server -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Facades\Mcp;
|
|
||||||
|
|
||||||
Mcp::web();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating MCP Primitives
|
|
||||||
|
|
||||||
Create MCP tools, resources, prompts, and servers using artisan commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php artisan make:mcp-tool ToolName # Create a tool
|
|
||||||
|
|
||||||
php artisan make:mcp-resource ResourceName # Create a resource
|
|
||||||
|
|
||||||
php artisan make:mcp-prompt PromptName # Create a prompt
|
|
||||||
|
|
||||||
php artisan make:mcp-server ServerName # Create a server
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
|
||||||
|
|
||||||
### Tools
|
|
||||||
|
|
||||||
<!-- MCP Tool Example -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Server\Tool;
|
|
||||||
use Laravel\Mcp\Server\Request;
|
|
||||||
use Laravel\Mcp\Server\Response;
|
|
||||||
|
|
||||||
class MyTool extends Tool
|
|
||||||
{
|
|
||||||
public function handle(Request $request): Response
|
|
||||||
{
|
|
||||||
return new Response(['result' => 'success']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Registering Primitives in a Server
|
|
||||||
|
|
||||||
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
|
||||||
|
|
||||||
<!-- Register Primitives in MCP Server -->
|
|
||||||
```php
|
|
||||||
use Laravel\Mcp\Server;
|
|
||||||
|
|
||||||
class AppServer extends Server
|
|
||||||
{
|
|
||||||
protected array $tools = [
|
|
||||||
\App\Mcp\Tools\MyTool::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
protected array $resources = [
|
|
||||||
\App\Mcp\Resources\MyResource::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
protected array $prompts = [
|
|
||||||
\App\Mcp\Prompts\MyPrompt::class,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Check `routes/ai.php` for proper registration
|
|
||||||
2. Test tool via MCP client
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Running `mcp:start` command (it hangs waiting for input)
|
|
||||||
- Using HTTPS locally with Node-based MCP clients
|
|
||||||
- Not using `search-docs` for the latest MCP documentation
|
|
||||||
- Not registering MCP server routes in `routes/ai.php`
|
|
||||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
|
||||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
|
||||||
+7
-6
@@ -25,6 +25,7 @@
|
|||||||
**/compose.*.y*ml
|
**/compose.*.y*ml
|
||||||
!**/composer.json
|
!**/composer.json
|
||||||
!**/composer.lock
|
!**/composer.lock
|
||||||
|
**/Dockerfile*
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/npm-debug.log
|
**/npm-debug.log
|
||||||
**/obj
|
**/obj
|
||||||
@@ -40,9 +41,9 @@ vendor
|
|||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|
||||||
# Runtime-generated data must not become part of an image layer.
|
# Ignore development-related files and directories
|
||||||
storage/framework/cache/data/*
|
# For development environment, we will use mount bind to mount the source code to the container
|
||||||
storage/framework/sessions/*
|
# So we don't need to copy the source code to the container for development
|
||||||
storage/framework/testing/*
|
tests/
|
||||||
storage/framework/views/*
|
phpunit.xml
|
||||||
storage/logs/*
|
phpunit.xml.dist
|
||||||
|
|||||||
+1
-17
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
|||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
LOG_STACK=stderr
|
LOG_STACK=daily
|
||||||
|
|
||||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||||
LOG_DISCORD_WEBHOOK_URL=
|
LOG_DISCORD_WEBHOOK_URL=
|
||||||
@@ -38,22 +38,6 @@ MAIL_ENCRYPTION=tls
|
|||||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
CACHE_STORE=database
|
|
||||||
QUEUE_CONNECTION=database
|
|
||||||
SESSION_DRIVER=database
|
|
||||||
|
|
||||||
FILESYSTEM_DISK=public
|
FILESYSTEM_DISK=public
|
||||||
AVATAR_FILESYSTEM_DISK=public
|
|
||||||
AVATAR_URLS_TEMPORARY=false
|
|
||||||
AVATAR_URL_EXPIRATION_MINUTES=15
|
|
||||||
|
|
||||||
# Production: set AVATAR_FILESYSTEM_DISK=s3 and inject these as runtime secrets.
|
|
||||||
AWS_ACCESS_KEY_ID=
|
|
||||||
AWS_SECRET_ACCESS_KEY=
|
|
||||||
AWS_DEFAULT_REGION=eu-central-1
|
|
||||||
AWS_BUCKET=
|
|
||||||
AWS_URL=
|
|
||||||
AWS_ENDPOINT=
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
name: test_and_check_app
|
|
||||||
description: Build the test image and run all application checks
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- name: Test and check app
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--file Dockerfile \
|
|
||||||
--target test \
|
|
||||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
|
||||||
--progress plain \
|
|
||||||
--load \
|
|
||||||
.
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
name: CI/CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: git.twyco.de
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test_and_check_app:
|
|
||||||
name: test_and_check_app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
|
|
||||||
- name: Run required application checks
|
|
||||||
uses: ./.github/tests/test_and_check_app
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish immutable image
|
|
||||||
if: >-
|
|
||||||
github.event_name == 'push' &&
|
|
||||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
|
||||||
needs:
|
|
||||||
- test_and_check_app
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Validate ref and calculate image name
|
|
||||||
id: image
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
image_path="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
image="${REGISTRY}/${image_path}"
|
|
||||||
|
|
||||||
case "${GITHUB_REF}" in
|
|
||||||
refs/heads/main)
|
|
||||||
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
|
|
||||||
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
|
|
||||||
environment="staging"
|
|
||||||
;;
|
|
||||||
refs/tags/*)
|
|
||||||
tag="${GITHUB_REF#refs/tags/}"
|
|
||||||
|
|
||||||
if [[ ! "${tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
|
||||||
echo "Production tags must match vMAJOR.MINOR.PATCH." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
|
|
||||||
echo "Production tag must point to a commit contained in main." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
environment="production"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported ref: ${GITHUB_REF}" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "image=${image}"
|
|
||||||
echo "tag=${tag}"
|
|
||||||
echo "environment=${environment}"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Log in to private registry
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
if [[ -z "${REGISTRY_USERNAME}" || -z "${REGISTRY_TOKEN}" ]]; then
|
|
||||||
echo "Registry credentials are missing." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s' "${REGISTRY_TOKEN}" |
|
|
||||||
docker login \
|
|
||||||
--username "${REGISTRY_USERNAME}" \
|
|
||||||
--password-stdin \
|
|
||||||
"${REGISTRY}"
|
|
||||||
|
|
||||||
- name: Build and publish runtime image
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE: ${{ steps.image.outputs.image }}
|
|
||||||
IMAGE_TAG: ${{ steps.image.outputs.tag }}
|
|
||||||
DEPLOYMENT_ENVIRONMENT: ${{ steps.image.outputs.environment }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
if [[ "${DEPLOYMENT_ENVIRONMENT}" == "production" ]] &&
|
|
||||||
docker buildx imagetools inspect "${IMAGE}:${IMAGE_TAG}" >/dev/null 2>&1; then
|
|
||||||
echo "Production image ${IMAGE}:${IMAGE_TAG} already exists and will not be overwritten." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker buildx build \
|
|
||||||
--file Dockerfile \
|
|
||||||
--target runtime \
|
|
||||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
|
||||||
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
|
|
||||||
--label "de.twyco.environment=${DEPLOYMENT_ENVIRONMENT}" \
|
|
||||||
--tag "${IMAGE}:${IMAGE_TAG}" \
|
|
||||||
--push \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Remove registry credentials
|
|
||||||
if: ${{ always() }}
|
|
||||||
shell: bash
|
|
||||||
run: docker logout "${REGISTRY}" >/dev/null 2>&1 || true
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: composer lint
|
||||||
|
|
||||||
|
- name: Lint Frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# - name: Commit Changes
|
||||||
|
# uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
|
# with:
|
||||||
|
# commit_message: fix code style
|
||||||
|
# commit_options: '--no-verify'
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- staging
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php-version: ['8.4']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-version }}
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: xdebug
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node Dependencies
|
||||||
|
run: npm i
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: ./vendor/bin/phpunit
|
||||||
@@ -13,10 +13,10 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
- laravel/wayfinder (WAYFINDER) - v0
|
||||||
- laravel/boost (BOOST) - v2
|
- laravel/boost (BOOST) - v2
|
||||||
|
- laravel/mcp (MCP) - v0
|
||||||
- laravel/pail (PAIL) - v1
|
- laravel/pail (PAIL) - v1
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/sail (SAIL) - v1
|
- laravel/sail (SAIL) - v1
|
||||||
@@ -34,7 +34,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
|
|
||||||
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
|
||||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
- `project-route-structure` — Always apply in this project when creating, moving, or refactoring Laravel route definitions in routes/**/*.php; changing route prefixes, names, middleware, scoped bindings, or route files; or adding frontend-facing endpoints that Wayfinder generates. Documents the KanbanGit route-file layout and grouping conventions.
|
||||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||||
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
|
||||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||||
|
|||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
ARG COMPOSER_IMAGE=composer:2.9.5@sha256:698d3801b2a622ace460c4743c781282fcbcb733a4cbf8b31c44731e846585e8
|
|
||||||
ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436
|
|
||||||
ARG PHP_IMAGE=php:8.4-fpm-bookworm@sha256:c5fb7a0c02f4efe280691910c8b734995fa83598cdcf3115ef5dcb2e4617681c
|
|
||||||
|
|
||||||
FROM ${COMPOSER_IMAGE} AS composer_binary
|
|
||||||
FROM ${NODE_IMAGE} AS node_binary
|
|
||||||
|
|
||||||
FROM ${PHP_IMAGE} AS php_base
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
git \
|
|
||||||
libfcgi-bin \
|
|
||||||
libfreetype6-dev \
|
|
||||||
libicu-dev \
|
|
||||||
libjpeg62-turbo-dev \
|
|
||||||
libmagickwand-dev \
|
|
||||||
libonig-dev \
|
|
||||||
libpng-dev \
|
|
||||||
libpq-dev \
|
|
||||||
libsqlite3-dev \
|
|
||||||
libssl-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libzip-dev \
|
|
||||||
nginx \
|
|
||||||
procps \
|
|
||||||
unzip \
|
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
|
||||||
bcmath \
|
|
||||||
gd \
|
|
||||||
intl \
|
|
||||||
opcache \
|
|
||||||
pcntl \
|
|
||||||
pdo_mysql \
|
|
||||||
pdo_pgsql \
|
|
||||||
pdo_sqlite \
|
|
||||||
pgsql \
|
|
||||||
soap \
|
|
||||||
zip \
|
|
||||||
&& pecl install imagick \
|
|
||||||
&& docker-php-ext-enable imagick \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
||||||
|
|
||||||
COPY --from=composer_binary /usr/bin/composer /usr/local/bin/composer
|
|
||||||
|
|
||||||
WORKDIR /var/www
|
|
||||||
|
|
||||||
FROM php_base AS build
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
FROM build AS test
|
|
||||||
|
|
||||||
RUN touch .env \
|
|
||||||
&& composer audit --locked --no-interaction \
|
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
|
||||||
|
|
||||||
FROM build AS production_builder
|
|
||||||
|
|
||||||
RUN composer install \
|
|
||||||
--classmap-authoritative \
|
|
||||||
--no-dev \
|
|
||||||
--no-interaction \
|
|
||||||
--no-progress \
|
|
||||||
--prefer-dist \
|
|
||||||
&& rm -rf node_modules tests phpunit.xml
|
|
||||||
|
|
||||||
FROM php_base AS runtime
|
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/kanbangit-entrypoint
|
|
||||||
COPY docker/nginx/nginx.conf.template /etc/nginx/nginx.conf.template
|
|
||||||
COPY docker/php-fpm-healthcheck.sh /usr/local/bin/php-fpm-healthcheck
|
|
||||||
COPY --from=production_builder --chown=www-data:www-data /var/www /var/www
|
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/kanbangit-entrypoint /usr/local/bin/php-fpm-healthcheck \
|
|
||||||
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
|
|
||||||
&& printf '%s\n' \
|
|
||||||
'[global]' \
|
|
||||||
'daemonize = no' \
|
|
||||||
'' \
|
|
||||||
'[www]' \
|
|
||||||
'pm.status_path = /status' \
|
|
||||||
> /usr/local/etc/php-fpm.d/zz-kanbangit.conf \
|
|
||||||
&& mkdir -p \
|
|
||||||
/var/www/bootstrap/cache \
|
|
||||||
/var/www/storage/app/public \
|
|
||||||
/var/www/storage/framework/cache \
|
|
||||||
/var/www/storage/framework/sessions \
|
|
||||||
/var/www/storage/framework/testing \
|
|
||||||
/var/www/storage/framework/views \
|
|
||||||
/var/www/storage/logs \
|
|
||||||
&& ln -s ../storage/app/public /var/www/public/storage \
|
|
||||||
&& chown -R www-data:www-data /var/www/bootstrap/cache /var/www/storage
|
|
||||||
|
|
||||||
USER www-data
|
|
||||||
|
|
||||||
EXPOSE 8080 9000
|
|
||||||
|
|
||||||
ENTRYPOINT ["kanbangit-entrypoint"]
|
|
||||||
CMD ["php-fpm"]
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Actions\Notifications;
|
namespace App\Actions\Notifications;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Enums\DiscordAllowedMentionType;
|
use App\Enums\DiscordAllowedMentionType;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
]),
|
||||||
'templateVariables' => $this->templateVariables(),
|
'templateVariables' => $this->templateVariables(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use App\Models\Project;
|
|||||||
|
|
||||||
class BuildProjectBoardSettingsPayload
|
class BuildProjectBoardSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
|
|||||||
)->resolve(),
|
)->resolve(),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
]),
|
||||||
'ticketStatuses' => TicketStatusResource::collection(
|
'ticketStatuses' => TicketStatusResource::collection(
|
||||||
$project->ticketStatuses()
|
$project->ticketStatuses()
|
||||||
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
|
|||||||
|
|
||||||
class BuildProjectTicketMetadataSettingsPayload
|
class BuildProjectTicketMetadataSettingsPayload
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
'status' => $status,
|
'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(),
|
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
|||||||
use App\Http\Resources\TicketReferenceResource;
|
use App\Http\Resources\TicketReferenceResource;
|
||||||
use App\Http\Resources\TicketStatusResource;
|
use App\Http\Resources\TicketStatusResource;
|
||||||
use App\Http\Resources\TicketTypeResource;
|
use App\Http\Resources\TicketTypeResource;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
class BuildProjectTicketOptionsPayload
|
class BuildProjectTicketOptionsPayload
|
||||||
|
|||||||
@@ -6,25 +6,80 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CreateProject
|
class CreateProject
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @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
|
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;
|
||||||
|
|
||||||
|
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||||
|
$sourceTemplate = ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->findOrFail((int) $sourceTemplateId);
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $sourceTemplate->id,
|
||||||
|
])->save();
|
||||||
|
} else {
|
||||||
|
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||||
|
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $baseTemplate->id,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = $project->fresh() ?? $project;
|
||||||
|
|
||||||
$this->initializeProjectTicketMetadata->handle($project);
|
$this->initializeProjectTicketMetadata->handle($project);
|
||||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||||
|
|
||||||
return $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,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
|
class DuplicateProjectConfiguration
|
||||||
|
{
|
||||||
|
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
||||||
|
|
||||||
|
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function copyableAreas(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::COPY_PROJECT_SETTINGS,
|
||||||
|
self::COPY_BOARD_WORKFLOW,
|
||||||
|
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<array{value: string, label: string, description: string}>
|
||||||
|
*/
|
||||||
|
public static function copyAreaOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'value' => self::COPY_PROJECT_SETTINGS,
|
||||||
|
'label' => 'Project settings',
|
||||||
|
'description' => 'Copy general project preferences like story points and author visibility.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_BOARD_WORKFLOW,
|
||||||
|
'label' => 'Board workflow',
|
||||||
|
'description' => 'Copy board columns, ticket statuses, and allowed status transitions.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_TICKET_TYPES,
|
||||||
|
'label' => 'Ticket types',
|
||||||
|
'description' => 'Copy available ticket types, including parent-ticket types.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_TICKET_PRIORITIES,
|
||||||
|
'label' => 'Ticket priorities',
|
||||||
|
'description' => 'Copy the project-specific ticket priorities.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_TICKET_FIELDS,
|
||||||
|
'label' => 'Custom ticket fields',
|
||||||
|
'description' => 'Copy custom ticket field definitions without any ticket values.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_TICKET_LINK_TYPES,
|
||||||
|
'label' => 'Ticket link types',
|
||||||
|
'description' => 'Copy available relationship labels between tickets.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
'label' => 'Repository connections',
|
||||||
|
'description' => 'Copy linked repositories without changing the new project identity.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value' => self::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
'label' => 'Notification actions',
|
||||||
|
'description' => 'Copy notification rules and delivery settings.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $copyAreas
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function normalizeCopyAreas(array $copyAreas): array
|
||||||
|
{
|
||||||
|
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $copyAreas
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function export(Project $sourceProject, array $copyAreas): array
|
||||||
|
{
|
||||||
|
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||||
|
|
||||||
|
if ($selectedAreas === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceProject->loadMissing([
|
||||||
|
'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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_BOARD_WORKFLOW, $selectedAreas, true)) {
|
||||||
|
$boardColumnIndexes = [];
|
||||||
|
$boardColumns = [];
|
||||||
|
|
||||||
|
foreach ($sourceProject->boardColumns as $index => $boardColumn) {
|
||||||
|
$boardColumnIndexes[$boardColumn->id] = $index;
|
||||||
|
$boardColumns[] = [
|
||||||
|
'name' => $boardColumn->name,
|
||||||
|
'position' => $boardColumn->position,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketStatusIndexes = [];
|
||||||
|
$ticketStatuses = [];
|
||||||
|
|
||||||
|
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
|
||||||
|
$ticketStatusIndexes[$ticketStatus->id] = $index;
|
||||||
|
$ticketStatuses[] = [
|
||||||
|
'board_column_index' => $ticketStatus->board_column_id === null
|
||||||
|
? null
|
||||||
|
: ($boardColumnIndexes[$ticketStatus->board_column_id] ?? null),
|
||||||
|
'name' => $ticketStatus->name,
|
||||||
|
'position' => $ticketStatus->position,
|
||||||
|
'allowed_target_indexes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sourceProject->ticketStatuses as $index => $ticketStatus) {
|
||||||
|
$ticketStatuses[$index]['allowed_target_indexes'] = $ticketStatus->allowedTargetStatuses
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn (int $statusId): ?int => $ticketStatusIndexes[$statusId] ?? null)
|
||||||
|
->filter(static fn (?int $statusIndex): bool => $statusIndex !== null)
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration[self::COPY_BOARD_WORKFLOW] = [
|
||||||
|
'board_columns' => $boardColumns,
|
||||||
|
'ticket_statuses' => $ticketStatuses,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_TICKET_TYPES, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_TICKET_TYPES] = $sourceProject->ticketTypes
|
||||||
|
->map(fn ($ticketType): array => [
|
||||||
|
'name' => $ticketType->name,
|
||||||
|
'position' => $ticketType->position,
|
||||||
|
'is_parent' => $ticketType->is_parent,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_TICKET_PRIORITIES, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_TICKET_PRIORITIES] = $sourceProject->ticketPriorities
|
||||||
|
->map(fn ($ticketPriority): array => [
|
||||||
|
'name' => $ticketPriority->name,
|
||||||
|
'position' => $ticketPriority->position,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_TICKET_FIELDS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_TICKET_FIELDS] = $sourceProject->ticketFieldDefinitions
|
||||||
|
->map(fn ($ticketFieldDefinition): array => [
|
||||||
|
'field_type' => $ticketFieldDefinition->field_type,
|
||||||
|
'label' => $ticketFieldDefinition->label,
|
||||||
|
'position' => $ticketFieldDefinition->position,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_TICKET_LINK_TYPES, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_TICKET_LINK_TYPES] = $sourceProject->ticketLinkTypes
|
||||||
|
->map(fn ($ticketLinkType): array => [
|
||||||
|
'label' => $ticketLinkType->label,
|
||||||
|
'counterpart_label' => $ticketLinkType->counterpart_label,
|
||||||
|
'position' => $ticketLinkType->position,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_REPOSITORY_CONNECTIONS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_REPOSITORY_CONNECTIONS] = $sourceProject->repositoryConnections
|
||||||
|
->map(fn ($repositoryConnection): array => [
|
||||||
|
'provider' => $repositoryConnection->provider->value,
|
||||||
|
'base_url' => $repositoryConnection->base_url,
|
||||||
|
'provider_repository_id' => $repositoryConnection->provider_repository_id,
|
||||||
|
'owner_name' => $repositoryConnection->owner_name,
|
||||||
|
'repository_name' => $repositoryConnection->repository_name,
|
||||||
|
'repository_full_name' => $repositoryConnection->repository_full_name,
|
||||||
|
'repository_url' => $repositoryConnection->repository_url,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(self::COPY_NOTIFICATION_ACTIONS, $selectedAreas, true)) {
|
||||||
|
$configuration[self::COPY_NOTIFICATION_ACTIONS] = $sourceProject->notificationActions
|
||||||
|
->map(fn ($notificationAction): array => [
|
||||||
|
'name' => $notificationAction->name,
|
||||||
|
'enabled' => $notificationAction->enabled,
|
||||||
|
'event_type' => $notificationAction->event_type->value,
|
||||||
|
'channel' => $notificationAction->channel->value,
|
||||||
|
'channel_configuration' => $notificationAction->channel_configuration ?? [],
|
||||||
|
'message_template' => $notificationAction->message_template,
|
||||||
|
'delivery_url' => $notificationAction->delivery_url,
|
||||||
|
'filters' => $notificationAction->filters ?? [],
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $configuration
|
||||||
|
*/
|
||||||
|
public function apply(Project $targetProject, array $configuration): void
|
||||||
|
{
|
||||||
|
if (array_key_exists(self::COPY_PROJECT_SETTINGS, $configuration)) {
|
||||||
|
$projectSettings = $configuration[self::COPY_PROJECT_SETTINGS];
|
||||||
|
|
||||||
|
$targetProject->forceFill([
|
||||||
|
'show_author' => (bool) ($projectSettings['show_author'] ?? $targetProject->show_author),
|
||||||
|
'show_story_points' => (bool) ($projectSettings['show_story_points'] ?? $targetProject->show_story_points),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_BOARD_WORKFLOW, $configuration)) {
|
||||||
|
$targetProject->ticketStatuses()->delete();
|
||||||
|
$targetProject->boardColumns()->delete();
|
||||||
|
|
||||||
|
$boardColumnIds = [];
|
||||||
|
|
||||||
|
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['board_columns'] ?? []) as $boardColumn) {
|
||||||
|
$newBoardColumn = $targetProject->boardColumns()->create([
|
||||||
|
'name' => $boardColumn['name'],
|
||||||
|
'position' => $boardColumn['position'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$boardColumnIds[] = $newBoardColumn->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketStatusIds = [];
|
||||||
|
|
||||||
|
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $ticketStatus) {
|
||||||
|
$boardColumnIndex = $ticketStatus['board_column_index'] ?? null;
|
||||||
|
$newTicketStatus = $targetProject->ticketStatuses()->create([
|
||||||
|
'board_column_id' => $boardColumnIndex === null ? null : ($boardColumnIds[$boardColumnIndex] ?? null),
|
||||||
|
'name' => $ticketStatus['name'],
|
||||||
|
'position' => $ticketStatus['position'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ticketStatusIds[] = $newTicketStatus->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (($configuration[self::COPY_BOARD_WORKFLOW]['ticket_statuses'] ?? []) as $index => $ticketStatus) {
|
||||||
|
$newTicketStatusId = $ticketStatusIds[$index] ?? null;
|
||||||
|
|
||||||
|
if ($newTicketStatusId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetProject->ticketStatuses()->findOrFail($newTicketStatusId)
|
||||||
|
->allowedTargetStatuses()
|
||||||
|
->sync(
|
||||||
|
collect($ticketStatus['allowed_target_indexes'] ?? [])
|
||||||
|
->map(fn (int $statusIndex): ?int => $ticketStatusIds[$statusIndex] ?? null)
|
||||||
|
->filter(static fn (?int $statusId): bool => $statusId !== null)
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_TICKET_TYPES, $configuration)) {
|
||||||
|
$targetProject->ticketTypes()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_TICKET_TYPES] as $ticketType) {
|
||||||
|
$targetProject->ticketTypes()->create([
|
||||||
|
'name' => $ticketType['name'],
|
||||||
|
'position' => $ticketType['position'],
|
||||||
|
'is_parent' => $ticketType['is_parent'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_TICKET_PRIORITIES, $configuration)) {
|
||||||
|
$targetProject->ticketPriorities()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_TICKET_PRIORITIES] as $ticketPriority) {
|
||||||
|
$targetProject->ticketPriorities()->create([
|
||||||
|
'name' => $ticketPriority['name'],
|
||||||
|
'position' => $ticketPriority['position'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_TICKET_FIELDS, $configuration)) {
|
||||||
|
$targetProject->ticketFieldDefinitions()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_TICKET_FIELDS] as $ticketFieldDefinition) {
|
||||||
|
$targetProject->ticketFieldDefinitions()->create([
|
||||||
|
'field_type' => $ticketFieldDefinition['field_type'],
|
||||||
|
'label' => $ticketFieldDefinition['label'],
|
||||||
|
'position' => $ticketFieldDefinition['position'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_TICKET_LINK_TYPES, $configuration)) {
|
||||||
|
$targetProject->ticketLinkTypes()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_TICKET_LINK_TYPES] as $ticketLinkType) {
|
||||||
|
$targetProject->ticketLinkTypes()->create([
|
||||||
|
'label' => $ticketLinkType['label'],
|
||||||
|
'counterpart_label' => $ticketLinkType['counterpart_label'],
|
||||||
|
'position' => $ticketLinkType['position'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_REPOSITORY_CONNECTIONS, $configuration)) {
|
||||||
|
$targetProject->repositoryConnections()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_REPOSITORY_CONNECTIONS] as $repositoryConnection) {
|
||||||
|
$targetProject->repositoryConnections()->create([
|
||||||
|
'provider' => $repositoryConnection['provider'],
|
||||||
|
'base_url' => $repositoryConnection['base_url'],
|
||||||
|
'provider_repository_id' => $repositoryConnection['provider_repository_id'],
|
||||||
|
'owner_name' => $repositoryConnection['owner_name'],
|
||||||
|
'repository_name' => $repositoryConnection['repository_name'],
|
||||||
|
'repository_full_name' => $repositoryConnection['repository_full_name'],
|
||||||
|
'repository_url' => $repositoryConnection['repository_url'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists(self::COPY_NOTIFICATION_ACTIONS, $configuration)) {
|
||||||
|
$targetProject->notificationActions()->delete();
|
||||||
|
|
||||||
|
foreach ($configuration[self::COPY_NOTIFICATION_ACTIONS] as $notificationAction) {
|
||||||
|
$targetProject->notificationActions()->create([
|
||||||
|
'name' => $notificationAction['name'],
|
||||||
|
'enabled' => $notificationAction['enabled'],
|
||||||
|
'event_type' => $notificationAction['event_type'],
|
||||||
|
'channel' => $notificationAction['channel'],
|
||||||
|
'channel_configuration' => $notificationAction['channel_configuration'] ?? [],
|
||||||
|
'message_template' => $notificationAction['message_template'] ?? null,
|
||||||
|
'delivery_url' => $notificationAction['delivery_url'],
|
||||||
|
'filters' => $notificationAction['filters'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $copyAreas
|
||||||
|
*/
|
||||||
|
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||||
|
{
|
||||||
|
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?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);
|
||||||
|
|
||||||
|
$templateConfiguration = collect($projectTemplate->configuration_snapshot ?? [])
|
||||||
|
->only($selectedAreas)
|
||||||
|
->all();
|
||||||
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||||
|
|
||||||
|
$overriddenAreas = [];
|
||||||
|
$inheritedAreas = [];
|
||||||
|
|
||||||
|
foreach ($selectedAreas as $area) {
|
||||||
|
$label = $this->labelForArea($area);
|
||||||
|
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||||
|
$hasCurrentValue = array_key_exists($area, $currentConfiguration);
|
||||||
|
|
||||||
|
if (! $hasTemplateValue && ! $hasCurrentValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasTemplateValue && $hasCurrentValue && $currentConfiguration[$area] === $templateConfiguration[$area]) {
|
||||||
|
$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),
|
||||||
|
'templateConfiguration' => $templateConfiguration,
|
||||||
|
'templateId' => $projectTemplate->id,
|
||||||
|
'templateName' => $projectTemplate->name,
|
||||||
|
'templateSlug' => $projectTemplate->slug,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function labelForArea(string $area): string
|
||||||
|
{
|
||||||
|
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||||
|
if ($option['value'] === $area) {
|
||||||
|
return $option['label'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $area;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class UpdateProjectTemplate
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, copy_areas?: array<int, string>} $attributes
|
||||||
|
*/
|
||||||
|
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||||
|
{
|
||||||
|
$projectTemplate->loadMissing('sourceProject', 'linkedProjects.projectTemplate');
|
||||||
|
|
||||||
|
$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 = $projectTemplate->configuration_snapshot ?? [];
|
||||||
|
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||||
|
$affectedAreas = array_values(array_unique([
|
||||||
|
...array_keys($previousConfiguration),
|
||||||
|
...array_keys($nextConfiguration),
|
||||||
|
]));
|
||||||
|
|
||||||
|
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||||
|
$projectTemplate->forceFill([
|
||||||
|
'name' => $attributes['name'],
|
||||||
|
'copy_areas' => $normalizedCopyAreas,
|
||||||
|
'configuration_snapshot' => $nextConfiguration,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
foreach ($projectTemplate->linkedProjects as $linkedProject) {
|
||||||
|
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject->fresh(), $affectedAreas);
|
||||||
|
$replacementConfiguration = [];
|
||||||
|
|
||||||
|
foreach ($nextConfiguration as $area => $configuration) {
|
||||||
|
$isNewTemplateArea = ! array_key_exists($area, $previousConfiguration);
|
||||||
|
$matchesPreviousTemplate = array_key_exists($area, $previousConfiguration)
|
||||||
|
&& array_key_exists($area, $currentConfiguration)
|
||||||
|
&& $currentConfiguration[$area] === $previousConfiguration[$area];
|
||||||
|
|
||||||
|
if ($isNewTemplateArea || $matchesPreviousTemplate) {
|
||||||
|
$replacementConfiguration[$area] = $configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($replacementConfiguration === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->duplicateProjectConfiguration->apply($linkedProject->fresh(), $replacementConfiguration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function switch(Project $project, ProjectTemplate $template): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($project, $template): void {
|
||||||
|
$project->forceFill([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->duplicateProjectConfiguration->apply(
|
||||||
|
$project->fresh(),
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(Project $project): void
|
||||||
|
{
|
||||||
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
|
if ($template === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($project, $template): void {
|
||||||
|
$this->duplicateProjectConfiguration->apply(
|
||||||
|
$project->fresh(),
|
||||||
|
$template->configuration_snapshot ?? [],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
|
|||||||
|
|
||||||
use App\Actions\Git\Data\GitRepository;
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderManager;
|
use App\Actions\Git\GitProviderManager;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Http\Resources\RepositoryConnectionResource;
|
use App\Http\Resources\RepositoryConnectionResource;
|
||||||
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly GitProviderManager $gitProviderManager,
|
private readonly GitProviderManager $gitProviderManager,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
|
|||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ class CreateProjectTicket
|
|||||||
$nextSequenceNumber = (int) Ticket::query()
|
$nextSequenceNumber = (int) Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->max('sequence_number') + 1;
|
->max('sequence_number') + 1;
|
||||||
$ticketStatus = TicketStatus::query()
|
$ticketStatusQuery = TicketStatus::query()
|
||||||
->where('project_id', $project->id)
|
->where(
|
||||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
$project->project_template_id === null ? 'project_id' : 'project_template_id',
|
||||||
|
$project->project_template_id ?? $project->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||||
|
|
||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ class UpdateUserAvatar
|
|||||||
$color = $this->colorFor($user);
|
$color = $this->colorFor($user);
|
||||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||||
|
|
||||||
$stored = Storage::disk($this->avatarDisk())->put(
|
$stored = Storage::disk('public')->put(
|
||||||
$path,
|
$path,
|
||||||
$this->svg($this->initials($user->name), $color),
|
$this->svg($this->initials($user->name), $color),
|
||||||
['ContentType' => 'image/svg+xml'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $stored) {
|
if (! $stored) {
|
||||||
@@ -54,7 +53,7 @@ class UpdateUserAvatar
|
|||||||
public function upload(User $user, UploadedFile $avatar): User
|
public function upload(User $user, UploadedFile $avatar): User
|
||||||
{
|
{
|
||||||
$oldPath = $user->avatar_path;
|
$oldPath = $user->avatar_path;
|
||||||
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
$path = $avatar->store('avatars/uploads', 'public');
|
||||||
|
|
||||||
if (! is_string($path)) {
|
if (! is_string($path)) {
|
||||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||||
@@ -91,14 +90,7 @@ class UpdateUserAvatar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage::disk($this->avatarDisk())->delete($path);
|
Storage::disk('public')->delete($path);
|
||||||
}
|
|
||||||
|
|
||||||
private function avatarDisk(): string
|
|
||||||
{
|
|
||||||
$disk = config('filesystems.avatar_disk', 'public');
|
|
||||||
|
|
||||||
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function initials(string $name): string
|
private function initials(string $name): string
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Models\User;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||||
@@ -26,9 +25,6 @@ class CreateAdminUser extends Command
|
|||||||
*/
|
*/
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
DB::beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $this->argument('name'),
|
'name' => $this->argument('name'),
|
||||||
'email' => $this->argument('email'),
|
'email' => $this->argument('email'),
|
||||||
@@ -42,14 +38,7 @@ class CreateAdminUser extends Command
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
$this->info(sprintf('Admin User created with emil=\'%s\' and password=\'%s\'', $this->argument('email'), $this->argument('password')));
|
||||||
DB::commit();
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (\Exception $exception) {
|
|
||||||
$this->error('Admin User could not be created: ', $exception->getMessage());
|
|
||||||
DB::rollBack();
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService;
|
|||||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||||
use App\Actions\Projects\CreateProject;
|
use App\Actions\Projects\CreateProject;
|
||||||
use App\Actions\Projects\DeleteProject;
|
use App\Actions\Projects\DeleteProject;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\Projects\ListVisibleProjects;
|
use App\Actions\Projects\ListVisibleProjects;
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
|
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\DeleteProjectRequest;
|
use App\Http\Requests\DeleteProjectRequest;
|
||||||
|
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||||
use App\Http\Requests\StoreProjectRequest;
|
use App\Http\Requests\StoreProjectRequest;
|
||||||
|
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||||
use App\Http\Resources\PermissionOptionResource;
|
use App\Http\Resources\PermissionOptionResource;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\ProjectTemplateResource;
|
||||||
use App\Http\Resources\TeamResource;
|
use App\Http\Resources\TeamResource;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -32,6 +39,8 @@ class ProjectController extends Controller
|
|||||||
private readonly DeleteProject $deleteProject,
|
private readonly DeleteProject $deleteProject,
|
||||||
private readonly ListVisibleProjects $listVisibleProjects,
|
private readonly ListVisibleProjects $listVisibleProjects,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
|
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||||
|
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -51,7 +60,23 @@ class ProjectController extends Controller
|
|||||||
403,
|
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
|
public function store(StoreProjectRequest $request): RedirectResponse
|
||||||
@@ -73,6 +98,7 @@ class ProjectController extends Controller
|
|||||||
return Inertia::render('projects/Show', [
|
return Inertia::render('projects/Show', [
|
||||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +121,44 @@ class ProjectController extends Controller
|
|||||||
{
|
{
|
||||||
return Inertia::render('projects/settings/General', [
|
return Inertia::render('projects/settings/General', [
|
||||||
'project' => ProjectResource::make($project)->resolve(),
|
'project' => ProjectResource::make($project)->resolve(),
|
||||||
|
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||||
|
ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->with('sourceProject')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
)->resolve(),
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
|
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$template = ProjectTemplate::query()
|
||||||
|
->published()
|
||||||
|
->findOrFail($request->integer('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
|
||||||
|
{
|
||||||
|
$request->validated();
|
||||||
|
|
||||||
|
$template = $project->projectTemplate;
|
||||||
|
|
||||||
|
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||||
|
|
||||||
|
$this->updateProjectTemplateLink->reset($project->load('projectTemplate'));
|
||||||
|
|
||||||
|
return to_route('projects.settings.general', $project)
|
||||||
|
->with('status', "{$project->name} was reset to the {$template->name} template baseline.");
|
||||||
|
}
|
||||||
|
|
||||||
public function access(Request $request, Project $project): Response
|
public function access(Request $request, Project $project): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'replacement_ticket_type_id' => [
|
'replacement_ticket_type_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
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 [
|
||||||
|
'confirmation_key' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::in([$project->key]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
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\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
|
|||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$key = Arr::get($this->all(), 'key');
|
$key = Arr::get($this->all(), 'key');
|
||||||
|
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
|
||||||
|
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
|
||||||
|
|
||||||
if ($key !== null) {
|
if ($key !== null) {
|
||||||
$this->merge([
|
$this->merge([
|
||||||
'key' => strtoupper(trim((string) $key)),
|
'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
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
|
||||||
'key' => [
|
'key' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
|
|||||||
Rule::unique('projects', 'key'),
|
Rule::unique('projects', 'key'),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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::copyableAreas()),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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::copyableAreas())],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreProjectTicketBranchRequest extends FormRequest
|
class StoreProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'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($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'linked_ticket_id' => [
|
'linked_ticket_id' => [
|
||||||
@@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest
|
|||||||
'ticket_link_type_id' => [
|
'ticket_link_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_link_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'board_column_id' => [
|
'board_column_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
|
Rule::exists('board_columns', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'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::copyableAreas())],
|
||||||
|
'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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'acceptance_criteria' => ['nullable', 'array'],
|
'acceptance_criteria' => ['nullable', 'array'],
|
||||||
@@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'field_values.*.ticket_field_definition_id' => [
|
'field_values.*.ticket_field_definition_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_field_definitions', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||||
@@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
'ticket_priority_id' => [
|
'ticket_priority_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_priorities', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_types', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
'title' => ['required', 'string', 'max:255'],
|
'title' => ['required', 'string', 'max:255'],
|
||||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||||
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
|
|||||||
|
|
||||||
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
|
||||||
$requestedTicketType = TicketType::query()
|
$requestedTicketType = TicketType::query()
|
||||||
->where('project_id', $project->id)
|
->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
|
||||||
->find($requestedTicketTypeId);
|
->find($requestedTicketTypeId);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
/** @var Project $project */
|
/** @var Project $project */
|
||||||
$project = $this->route('project');
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||||
'allowed_ticket_status_ids.*' => [
|
'allowed_ticket_status_ids.*' => [
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
Rule::exists('ticket_statuses', 'id')->where(
|
||||||
|
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class ValidateProjectTicketBranchRequest extends FormRequest
|
class ValidateProjectTicketBranchRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
/** @var Project $project */
|
||||||
|
$project = $this->route('project');
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
|
||||||
|
$ownershipValue = $projectTemplateId ?? $project->id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'branch_name' => ['required', 'string', 'max:255'],
|
'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($ownershipColumn, $ownershipValue),
|
||||||
|
),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class ProjectResource extends JsonResource
|
class ProjectResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -14,10 +16,32 @@ class ProjectResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$this->resource->loadMissing('projectTemplate');
|
||||||
|
|
||||||
|
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'key' => $this->key,
|
'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', []),
|
||||||
|
'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_author' => $this->show_author,
|
||||||
'show_story_points' => $this->show_story_points,
|
'show_story_points' => $this->show_story_points,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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' => $this->copy_areas ?? [],
|
||||||
|
'configuration_snapshot' => $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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\BoardColumnFactory;
|
use Database\Factories\BoardColumnFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class BoardColumn extends Model
|
class BoardColumn extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BoardColumnFactory> */
|
/** @use HasFactory<BoardColumnFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $boardColumn): void {
|
||||||
|
$boardColumn->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
trait SyncsProjectTemplateOwnership
|
||||||
|
{
|
||||||
|
protected function syncProjectTemplateOwnership(): void
|
||||||
|
{
|
||||||
|
$projectTemplateId = $this->getAttribute('project_template_id');
|
||||||
|
$projectId = $this->getAttribute('project_id');
|
||||||
|
|
||||||
|
if ($projectTemplateId === null && $projectId !== null) {
|
||||||
|
$projectTemplateId = Project::query()
|
||||||
|
->whereKey($projectId)
|
||||||
|
->value('project_template_id');
|
||||||
|
|
||||||
|
if ($projectTemplateId !== null) {
|
||||||
|
$this->setAttribute('project_template_id', $projectTemplateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($projectId === null && $projectTemplateId !== null) {
|
||||||
|
$projectId = ProjectTemplate::query()
|
||||||
|
->whereKey($projectTemplateId)
|
||||||
|
->value('project_id');
|
||||||
|
|
||||||
|
if ($projectId !== null) {
|
||||||
|
$this->setAttribute('project_id', $projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function projectTemplate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ProjectTemplate::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\NotificationActionFactory;
|
use Database\Factories\NotificationActionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'message_template',
|
'message_template',
|
||||||
'name',
|
'name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
])]
|
])]
|
||||||
#[Hidden(['delivery_url'])]
|
#[Hidden(['delivery_url'])]
|
||||||
class NotificationAction extends Model
|
class NotificationAction extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<NotificationActionFactory> */
|
/** @use HasFactory<NotificationActionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
@@ -45,6 +48,13 @@ class NotificationAction extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $notificationAction): void {
|
||||||
|
$notificationAction->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeEnabled(Builder $query): Builder
|
public function scopeEnabled(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('enabled', true);
|
return $query->where('enabled', true);
|
||||||
|
|||||||
+14
-1
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
|
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points'])]
|
||||||
class Project extends Model
|
class Project extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ProjectFactory> */
|
/** @use HasFactory<ProjectFactory> */
|
||||||
@@ -44,6 +46,17 @@ class Project extends Model
|
|||||||
return $this->morphMany(PermissionAssignment::class, 'scope');
|
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
|
public function boardColumns(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(BoardColumn::class)
|
return $this->hasMany(BoardColumn::class)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\ProjectTemplateFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable(['project_id', 'source_project_id', 'name', 'slug', 'is_published', 'copy_areas', 'configuration_snapshot'])]
|
||||||
|
class ProjectTemplate extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<ProjectTemplateFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'copy_areas' => 'array',
|
||||||
|
'configuration_snapshot' => 'array',
|
||||||
|
'is_published' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_published', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function project(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceProject(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Project::class, 'source_project_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function linkedProjects(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Project::class, 'project_template_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
|||||||
|
|
||||||
use App\Actions\Git\Data\GitRepository;
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderKey;
|
use App\Actions\Git\GitProviderKey;
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\RepositoryConnectionFactory;
|
use Database\Factories\RepositoryConnectionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'base_url',
|
'base_url',
|
||||||
'owner_name',
|
'owner_name',
|
||||||
'project_id',
|
'project_id',
|
||||||
|
'project_template_id',
|
||||||
'provider',
|
'provider',
|
||||||
'provider_repository_id',
|
'provider_repository_id',
|
||||||
'repository_full_name',
|
'repository_full_name',
|
||||||
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
@@ -35,6 +38,13 @@ class RepositoryConnection extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $repositoryConnection): void {
|
||||||
|
$repositoryConnection->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketFieldDefinitionFactory;
|
use Database\Factories\TicketFieldDefinitionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketFieldDefinition extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
|
|||||||
|
|
||||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketFieldDefinition): void {
|
||||||
|
$ticketFieldDefinition->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketLinkTypeFactory;
|
use Database\Factories\TicketLinkTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketLinkType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketLinkType): void {
|
||||||
|
$ticketLinkType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function counterpartLabel(): string
|
public function counterpartLabel(): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketPriorityFactory;
|
use Database\Factories\TicketPriorityFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['name', 'position', 'project_id'])]
|
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||||
class TicketPriority extends Model
|
class TicketPriority extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketPriorityFactory> */
|
/** @use HasFactory<TicketPriorityFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketPriority): void {
|
||||||
|
$ticketPriority->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function project(): BelongsTo
|
public function project(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketStatusFactory;
|
use Database\Factories\TicketStatusFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Str;
|
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
|
class TicketStatus extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketStatusFactory> */
|
/** @use HasFactory<TicketStatusFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::saving(function (self $ticketStatus): 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);
|
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||||
use Database\Factories\TicketTypeFactory;
|
use Database\Factories\TicketTypeFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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
|
class TicketType extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<TicketTypeFactory> */
|
/** @use HasFactory<TicketTypeFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
use SyncsProjectTemplateOwnership;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $ticketType): void {
|
||||||
|
$ticketType->syncProjectTemplateOwnership();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-19
@@ -77,28 +77,12 @@ class User extends Authenticatable
|
|||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(
|
||||||
fn (): ?string => $this->avatarUrl(),
|
fn (): ?string => filled($this->avatar_path)
|
||||||
|
? Storage::disk('public')->url($this->avatar_path)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function avatarUrl(): ?string
|
|
||||||
{
|
|
||||||
if (blank($this->avatar_path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$diskName = config('filesystems.avatar_disk', 'public');
|
|
||||||
$disk = Storage::disk(is_string($diskName) && $diskName !== '' ? $diskName : 'public');
|
|
||||||
|
|
||||||
if ((bool) config('filesystems.avatar_urls_temporary', false)) {
|
|
||||||
$expirationMinutes = max(1, (int) config('filesystems.avatar_url_expiration_minutes', 15));
|
|
||||||
|
|
||||||
return $disk->temporaryUrl($this->avatar_path, now()->addMinutes($expirationMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $disk->url($this->avatar_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function permissionAssignments(): MorphMany
|
public function permissionAssignments(): MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
"mcp-development",
|
|
||||||
"wayfinder-development",
|
"wayfinder-development",
|
||||||
"inertia-vue-development",
|
"inertia-vue-development",
|
||||||
"tailwindcss-development"
|
"tailwindcss-development"
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: '*');
|
|
||||||
|
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
|
|||||||
+3
-4
@@ -12,11 +12,10 @@
|
|||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^v13.26.1",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/mcp": "^0.6",
|
"laravel/mcp": "^0.6",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"laravel/wayfinder": "^0.1.14",
|
"laravel/wayfinder": "^0.1.14",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
|
||||||
"scalar/laravel": "^0.2.1"
|
"scalar/laravel": "^0.2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"@php artisan dev"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
"dev:ssr": [
|
||||||
"npm run build:ssr",
|
"npm run build:ssr",
|
||||||
@@ -67,8 +66,8 @@
|
|||||||
],
|
],
|
||||||
"ci:check": [
|
"ci:check": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npm audit --audit-level=high",
|
|
||||||
"npm run lint:check",
|
"npm run lint:check",
|
||||||
|
"npm run format:check",
|
||||||
"npm run types:check",
|
"npm run types:check",
|
||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
|
|||||||
Generated
+332
-791
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,6 @@ return [
|
|||||||
|
|
||||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
'avatar_disk' => env('AVATAR_FILESYSTEM_DISK', env('FILESYSTEM_DISK', 'public')),
|
|
||||||
|
|
||||||
'avatar_urls_temporary' => env('AVATAR_URLS_TEMPORARY', false),
|
|
||||||
|
|
||||||
'avatar_url_expiration_minutes' => env('AVATAR_URL_EXPIRATION_MINUTES', 15),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Filesystem Disks
|
| Filesystem Disks
|
||||||
|
|||||||
@@ -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,116 @@
|
|||||||
|
<?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',
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
$projectTemplateId = $project->project_template_id;
|
||||||
|
|
||||||
|
if ($projectTemplateId === null || (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) {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
+12
-32
@@ -1,38 +1,33 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
container_name: kanbangit-web
|
container_name: kanbangit-web
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: ./docker/nginx/Dockerfile
|
||||||
target: runtime
|
|
||||||
command: nginx
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- kanbangit-storage:/var/www/storage:ro
|
||||||
|
- kanbangit-public-assets:/var/www/public/build:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
environment:
|
|
||||||
PHP_FPM_UPSTREAM: php-fpm:9000
|
|
||||||
networks:
|
networks:
|
||||||
- kanbangit-web
|
- kanbangit-web
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
depends_on:
|
depends_on:
|
||||||
php-fpm:
|
php-fpm:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
php-fpm:
|
php-fpm:
|
||||||
container_name: kanbangit-php-fpm
|
container_name: kanbangit-php-fpm
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: ./docker/php-fpm/Dockerfile
|
||||||
target: runtime
|
target: production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- kanbangit-storage:/var/www/storage
|
||||||
|
- kanbangit-public-assets:/var/www/public/build
|
||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
networks:
|
networks:
|
||||||
@@ -47,23 +42,6 @@ services:
|
|||||||
mariadb:
|
mariadb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
worker:
|
|
||||||
container_name: kanbangit-worker
|
|
||||||
image: kanbangit-app:local
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: runtime
|
|
||||||
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env.production
|
|
||||||
networks:
|
|
||||||
- kanbangit-db
|
|
||||||
depends_on:
|
|
||||||
mariadb:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
mariadb:
|
mariadb:
|
||||||
container_name: kanbangit-db
|
container_name: kanbangit-db
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
@@ -87,3 +65,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb-data-production:
|
mariadb-data-production:
|
||||||
|
kanbangit-storage:
|
||||||
|
kanbangit-public-assets:
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "${1:-}" = "nginx" ]; then
|
|
||||||
: "${PHP_FPM_UPSTREAM:=127.0.0.1:9000}"
|
|
||||||
export PHP_FPM_UPSTREAM
|
|
||||||
|
|
||||||
envsubst '${PHP_FPM_UPSTREAM}' \
|
|
||||||
< /etc/nginx/nginx.conf.template \
|
|
||||||
> /tmp/nginx.conf
|
|
||||||
|
|
||||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p \
|
|
||||||
bootstrap/cache \
|
|
||||||
storage/framework/cache \
|
|
||||||
storage/framework/sessions \
|
|
||||||
storage/framework/testing \
|
|
||||||
storage/framework/views \
|
|
||||||
storage/logs
|
|
||||||
|
|
||||||
php artisan config:cache --no-ansi
|
|
||||||
php artisan route:cache --no-ansi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM php:8.4-cli-bookworm AS builder
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl ca-certificates git unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- \
|
||||||
|
--install-dir=/usr/local/bin --filename=composer
|
||||||
|
WORKDIR /var/www
|
||||||
|
COPY . /var/www
|
||||||
|
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --from=builder /var/www/public /var/www/public
|
||||||
|
WORKDIR /var/www/public
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /var/www/public;
|
||||||
|
index index.php index.html;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
location ^~ /storage/ {
|
||||||
|
alias /var/www/storage/app/public/;
|
||||||
|
try_files $uri =404;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass php-fpm:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
pid /tmp/nginx.pid;
|
|
||||||
error_log /dev/stderr warn;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
access_log /dev/stdout;
|
|
||||||
client_body_temp_path /tmp/client_body;
|
|
||||||
proxy_temp_path /tmp/proxy;
|
|
||||||
fastcgi_temp_path /tmp/fastcgi;
|
|
||||||
uwsgi_temp_path /tmp/uwsgi;
|
|
||||||
scgi_temp_path /tmp/scgi;
|
|
||||||
|
|
||||||
sendfile on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
client_max_body_size 3m;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name _;
|
|
||||||
root /var/www/public;
|
|
||||||
index index.php;
|
|
||||||
|
|
||||||
location = /nginx-health {
|
|
||||||
access_log off;
|
|
||||||
default_type text/plain;
|
|
||||||
return 200 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /index.php {
|
|
||||||
include /etc/nginx/fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
|
||||||
fastcgi_param HTTP_PROXY '';
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_PORT $http_x_forwarded_port;
|
|
||||||
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
|
||||||
fastcgi_pass ${PHP_FPM_UPSTREAM};
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\. {
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_NAME=/status \
|
|
||||||
SCRIPT_FILENAME=/status \
|
|
||||||
REQUEST_METHOD=GET \
|
|
||||||
cgi-fcgi -bind -connect 127.0.0.1:9000 2>/dev/null | grep -q '^pool:'
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
FROM php:8.4-fpm AS builder
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
unzip \
|
||||||
|
libpq-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
libmagickwand-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
&& docker-php-ext-install -j$(nproc) \
|
||||||
|
pdo_mysql \
|
||||||
|
pdo_pgsql \
|
||||||
|
pgsql \
|
||||||
|
opcache \
|
||||||
|
intl \
|
||||||
|
zip \
|
||||||
|
bcmath \
|
||||||
|
soap \
|
||||||
|
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||||
|
&& docker-php-ext-install -j$(nproc) gd \
|
||||||
|
&& pecl install imagick \
|
||||||
|
&& docker-php-ext-enable imagick \
|
||||||
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
WORKDIR /var/www
|
||||||
|
COPY . /var/www
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
|
||||||
|
&& composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist
|
||||||
|
FROM php:8.4-fpm AS production
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev \
|
||||||
|
libicu-dev \
|
||||||
|
libzip-dev \
|
||||||
|
libfcgi-bin \
|
||||||
|
procps \
|
||||||
|
libmagickwand-dev \
|
||||||
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
||||||
|
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
||||||
|
&& chmod +x /usr/local/bin/php-fpm-healthcheck
|
||||||
|
COPY ./docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
COPY ./storage /var/www/storage-init
|
||||||
|
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
|
||||||
|
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
|
||||||
|
COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[global]' \
|
||||||
|
'daemonize = no' \
|
||||||
|
'' \
|
||||||
|
'[www]' \
|
||||||
|
'pm.status_path = /status' \
|
||||||
|
> /usr/local/etc/php-fpm.d/zz-docker.conf
|
||||||
|
COPY --from=builder /var/www /var/www
|
||||||
|
WORKDIR /var/www
|
||||||
|
RUN chown -R www-data:www-data /var/www
|
||||||
|
USER www-data
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["php-fpm"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ ! "$(ls -A /var/www/storage)" ]; then
|
||||||
|
echo "Initializing storage directory..."
|
||||||
|
cp -R /var/www/storage-init/. /var/www/storage
|
||||||
|
chown -R www-data:www-data /var/www/storage
|
||||||
|
fi
|
||||||
|
rm -rf /var/www/storage-init
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
exec "$@"
|
||||||
@@ -37,10 +37,7 @@ const eslintConfig = [
|
|||||||
'bootstrap/cache/**',
|
'bootstrap/cache/**',
|
||||||
'public/build/**',
|
'public/build/**',
|
||||||
'public/hot',
|
'public/hot',
|
||||||
'resources/js/actions/**',
|
|
||||||
'resources/js/components/ui/**',
|
'resources/js/components/ui/**',
|
||||||
'resources/js/routes/**',
|
|
||||||
'resources/js/wayfinder/**',
|
|
||||||
'resources/views/mail/**',
|
'resources/views/mail/**',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+198
-497
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,7 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
|
||||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
FolderGit2,
|
FolderGit2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
Layers3,
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { index as adminUsers } from '@/routes/admin/users';
|
import { index as adminUsers } from '@/routes/admin/users';
|
||||||
|
import { index as projectTemplatesIndex } from '@/routes/project-templates';
|
||||||
import { index as projectsIndex } from '@/routes/projects';
|
import { index as projectsIndex } from '@/routes/projects';
|
||||||
import { index as teamsIndex } from '@/routes/teams';
|
import { index as teamsIndex } from '@/routes/teams';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
@@ -44,6 +46,14 @@ const mainNavItems = computed<NavItem[]>(() => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (page.props.auth.permissions.projectCreate) {
|
||||||
|
items.push({
|
||||||
|
title: 'Project templates',
|
||||||
|
href: projectTemplatesIndex(),
|
||||||
|
icon: Layers3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (page.props.auth.permissions.globalAdmin) {
|
if (page.props.auth.permissions.globalAdmin) {
|
||||||
items.push({
|
items.push({
|
||||||
title: 'Users',
|
title: 'Users',
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
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 { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { index as projectsIndex, show as projectShow } from '@/routes/projects/index';
|
||||||
|
import {
|
||||||
|
create as projectTemplateCreate,
|
||||||
|
store as projectTemplateStore,
|
||||||
|
} from '@/routes/projects/templates';
|
||||||
|
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Projects',
|
||||||
|
href: projectsIndex(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.project.name,
|
||||||
|
href: projectShow(props.project.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Save as new template',
|
||||||
|
href: projectTemplateCreate(props.project.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
copy_areas: props.copyAreaOptions.map((copyAreaOption) => copyAreaOption.value),
|
||||||
|
name: `${props.project.name} template`,
|
||||||
|
slug: `${props.project.key.toLowerCase()}-template`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyAreasError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.copy_areas ??
|
||||||
|
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.post(projectTemplateStore.url(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`Save ${project.name} as new template`" />
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Badge variant="outline" class="px-3 py-1">{{ project.key }}</Badge>
|
||||||
|
<Heading
|
||||||
|
:title="`Save ${project.name} as new template`"
|
||||||
|
description="Capture reusable project configuration without copying operational ticket data."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pick the configuration groups that should be reusable in future projects.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Template name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="slug">Template slug</Label>
|
||||||
|
<Input id="slug" v-model="form.slug" required placeholder="foundation-template" />
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Slugs are normalized automatically and used to identify reusable templates.
|
||||||
|
</p>
|
||||||
|
<InputError :message="form.errors.slug" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Configuration groups</Label>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Only the selected groups will be stored in the template.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<Label
|
||||||
|
v-for="copyAreaOption in copyAreaOptions"
|
||||||
|
:key="copyAreaOption.value"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.copy_areas"
|
||||||
|
type="checkbox"
|
||||||
|
:value="copyAreaOption.value"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ copyAreaOption.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<InputError :message="copyAreasError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button type="submit" :disabled="form.processing">
|
||||||
|
Save new template
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectShow(project.id)">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
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 { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import {
|
||||||
|
edit as projectTemplatesEdit,
|
||||||
|
index as projectTemplatesIndex,
|
||||||
|
update as projectTemplatesUpdate,
|
||||||
|
} from '@/routes/project-templates';
|
||||||
|
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectTemplatesEditPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Project templates',
|
||||||
|
href: projectTemplatesIndex(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.template.name,
|
||||||
|
href: projectTemplatesEdit(props.template.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
confirm_linked_projects_update: false,
|
||||||
|
copy_areas: [...props.template.copy_areas],
|
||||||
|
name: props.template.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyAreasError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.copy_areas ??
|
||||||
|
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkedProjectCount = computed(() => props.template.linked_project_count ?? 0);
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
form.patch(projectTemplatesUpdate.url(props.template.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`Edit ${template.name}`" />
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge variant="outline" class="px-3 py-1">{{ template.slug }}</Badge>
|
||||||
|
<Badge v-if="linkedProjectCount > 0" variant="secondary">
|
||||||
|
{{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Heading
|
||||||
|
:title="`Edit ${template.name}`"
|
||||||
|
description="Refresh the template baseline from its source project while keeping immutable identifiers protected."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<template v-if="template.source_project">
|
||||||
|
This template baseline is rebuilt from {{ template.source_project.name }} ({{ template.source_project.key }}) when you save.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This template baseline is rebuilt from its source project when you save.
|
||||||
|
</template>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">Template name</Label>
|
||||||
|
<Input id="name" v-model="form.name" required placeholder="Foundation template" />
|
||||||
|
<InputError :message="form.errors.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="slug">Template slug</Label>
|
||||||
|
<Input id="slug" :model-value="template.slug" disabled />
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
The slug stays locked so existing template references remain stable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Configuration groups</Label>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Saving rebuilds the selected groups from the current source-project configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<Label
|
||||||
|
v-for="copyAreaOption in copyAreaOptions"
|
||||||
|
:key="copyAreaOption.value"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.copy_areas"
|
||||||
|
type="checkbox"
|
||||||
|
:value="copyAreaOption.value"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ copyAreaOption.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<InputError :message="copyAreasError" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="linkedProjectCount > 0"
|
||||||
|
class="space-y-3 rounded-xl border border-amber-300 bg-amber-50 p-4 text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Warning</div>
|
||||||
|
<p class="text-sm">
|
||||||
|
Saving this template updates the baseline for {{ linkedProjectCount }} linked {{ linkedProjectCount === 1 ? 'project' : 'projects' }}.
|
||||||
|
Inherited areas will follow the new template version, while existing overrides stay untouched.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label class="flex cursor-pointer items-start gap-3">
|
||||||
|
<input v-model="form.confirm_linked_projects_update" type="checkbox" class="mt-1" />
|
||||||
|
<span class="text-sm">
|
||||||
|
I understand that linked projects inheriting from this template will be affected.
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<InputError :message="form.errors.confirm_linked_projects_update" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button type="submit" :disabled="form.processing">
|
||||||
|
Save template changes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectTemplatesIndex()">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, Link, router } from '@inertiajs/vue3';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import {
|
||||||
|
destroy as projectTemplatesDestroy,
|
||||||
|
edit as projectTemplatesEdit,
|
||||||
|
index as projectTemplatesIndex,
|
||||||
|
} from '@/routes/project-templates';
|
||||||
|
import { create as projectsCreate, index as projectsIndex } from '@/routes/projects/index';
|
||||||
|
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Project templates',
|
||||||
|
href: projectTemplatesIndex(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const removeTemplate = (templateId: number): void => {
|
||||||
|
router.delete(projectTemplatesDestroy.url(templateId), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head title="Project templates" />
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<Heading
|
||||||
|
title="Project templates"
|
||||||
|
description="Reusable published project templates. Internal project base templates stay hidden here by default."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectsIndex()">Back to projects</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child>
|
||||||
|
<Link :href="projectsCreate()">Create project</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="templates.length === 0" class="text-muted-foreground text-sm">
|
||||||
|
No published project templates have been saved yet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Card v-for="template in templates" :key="template.id" class="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitle>{{ template.name }}</CardTitle>
|
||||||
|
<Badge variant="secondary">{{ template.slug }}</Badge>
|
||||||
|
<Badge v-if="template.linked_project_count" variant="outline">
|
||||||
|
{{ template.linked_project_count }} linked {{ template.linked_project_count === 1 ? 'project' : 'projects' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
<template v-if="template.source_project">
|
||||||
|
Saved from {{ template.source_project.name }} ({{ template.source_project.key }}).
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Saved template configuration.
|
||||||
|
</template>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="copyArea in template.copy_areas"
|
||||||
|
:key="copyArea"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ copyArea }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button as-child variant="secondary">
|
||||||
|
<Link :href="projectTemplatesEdit(template.id)">
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="projectsCreate({ query: { template: template.id } })">
|
||||||
|
Use template
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
@click="removeTemplate(template.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
@@ -15,7 +16,9 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index } from '@/routes/projects/index';
|
import { create, index } from '@/routes/projects/index';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem, ProjectsCreatePageProps } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectsCreatePageProps>();
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
@@ -27,6 +30,43 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
href: create(),
|
href: create(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const defaultTemplateId = props.selectedSourceTemplateId ?? props.sourceTemplates[0]?.id ?? null;
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
copy_areas: [] as string[],
|
||||||
|
creation_mode: defaultTemplateId !== null ? 'template' : 'scratch',
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
source_project_id: props.sourceProjects[0]?.id ? String(props.sourceProjects[0].id) : '',
|
||||||
|
source_template_id: defaultTemplateId !== null ? String(defaultTemplateId) : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSourceProjects = computed(() => props.sourceProjects.length > 0);
|
||||||
|
const hasSourceTemplates = computed(() => props.sourceTemplates.length > 0);
|
||||||
|
|
||||||
|
const copyAreasError = computed(() => {
|
||||||
|
return (
|
||||||
|
form.errors.copy_areas ??
|
||||||
|
Object.entries(form.errors).find(([key]) => key.startsWith('copy_areas.'))?.[1] ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (form.creation_mode !== 'duplicate') {
|
||||||
|
form.source_project_id = '';
|
||||||
|
form.copy_areas = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.creation_mode !== 'template') {
|
||||||
|
form.source_template_id = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
form.post(ProjectController.store.url(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,7 +76,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
||||||
<Heading
|
<Heading
|
||||||
title="Create project"
|
title="Create project"
|
||||||
description="Create a project with a stable uppercase key that will prefix all future ticket keys."
|
description="Create a fresh project, duplicate selected settings from an existing project, or start from a saved template."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -49,28 +89,82 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form
|
<form class="space-y-6" @submit.prevent="submit">
|
||||||
v-bind="ProjectController.store.form()"
|
<div class="grid gap-3">
|
||||||
class="space-y-6"
|
<Label>How do you want to start?</Label>
|
||||||
v-slot="{ errors, processing }"
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Label class="flex cursor-pointer items-start gap-3 rounded-xl border p-4">
|
||||||
|
<input
|
||||||
|
v-model="form.creation_mode"
|
||||||
|
type="radio"
|
||||||
|
value="scratch"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Create from scratch</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Start with the default project setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
class="flex items-start gap-3 rounded-xl border p-4"
|
||||||
|
:class="hasSourceProjects ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
>
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.creation_mode"
|
||||||
|
type="radio"
|
||||||
|
value="duplicate"
|
||||||
|
class="mt-1"
|
||||||
|
:disabled="!hasSourceProjects"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Create from existing project</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Copy only the settings you explicitly select. Tickets are never duplicated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
class="flex items-start gap-3 rounded-xl border p-4"
|
||||||
|
:class="hasSourceTemplates ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.creation_mode"
|
||||||
|
type="radio"
|
||||||
|
value="template"
|
||||||
|
class="mt-1"
|
||||||
|
:disabled="!hasSourceTemplates"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">Create from template</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Reuse a saved configuration template.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="name">Project name</Label>
|
<Label for="name">Project name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
v-model="form.name"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
placeholder="Kanban foundation"
|
placeholder="Kanban foundation"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.name" />
|
<InputError :message="form.errors.name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="key">Project key</Label>
|
<Label for="key">Project key</Label>
|
||||||
<Input
|
<Input
|
||||||
id="key"
|
id="key"
|
||||||
name="key"
|
v-model="form.key"
|
||||||
required
|
required
|
||||||
maxlength="5"
|
maxlength="5"
|
||||||
placeholder="CORE"
|
placeholder="CORE"
|
||||||
@@ -78,15 +172,111 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
/>
|
/>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Example ticket keys will look like
|
Example ticket keys will look like
|
||||||
<span class="text-foreground font-medium"
|
<span class="text-foreground font-medium">CORE-1</span>.
|
||||||
>CORE-1</span
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
<InputError :message="errors.key" />
|
<InputError :message="form.errors.key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="form.creation_mode === 'duplicate'"
|
||||||
|
class="space-y-6 rounded-xl border border-dashed p-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_project_id">Source project</Label>
|
||||||
|
<select
|
||||||
|
id="source_project_id"
|
||||||
|
v-model="form.source_project_id"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!hasSourceProjects"
|
||||||
|
>
|
||||||
|
<option value="">Select a source project</option>
|
||||||
|
<option
|
||||||
|
v-for="sourceProject in sourceProjects"
|
||||||
|
:key="sourceProject.id"
|
||||||
|
:value="String(sourceProject.id)"
|
||||||
|
>
|
||||||
|
{{ sourceProject.name }} ({{ sourceProject.key }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
The new project keeps its own identity, key, and future ticket numbering.
|
||||||
|
</p>
|
||||||
|
<InputError :message="form.errors.source_project_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Copy options</Label>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Choose which settings should be copied into the new project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<Label
|
||||||
|
v-for="copyAreaOption in copyAreaOptions"
|
||||||
|
:key="copyAreaOption.value"
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.copy_areas"
|
||||||
|
type="checkbox"
|
||||||
|
:value="copyAreaOption.value"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">{{ copyAreaOption.label }}</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ copyAreaOption.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Operational ticket data is always left behind so the new project starts clean.
|
||||||
|
</p>
|
||||||
|
<InputError :message="copyAreasError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="form.creation_mode === 'template'"
|
||||||
|
class="space-y-4 rounded-xl border border-dashed p-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_template_id">Template</Label>
|
||||||
|
<select
|
||||||
|
id="source_template_id"
|
||||||
|
v-model="form.source_template_id"
|
||||||
|
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!hasSourceTemplates"
|
||||||
|
>
|
||||||
|
<option value="">Select a template</option>
|
||||||
|
<option
|
||||||
|
v-for="sourceTemplate in sourceTemplates"
|
||||||
|
:key="sourceTemplate.id"
|
||||||
|
:value="String(sourceTemplate.id)"
|
||||||
|
>
|
||||||
|
{{ sourceTemplate.name }} ({{ sourceTemplate.slug }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Templates apply their saved configuration groups to the new project.
|
||||||
|
</p>
|
||||||
|
<InputError :message="form.errors.source_template_id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!hasSourceProjects && !hasSourceTemplates"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
No accessible source projects or saved templates are available yet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Button type="submit" :disabled="processing">
|
<Button type="submit" :disabled="form.processing">
|
||||||
Create project
|
Create project
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -94,7 +284,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
<Link :href="index()">Cancel</Link>
|
<Link :href="index()">Cancel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
notifications as notificationSettings,
|
notifications as notificationSettings,
|
||||||
repositories as repositorySettings,
|
repositories as repositorySettings,
|
||||||
} from '@/routes/projects/settings';
|
} from '@/routes/projects/settings';
|
||||||
|
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectShowPageProps>();
|
const props = defineProps<ProjectShowPageProps>();
|
||||||
@@ -94,12 +95,48 @@ const projectAreas = [
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="$page.props.auth.permissions.projectCreate"
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link :href="projectTemplateCreate(project.id)">
|
||||||
|
Save as new template
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="index()">Back to projects</Link>
|
<Link :href="index()">Back to projects</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Built from template {{ templateState.templateName }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This project keeps its template link. Inherited areas follow the template baseline; overridden areas were customized locally.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.inheritedAreas"
|
||||||
|
:key="`inherited-${area.value}`"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ area.label }} · inherited
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.overriddenAreas"
|
||||||
|
:key="`overridden-${area.value}`"
|
||||||
|
>
|
||||||
|
{{ area.label }} · overridden
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
|
||||||
<Button as-child>
|
<Button as-child>
|
||||||
<Link :href="boardSettings(project.id)">
|
<Link :href="boardSettings(project.id)">
|
||||||
|
|||||||
@@ -138,6 +138,32 @@ const statusAllowsTarget = (
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Template inheritance: {{ templateState.templateName }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This project stays linked to its template. Sections below show whether the current configuration is still inherited or has local overrides.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.inheritedAreas"
|
||||||
|
:key="`inherited-${area.value}`"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ area.label }} · inherited
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.overriddenAreas"
|
||||||
|
:key="`overridden-${area.value}`"
|
||||||
|
>
|
||||||
|
{{ area.label }} · overridden
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Workflow model</CardTitle>
|
<CardTitle>Workflow model</CardTitle>
|
||||||
|
|||||||
@@ -43,20 +43,73 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const switchTemplateForm = useForm({
|
||||||
|
confirmation_key: '',
|
||||||
|
source_template_id: props.sourceTemplates.find(
|
||||||
|
(template) => template.id !== props.project.project_template_id,
|
||||||
|
)?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetTemplateForm = useForm({
|
||||||
|
confirmation_key: '',
|
||||||
|
});
|
||||||
|
|
||||||
const deleteForm = useForm({
|
const deleteForm = useForm({
|
||||||
confirmation_key: '',
|
confirmation_key: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const availableSwitchTemplates = computed(() =>
|
||||||
|
props.sourceTemplates.filter(
|
||||||
|
(template) => template.id !== props.project.project_template_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentTemplate = computed(() => props.project.project_template ?? null);
|
||||||
|
|
||||||
|
const isSwitchConfirmationValid = computed(
|
||||||
|
() => switchTemplateForm.confirmation_key === props.project.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSwitchTemplate = computed(
|
||||||
|
() =>
|
||||||
|
isSwitchConfirmationValid.value &&
|
||||||
|
switchTemplateForm.source_template_id !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isResetConfirmationValid = computed(
|
||||||
|
() => resetTemplateForm.confirmation_key === props.project.key,
|
||||||
|
);
|
||||||
|
|
||||||
const isDeleteConfirmationValid = computed(
|
const isDeleteConfirmationValid = computed(
|
||||||
() => deleteForm.confirmation_key === props.project.key,
|
() => deleteForm.confirmation_key === props.project.key,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const submitSwitchTemplate = (): void => {
|
||||||
|
if (!canSwitchTemplate.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTemplateForm.post(ProjectController.switchTemplate.url(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitResetTemplate = (): void => {
|
||||||
|
if (!isResetConfirmationValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTemplateForm.post(ProjectController.resetTemplate.url(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const submitDelete = (): void => {
|
const submitDelete = (): void => {
|
||||||
if (!isDeleteConfirmationValid.value) {
|
if (!isDeleteConfirmationValid.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteForm.submit(ProjectController.destroy(props.project.id), {
|
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -152,6 +205,139 @@ const submitDelete = (): void => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template baseline</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Switch this project to another published template or reset it back to the currently linked template baseline. Both actions can overwrite local configuration.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="space-y-6">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="border-border/70 rounded-xl border p-4">
|
||||||
|
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
|
||||||
|
Current linked template
|
||||||
|
</p>
|
||||||
|
<p class="text-foreground mt-2 text-sm font-semibold">
|
||||||
|
{{ currentTemplate?.name ?? 'No linked template' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
{{
|
||||||
|
templateState?.inheritsFromTemplate
|
||||||
|
? 'Inherited and overridden areas stay visible after switching or resetting.'
|
||||||
|
: 'This project currently behaves like a standalone baseline.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-border/70 rounded-xl border p-4">
|
||||||
|
<p class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]">
|
||||||
|
Warning
|
||||||
|
</p>
|
||||||
|
<p class="text-foreground mt-2 text-sm font-medium">
|
||||||
|
Template changes can replace board workflow, ticket metadata, repositories, and notifications for copied areas.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground mt-2 text-sm">
|
||||||
|
Confirm each action with the exact project key to avoid accidental overwrites.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
|
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitSwitchTemplate">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-sm font-semibold">Switch to another template</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Applying a different template replaces the copied areas with that template's current baseline.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_template_id">Published template</Label>
|
||||||
|
<select
|
||||||
|
id="source_template_id"
|
||||||
|
v-model="switchTemplateForm.source_template_id"
|
||||||
|
:disabled="switchTemplateForm.processing || availableSwitchTemplates.length === 0"
|
||||||
|
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 transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option :value="undefined" disabled>
|
||||||
|
Select a template
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="template in availableSwitchTemplates"
|
||||||
|
:key="template.id"
|
||||||
|
:value="template.id"
|
||||||
|
>
|
||||||
|
{{ template.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError :message="switchTemplateForm.errors.source_template_id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="switch_confirmation_key">Confirmation key</Label>
|
||||||
|
<Input
|
||||||
|
id="switch_confirmation_key"
|
||||||
|
v-model="switchTemplateForm.confirmation_key"
|
||||||
|
:disabled="switchTemplateForm.processing"
|
||||||
|
placeholder="Type the project key exactly"
|
||||||
|
/>
|
||||||
|
<InputError :message="switchTemplateForm.errors.confirmation_key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Existing project-specific configuration in copied areas may be replaced or removed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="switchTemplateForm.processing || !canSwitchTemplate"
|
||||||
|
>
|
||||||
|
Switch template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="space-y-4 rounded-xl border p-4" @submit.prevent="submitResetTemplate">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-sm font-semibold">Reset to current template baseline</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Re-apply the currently linked template and clear local divergence inside copied areas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="reset_confirmation_key">Confirmation key</Label>
|
||||||
|
<Input
|
||||||
|
id="reset_confirmation_key"
|
||||||
|
v-model="resetTemplateForm.confirmation_key"
|
||||||
|
:disabled="resetTemplateForm.processing || !currentTemplate"
|
||||||
|
placeholder="Type the project key exactly"
|
||||||
|
/>
|
||||||
|
<InputError :message="resetTemplateForm.errors.confirmation_key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
This restores the current template baseline for copied areas and rewinds project-level overrides there.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="resetTemplateForm.processing || !isResetConfirmationValid || !currentTemplate"
|
||||||
|
>
|
||||||
|
Reset to baseline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -115,6 +115,32 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Template inheritance: {{ templateState.templateName }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Inherited sections stay synced with the template baseline. Local edits in these settings are treated as project-specific overrides.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.inheritedAreas"
|
||||||
|
:key="`inherited-${area.value}`"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ area.label }} · inherited
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.overriddenAreas"
|
||||||
|
:key="`overridden-${area.value}`"
|
||||||
|
>
|
||||||
|
{{ area.label }} · overridden
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-2">
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
<Card class="gap-0 overflow-hidden">
|
<Card class="gap-0 overflow-hidden">
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
|
|||||||
@@ -305,6 +305,32 @@ const notificationActionTimestamp = (
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Template inheritance: {{ templateState.templateName }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Notification rules can remain inherited from the template or diverge here as project-level overrides.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.inheritedAreas"
|
||||||
|
:key="`inherited-${area.value}`"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ area.label }} · inherited
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.overriddenAreas"
|
||||||
|
:key="`overridden-${area.value}`"
|
||||||
|
>
|
||||||
|
{{ area.label }} · overridden
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -277,6 +277,32 @@ const submitUpdate = (): void => {
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="templateState?.inheritsFromTemplate">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Template inheritance: {{ templateState.templateName }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Repository links can stay inherited from the template or be replaced with project-specific overrides.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="flex flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.inheritedAreas"
|
||||||
|
:key="`inherited-${area.value}`"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ area.label }} · inherited
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-for="area in templateState.overriddenAreas"
|
||||||
|
:key="`overridden-${area.value}`"
|
||||||
|
>
|
||||||
|
{{ area.label }} · overridden
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Personal Git access</CardTitle>
|
<CardTitle>Personal Git access</CardTitle>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||||
|
|
||||||
export type ProjectModel = {
|
export type ProjectModel = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
project_template_id?: number | null;
|
||||||
|
project_template?: ProjectTemplateModel | null;
|
||||||
show_author: boolean;
|
show_author: boolean;
|
||||||
show_story_points: boolean;
|
show_story_points: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
|
||||||
|
export type ProjectTemplateModel = {
|
||||||
|
id: number;
|
||||||
|
is_published: boolean;
|
||||||
|
project_id: number | null;
|
||||||
|
source_project_id: number | null;
|
||||||
|
source_project: ProjectModel | null;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
copy_areas: string[];
|
||||||
|
configuration_snapshot: Record<string, unknown>;
|
||||||
|
linked_project_count?: number;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import type { NotificationActionModel } from '@/types/models/notification-action
|
|||||||
import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model';
|
import type { NotificationActionOptionModel } from '@/types/models/notification-action-option-model';
|
||||||
import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model';
|
import type { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-model';
|
||||||
import type { ProjectModel } from '@/types/models/project-model';
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
import type { TeamModel } from '@/types/models/team-model';
|
import type { TeamModel } from '@/types/models/team-model';
|
||||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||||
@@ -32,14 +33,61 @@ export type AdminUserEditPageProps = {
|
|||||||
user: UserModel;
|
user: UserModel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectDuplicateCopyAreaOption = {
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectsCreatePageProps = {
|
||||||
|
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||||
|
selectedSourceTemplateId?: number | null;
|
||||||
|
sourceProjects: ProjectModel[];
|
||||||
|
sourceTemplates: ProjectTemplateModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplatesIndexPageProps = {
|
||||||
|
status?: string | null;
|
||||||
|
templates: ProjectTemplateModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplatesCreatePageProps = {
|
||||||
|
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||||
|
project: ProjectModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplatesEditPageProps = {
|
||||||
|
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||||
|
template: ProjectTemplateModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplateStateArea = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTemplateState = {
|
||||||
|
effectiveConfiguration: Record<string, unknown>;
|
||||||
|
inheritedAreas: ProjectTemplateStateArea[];
|
||||||
|
inheritedConfiguration?: Record<string, unknown>;
|
||||||
|
inheritsFromTemplate: boolean;
|
||||||
|
overriddenAreas: ProjectTemplateStateArea[];
|
||||||
|
overriddenConfiguration?: Record<string, unknown>;
|
||||||
|
templateConfiguration?: Record<string, unknown>;
|
||||||
|
templateId: number;
|
||||||
|
templateName: string;
|
||||||
|
templateSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectShowPageProps = {
|
export type ProjectShowPageProps = {
|
||||||
canManageAccess: boolean;
|
canManageAccess: boolean;
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PermissionOption = {
|
export type PermissionOption = {
|
||||||
@@ -71,12 +119,15 @@ export type ProjectBoardSettingsPageProps = {
|
|||||||
boardColumns: BoardColumnModel[];
|
boardColumns: BoardColumnModel[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
ticketStatuses: TicketStatusModel[];
|
ticketStatuses: TicketStatusModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectGeneralSettingsPageProps = {
|
export type ProjectGeneralSettingsPageProps = {
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
|
sourceTemplates: ProjectTemplateModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectBoardPageProps = {
|
export type ProjectBoardPageProps = {
|
||||||
@@ -97,6 +148,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
|||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||||
ticketLinkTypes: TicketLinkTypeModel[];
|
ticketLinkTypes: TicketLinkTypeModel[];
|
||||||
ticketPriorities: TicketPriorityModel[];
|
ticketPriorities: TicketPriorityModel[];
|
||||||
@@ -108,6 +160,7 @@ export type ProjectRepositorySettingsPageProps = {
|
|||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
repositoryConnections: RepositoryConnectionModel[];
|
repositoryConnections: RepositoryConnectionModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectNotificationSettingsPageProps = {
|
export type ProjectNotificationSettingsPageProps = {
|
||||||
@@ -119,6 +172,7 @@ export type ProjectNotificationSettingsPageProps = {
|
|||||||
notificationActions: NotificationActionModel[];
|
notificationActions: NotificationActionModel[];
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
templateState?: ProjectTemplateState | null;
|
||||||
templateVariables: NotificationActionTemplateVariableModel[];
|
templateVariables: NotificationActionTemplateVariableModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||||
|
|
||||||
require __DIR__.'/web/admin.php';
|
require __DIR__.'/web/admin.php';
|
||||||
|
require __DIR__.'/web/project-templates.php';
|
||||||
require __DIR__.'/web/projects.php';
|
require __DIR__.'/web/projects.php';
|
||||||
require __DIR__.'/web/teams.php';
|
require __DIR__.'/web/teams.php';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ProjectTemplateController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::middleware('can:create-project')
|
||||||
|
->prefix('project-templates')
|
||||||
|
->name('project-templates.')
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('/', [ProjectTemplateController::class, 'index'])->name('index');
|
||||||
|
Route::get('{projectTemplate}/edit', [ProjectTemplateController::class, 'edit'])->name('edit');
|
||||||
|
Route::patch('{projectTemplate}', [ProjectTemplateController::class, 'update'])->name('update');
|
||||||
|
Route::delete('{projectTemplate}', [ProjectTemplateController::class, 'destroy'])->name('destroy');
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('general', [ProjectController::class, 'general'])->name('general');
|
Route::get('general', [ProjectController::class, 'general'])->name('general');
|
||||||
|
Route::post('general/switch-template', [ProjectController::class, 'switchTemplate'])->name('general.switch-template');
|
||||||
|
Route::post('general/reset-template', [ProjectController::class, 'resetTemplate'])->name('general.reset-template');
|
||||||
|
|
||||||
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
|
Route::get('board', [ProjectBoardSettingsController::class, 'show'])->name('board');
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\ProjectBoardController;
|
use App\Http\Controllers\ProjectBoardController;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
|
use App\Http\Controllers\ProjectTemplateController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('projects')->name('projects.')->group(function (): void {
|
Route::prefix('projects')->name('projects.')->group(function (): void {
|
||||||
@@ -18,6 +19,11 @@ Route::prefix('projects')->name('projects.')->group(function (): void {
|
|||||||
Route::get('/', [ProjectController::class, 'show'])->name('show');
|
Route::get('/', [ProjectController::class, 'show'])->name('show');
|
||||||
Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index');
|
Route::get('board', [ProjectBoardController::class, 'index'])->name('board.index');
|
||||||
Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
|
Route::get('backlog', [ProjectController::class, 'backlog'])->name('backlog.index');
|
||||||
|
|
||||||
|
Route::middleware('can:create-project')->group(function (): void {
|
||||||
|
Route::get('templates/create', [ProjectTemplateController::class, 'create'])->name('templates.create');
|
||||||
|
Route::post('templates', [ProjectTemplateController::class, 'store'])->name('templates.store');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::delete('/', [ProjectController::class, 'destroy'])
|
Route::delete('/', [ProjectController::class, 'destroy'])
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Api;
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\TicketType;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -38,6 +43,7 @@ class ProjectApiTest extends TestCase
|
|||||||
|
|
||||||
$this->withApplicationKey($user)
|
$this->withApplicationKey($user)
|
||||||
->postJson(route('api.projects.store'), [
|
->postJson(route('api.projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
'key' => 'api',
|
'key' => 'api',
|
||||||
'name' => 'API Project',
|
'name' => 'API Project',
|
||||||
])
|
])
|
||||||
@@ -47,8 +53,218 @@ class ProjectApiTest extends TestCase
|
|||||||
->assertJsonPath('meta.message', 'API Project was created.');
|
->assertJsonPath('meta.message', 'API Project was created.');
|
||||||
|
|
||||||
$project = Project::query()->where('key', 'API')->firstOrFail();
|
$project = Project::query()->where('key', 'API')->firstOrFail();
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
$this->assertSame(2, $project->ticketTypes()->count());
|
$this->assertSame(2, $project->ticketTypes()->count());
|
||||||
$this->assertSame(1, $project->ticketStatuses()->count());
|
$this->assertSame(1, $project->ticketStatuses()->count());
|
||||||
|
$this->assertNotNull($project->project_template_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_create_projects_from_templates_via_api(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'API Template',
|
||||||
|
'slug' => 'api-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
[
|
||||||
|
'name' => 'Spike',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->postJson(route('api.projects.store'), [
|
||||||
|
'key' => 'tpl1',
|
||||||
|
'name' => 'Template API Project',
|
||||||
|
'creation_mode' => 'template',
|
||||||
|
'source_template_id' => $template->id,
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.key', 'TPL1')
|
||||||
|
->assertJsonPath('data.name', 'Template API Project');
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'TPL1')->firstOrFail();
|
||||||
|
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($template->id, $project->project_template_id);
|
||||||
|
$this->assertDatabaseMissing('project_templates', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'is_published' => false,
|
||||||
|
]);
|
||||||
|
$this->assertFalse($project->show_author);
|
||||||
|
$this->assertFalse($project->show_story_points);
|
||||||
|
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
|
||||||
|
$this->assertDatabaseHas('ticket_types', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'name' => 'Spike',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_api_exposes_template_context_and_overrides(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'TMP1',
|
||||||
|
'name' => 'Template Driven Project',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Workflow Template',
|
||||||
|
'slug' => 'workflow-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
[
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Feature',
|
||||||
|
'position' => 2,
|
||||||
|
'is_parent' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
]);
|
||||||
|
$project->ticketTypes()->delete();
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
]);
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'name' => 'Feature',
|
||||||
|
'position' => 2,
|
||||||
|
'is_parent' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.show', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.project_template.id', $template->id)
|
||||||
|
->assertJsonPath('data.project_template.slug', 'workflow-template')
|
||||||
|
->assertJsonPath('data.project_template.configuration_snapshot.project_settings.show_author', true)
|
||||||
|
->assertJsonPath('data.template_state.inherits_from_template', true)
|
||||||
|
->assertJsonPath('data.template_state.template_id', $template->id)
|
||||||
|
->assertJsonPath('data.template_state.template_configuration.project_settings.show_author', true)
|
||||||
|
->assertJsonPath('data.template_state.effective_configuration.ticket_types.0.name', 'Bug')
|
||||||
|
->assertJsonPath('data.template_state.inherited_configuration.ticket_types.1.name', 'Feature')
|
||||||
|
->assertJsonPath('data.template_state.overridden_configuration.project_settings.show_author', false)
|
||||||
|
->assertJsonPath('data.template_state.overridden_areas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_can_duplicate_projects_via_api(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
|
||||||
|
$sourceProject = Project::factory()->create([
|
||||||
|
'key' => 'SRC1',
|
||||||
|
'show_author' => false,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($sourceProject, $user, ['view']);
|
||||||
|
|
||||||
|
$sourceProject->ticketTypes()->delete();
|
||||||
|
$sourceProject->ticketStatuses()->delete();
|
||||||
|
$sourceProject->boardColumns()->delete();
|
||||||
|
|
||||||
|
$column = BoardColumn::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'In Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$backlogStatus = TicketStatus::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Queued',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$activeStatus = TicketStatus::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'board_column_id' => $column->id,
|
||||||
|
'name' => 'Active',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$backlogStatus->allowedTargetStatuses()->sync([$activeStatus->id]);
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Spike',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->postJson(route('api.projects.store'), [
|
||||||
|
'key' => 'dup2',
|
||||||
|
'name' => 'Duplicated API Project',
|
||||||
|
'creation_mode' => 'duplicate',
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('data.key', 'DUP2')
|
||||||
|
->assertJsonPath('data.name', 'Duplicated API Project');
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'DUP2')->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertFalse($project->show_author);
|
||||||
|
$this->assertSame(['In Progress'], $project->boardColumns()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Queued', 'Active'], $project->ticketStatuses()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Active'], $project->ticketStatuses()->where('name', 'Queued')->firstOrFail()->allowedTargetStatuses()->pluck('name')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_duplication_via_api_requires_visible_source_project(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['create']);
|
||||||
|
$sourceProject = Project::factory()->create();
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->postJson(route('api.projects.store'), [
|
||||||
|
'key' => 'nope',
|
||||||
|
'name' => 'No Access',
|
||||||
|
'creation_mode' => 'duplicate',
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
])
|
||||||
|
->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['source_project_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_project_show_and_delete_enforce_permissions(): void
|
public function test_project_show_and_delete_enforce_permissions(): void
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Database;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTemplateBaselineMigrationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_migration_backfills_hidden_base_templates_for_existing_projects(): void
|
||||||
|
{
|
||||||
|
Artisan::call('migrate:fresh', [
|
||||||
|
'--path' => [
|
||||||
|
'database/migrations/0001_01_01_000000_create_users_table.php',
|
||||||
|
'database/migrations/0001_01_01_000001_create_cache_table.php',
|
||||||
|
'database/migrations/0001_01_01_000002_create_jobs_table.php',
|
||||||
|
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
|
||||||
|
'database/migrations/2026_03_24_234007_create_projects_table.php',
|
||||||
|
'database/migrations/2026_03_24_234734_create_teams_table.php',
|
||||||
|
'database/migrations/2026_03_24_234735_create_team_user_table.php',
|
||||||
|
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
|
||||||
|
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
|
||||||
|
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
|
||||||
|
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
|
||||||
|
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
|
||||||
|
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
|
||||||
|
'database/migrations/2026_03_25_004100_create_tickets_table.php',
|
||||||
|
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
|
||||||
|
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
|
||||||
|
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
|
||||||
|
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
|
||||||
|
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
|
||||||
|
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
|
||||||
|
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
|
||||||
|
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
|
||||||
|
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
|
||||||
|
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
|
||||||
|
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
|
||||||
|
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
|
||||||
|
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
|
||||||
|
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
|
||||||
|
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
|
||||||
|
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('projects')->insert([
|
||||||
|
'name' => 'Legacy Project',
|
||||||
|
'key' => 'LGCY',
|
||||||
|
'show_author' => 0,
|
||||||
|
'show_story_points' => 1,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
|
||||||
|
|
||||||
|
Artisan::call('migrate', [
|
||||||
|
'--path' => 'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = DB::table('projects')->where('id', $projectId)->first();
|
||||||
|
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($project->project_template_id);
|
||||||
|
$this->assertNotNull($template);
|
||||||
|
$this->assertSame($projectId, $template->project_id);
|
||||||
|
$this->assertSame($projectId, $template->source_project_id);
|
||||||
|
$this->assertSame(0, $template->is_published);
|
||||||
|
$this->assertSame('lgcy-base-template', $template->slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void
|
||||||
|
{
|
||||||
|
Artisan::call('migrate:fresh', [
|
||||||
|
'--path' => [
|
||||||
|
'database/migrations/0001_01_01_000000_create_users_table.php',
|
||||||
|
'database/migrations/0001_01_01_000001_create_cache_table.php',
|
||||||
|
'database/migrations/0001_01_01_000002_create_jobs_table.php',
|
||||||
|
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
|
||||||
|
'database/migrations/2026_03_24_234007_create_projects_table.php',
|
||||||
|
'database/migrations/2026_03_24_234734_create_teams_table.php',
|
||||||
|
'database/migrations/2026_03_24_234735_create_team_user_table.php',
|
||||||
|
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
|
||||||
|
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
|
||||||
|
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
|
||||||
|
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
|
||||||
|
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
|
||||||
|
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
|
||||||
|
'database/migrations/2026_03_25_004100_create_tickets_table.php',
|
||||||
|
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
|
||||||
|
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
|
||||||
|
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
|
||||||
|
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
|
||||||
|
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
|
||||||
|
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
|
||||||
|
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
|
||||||
|
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
|
||||||
|
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
|
||||||
|
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
|
||||||
|
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
|
||||||
|
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
|
||||||
|
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
|
||||||
|
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
|
||||||
|
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
|
||||||
|
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
|
||||||
|
'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('projects')->insert([
|
||||||
|
'name' => 'Legacy Project',
|
||||||
|
'key' => 'LGCY',
|
||||||
|
'show_author' => 0,
|
||||||
|
'show_story_points' => 1,
|
||||||
|
'project_template_id' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
|
||||||
|
|
||||||
|
DB::table('ticket_types')->insert([
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => 0,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Artisan::call('migrate', [
|
||||||
|
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = DB::table('projects')->where('id', $projectId)->first();
|
||||||
|
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
|
||||||
|
$ticketType = DB::table('ticket_types')->where('project_id', $projectId)->first();
|
||||||
|
$snapshot = json_decode((string) $template->configuration_snapshot, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->assertSame($project->project_template_id, $ticketType->project_template_id);
|
||||||
|
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Mcp;
|
|||||||
|
|
||||||
use App\Actions\Notifications\DeliverNotificationAction;
|
use App\Actions\Notifications\DeliverNotificationAction;
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Enums\NotificationDeliveryStatus;
|
use App\Enums\NotificationDeliveryStatus;
|
||||||
use App\Mcp\Servers\KanbanGitServer;
|
use App\Mcp\Servers\KanbanGitServer;
|
||||||
use App\Mcp\Tools\GetTicket;
|
use App\Mcp\Tools\GetTicket;
|
||||||
@@ -15,8 +16,10 @@ use App\Mcp\Tools\UpdateTicketDescription;
|
|||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\NotificationAction;
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\TicketType;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
@@ -207,6 +210,66 @@ class KanbanGitMcpTest extends TestCase
|
|||||||
->etc());
|
->etc());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_get_ticket_exposes_project_template_context_and_overrides(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = $this->project('CORE', 'Core project');
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Agent Workflow Template',
|
||||||
|
'slug' => 'agent-workflow-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
[
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$project->update([
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$project->ticketTypes()->delete();
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
]);
|
||||||
|
$status = $project->ticketStatuses()->firstOrFail();
|
||||||
|
$ticket = $this->ticket($project, $status, [
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'title' => 'Template aware ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
|
KanbanGitServer::actingAs($user)
|
||||||
|
->tool(GetTicket::class, [
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertStructuredContent(fn (AssertableJson $json) => $json
|
||||||
|
->where('data.project.project_template.id', $template->id)
|
||||||
|
->where('data.project.project_template.slug', 'agent-workflow-template')
|
||||||
|
->where('data.project.template_state.inherits_from_template', true)
|
||||||
|
->where('data.project.template_state.template_configuration.project_settings.show_author', true)
|
||||||
|
->where('data.project.template_state.overridden_configuration.project_settings.show_author', false)
|
||||||
|
->where('data.project.template_state.effective_configuration.ticket_types.0.name', 'Bug')
|
||||||
|
->etc());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void
|
public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ namespace Tests\Feature\Models;
|
|||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\UserSeeder;
|
use Database\Seeders\UserSeeder;
|
||||||
use DateTimeInterface;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Filesystem\FilesystemAdapter;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Mockery;
|
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class UserTest extends TestCase
|
class UserTest extends TestCase
|
||||||
@@ -35,13 +32,9 @@ class UserTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_user_avatar_attribute_returns_configured_storage_url(): void
|
public function test_user_avatar_attribute_returns_public_storage_url(): void
|
||||||
{
|
{
|
||||||
config([
|
Storage::fake('public');
|
||||||
'filesystems.avatar_disk' => 'avatar-test',
|
|
||||||
'filesystems.avatar_urls_temporary' => false,
|
|
||||||
]);
|
|
||||||
Storage::fake('avatar-test');
|
|
||||||
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'avatar_path' => 'avatars/generated/example.svg',
|
'avatar_path' => 'avatars/generated/example.svg',
|
||||||
@@ -50,38 +43,12 @@ class UserTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
Storage::disk('avatar-test')->url('avatars/generated/example.svg'),
|
Storage::disk('public')->url('avatars/generated/example.svg'),
|
||||||
$user->avatar,
|
$user->avatar,
|
||||||
);
|
);
|
||||||
$this->assertArrayNotHasKey('avatar_path', $user->toArray());
|
$this->assertArrayNotHasKey('avatar_path', $user->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_user_avatar_attribute_returns_temporary_url_for_private_storage(): void
|
|
||||||
{
|
|
||||||
config([
|
|
||||||
'filesystems.avatar_disk' => 's3',
|
|
||||||
'filesystems.avatar_urls_temporary' => true,
|
|
||||||
'filesystems.avatar_url_expiration_minutes' => 10,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$disk = Mockery::mock(FilesystemAdapter::class);
|
|
||||||
$disk->shouldReceive('temporaryUrl')
|
|
||||||
->once()
|
|
||||||
->with(
|
|
||||||
'avatars/uploads/private.png',
|
|
||||||
Mockery::on(fn (mixed $expiration): bool => $expiration instanceof DateTimeInterface),
|
|
||||||
)
|
|
||||||
->andReturn('https://object-storage.test/signed-avatar');
|
|
||||||
Storage::shouldReceive('disk')->once()->with('s3')->andReturn($disk);
|
|
||||||
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'avatar_path' => 'avatars/uploads/private.png',
|
|
||||||
'avatar_is_generated' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSame('https://object-storage.test/signed-avatar', $user->avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_user_can_identify_uploaded_avatars(): void
|
public function test_user_can_identify_uploaded_avatars(): void
|
||||||
{
|
{
|
||||||
$generated = User::factory()->make([
|
$generated = User::factory()->make([
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -31,6 +33,15 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
]),
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW],
|
||||||
|
'name' => 'Starter Workflow',
|
||||||
|
]);
|
||||||
|
$project->update(['project_template_id' => $template->id]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -41,7 +52,9 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
->where('project.id', $project->id)
|
->where('project.id', $project->id)
|
||||||
->where('backlogTicketStatuses.0.name', 'Backlog')
|
->where('backlogTicketStatuses.0.name', 'Backlog')
|
||||||
->where('boardColumns.0.name', 'Todo')
|
->where('boardColumns.0.name', 'Todo')
|
||||||
->where('ticketStatuses.0.name', 'Backlog'),
|
->where('ticketStatuses.0.name', 'Backlog')
|
||||||
|
->where('templateState.templateName', 'Starter Workflow')
|
||||||
|
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Models\BoardColumn;
|
use App\Models\BoardColumn;
|
||||||
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
|
use App\Models\RepositoryConnection;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketLinkType;
|
||||||
|
use App\Models\TicketPriority;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
|
use App\Models\TicketType;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -100,12 +108,40 @@ class ProjectControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->syncGlobalPermissions($user, ['project:create']);
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
$visibleProject = Project::factory()->create([
|
||||||
|
'name' => 'Visible Source',
|
||||||
|
'key' => 'SRC1',
|
||||||
|
]);
|
||||||
|
Project::factory()->create([
|
||||||
|
'name' => 'Hidden Source',
|
||||||
|
'key' => 'SRC2',
|
||||||
|
]);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Reusable Setup',
|
||||||
|
'slug' => 'reusable-setup',
|
||||||
|
'source_project_id' => $visibleProject->id,
|
||||||
|
]);
|
||||||
|
ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Hidden Base',
|
||||||
|
'slug' => 'hidden-base',
|
||||||
|
'project_id' => $visibleProject->id,
|
||||||
|
'source_project_id' => $visibleProject->id,
|
||||||
|
'is_published' => false,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($visibleProject, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('projects.create'))
|
->get(route('projects.create', ['template' => $template->id]))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('projects/Create'),
|
->component('projects/Create')
|
||||||
|
->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas()))
|
||||||
|
->has('sourceProjects', 1)
|
||||||
|
->has('sourceTemplates', 1)
|
||||||
|
->where('sourceProjects.0.id', $visibleProject->id)
|
||||||
|
->where('sourceTemplates.0.id', $template->id)
|
||||||
|
->where('selectedSourceTemplateId', $template->id)
|
||||||
|
->where('copyAreaOptions.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +167,7 @@ class ProjectControllerTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->post(route('projects.store'), [
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'scratch',
|
||||||
'name' => 'Kanban Foundation',
|
'name' => 'Kanban Foundation',
|
||||||
'key' => 'core',
|
'key' => 'core',
|
||||||
]);
|
]);
|
||||||
@@ -144,6 +181,18 @@ class ProjectControllerTest extends TestCase
|
|||||||
'key' => 'CORE',
|
'key' => 'CORE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
$this->assertNotNull($project->project_template_id);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('project_templates', [
|
||||||
|
'id' => $project->project_template_id,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'source_project_id' => $project->id,
|
||||||
|
'is_published' => false,
|
||||||
|
'slug' => 'core-base-template',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('permission_assignments', [
|
$this->assertDatabaseHas('permission_assignments', [
|
||||||
'assignee_id' => $user->id,
|
'assignee_id' => $user->id,
|
||||||
'assignee_type' => $user->getMorphClass(),
|
'assignee_type' => $user->getMorphClass(),
|
||||||
@@ -187,6 +236,220 @@ class ProjectControllerTest extends TestCase
|
|||||||
->assertRedirect(route('projects.create'));
|
->assertRedirect(route('projects.create'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_users_can_create_projects_from_saved_templates(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$sourceProject = Project::factory()->create([
|
||||||
|
'key' => 'SRC1',
|
||||||
|
'name' => 'Source Project',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Kanban Template',
|
||||||
|
'slug' => 'kanban-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
[
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'creation_mode' => 'template',
|
||||||
|
'name' => 'Template Based Project',
|
||||||
|
'key' => 'TMP1',
|
||||||
|
'source_template_id' => $template->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'TMP1')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('projects.show', $project));
|
||||||
|
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($template->id, $project->project_template_id);
|
||||||
|
$this->assertDatabaseMissing('project_templates', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'is_published' => false,
|
||||||
|
]);
|
||||||
|
$this->assertSame(false, $project->show_author);
|
||||||
|
$this->assertSame(false, $project->show_story_points);
|
||||||
|
$this->assertDatabaseHas('ticket_types', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
'is_parent' => false,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('tickets', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authenticated_users_can_duplicate_projects_with_selected_settings_only(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$sourceProject = Project::factory()->create([
|
||||||
|
'key' => 'SRC1',
|
||||||
|
'name' => 'Source Project',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($sourceProject, $user, ['view']);
|
||||||
|
|
||||||
|
$sourceProject->ticketTypes()->delete();
|
||||||
|
$sourceProject->ticketPriorities()->delete();
|
||||||
|
$sourceProject->ticketStatuses()->delete();
|
||||||
|
$sourceProject->boardColumns()->delete();
|
||||||
|
|
||||||
|
$todoColumn = BoardColumn::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$doingColumn = BoardColumn::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Doing',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backlogStatus = TicketStatus::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'board_column_id' => null,
|
||||||
|
'name' => 'Planned',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$todoStatus = TicketStatus::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'board_column_id' => $todoColumn->id,
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$doingStatus = TicketStatus::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'board_column_id' => $doingColumn->id,
|
||||||
|
'name' => 'Doing',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$todoStatus->allowedTargetStatuses()->sync([$doingStatus->id]);
|
||||||
|
$backlogStatus->allowedTargetStatuses()->sync([$todoStatus->id]);
|
||||||
|
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Bug',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
TicketType::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Epic',
|
||||||
|
'position' => 2,
|
||||||
|
'is_parent' => true,
|
||||||
|
]);
|
||||||
|
TicketPriority::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Urgent',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
TicketFieldDefinition::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'field_type' => 'number',
|
||||||
|
'label' => 'Story estimate',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
TicketLinkType::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'label' => 'Blocks',
|
||||||
|
'counterpart_label' => 'Blocked by',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
RepositoryConnection::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'repository_full_name' => 'OpenSource/source-project',
|
||||||
|
]);
|
||||||
|
NotificationAction::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'name' => 'Notify board changes',
|
||||||
|
]);
|
||||||
|
Ticket::factory()->create([
|
||||||
|
'project_id' => $sourceProject->id,
|
||||||
|
'sequence_number' => 42,
|
||||||
|
'title' => 'Existing ticket',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'name' => 'Duplicated Project',
|
||||||
|
'key' => 'dupe',
|
||||||
|
'creation_mode' => 'duplicate',
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = Project::query()->where('key', 'DUPE')->firstOrFail();
|
||||||
|
|
||||||
|
$response->assertRedirect(route('projects.show', $project));
|
||||||
|
|
||||||
|
$this->assertSame('Duplicated Project', $project->name);
|
||||||
|
$this->assertFalse($project->show_author);
|
||||||
|
$this->assertFalse($project->show_story_points);
|
||||||
|
$this->assertSame(0, $project->tickets()->count());
|
||||||
|
|
||||||
|
$this->assertSame(['Todo', 'Doing'], $project->boardColumns()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Planned', 'Todo', 'Doing'], $project->ticketStatuses()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Bug', 'Epic'], $project->ticketTypes()->pluck('name')->all());
|
||||||
|
$this->assertSame(['Story estimate'], $project->ticketFieldDefinitions()->pluck('label')->all());
|
||||||
|
$this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all());
|
||||||
|
$this->assertCount(0, $project->ticketLinkTypes);
|
||||||
|
$this->assertCount(0, $project->repositoryConnections);
|
||||||
|
$this->assertCount(0, $project->notificationActions);
|
||||||
|
|
||||||
|
$duplicatedTodoStatus = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail();
|
||||||
|
$this->assertSame(['Doing'], $duplicatedTodoStatus->allowedTargetStatuses()->pluck('name')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_duplication_requires_accessible_source_projects(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
$sourceProject = Project::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.create'))
|
||||||
|
->post(route('projects.store'), [
|
||||||
|
'name' => 'Denied Duplicate',
|
||||||
|
'key' => 'deny',
|
||||||
|
'creation_mode' => 'duplicate',
|
||||||
|
'source_project_id' => $sourceProject->id,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('source_project_id')
|
||||||
|
->assertRedirect(route('projects.create'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure project keys cannot exceed five characters.
|
* Ensure project keys cannot exceed five characters.
|
||||||
*/
|
*/
|
||||||
@@ -332,6 +595,159 @@ class ProjectControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_project_admins_can_switch_projects_to_another_template(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
'name' => 'Core Platform',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$currentTemplate = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Current Template',
|
||||||
|
'slug' => 'current-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$newTemplate = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Operations Template',
|
||||||
|
'slug' => 'operations-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
['name' => 'Incident', 'position' => 1, 'is_parent' => false],
|
||||||
|
['name' => 'Maintenance', 'position' => 2, 'is_parent' => false],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update(['project_template_id' => $currentTemplate->id]);
|
||||||
|
$project->ticketTypes()->delete();
|
||||||
|
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.general', $project))
|
||||||
|
->post(route('projects.settings.general.switch-template', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
'source_template_id' => $newTemplate->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.general', $project))
|
||||||
|
->assertSessionHas('status', 'Core Platform now uses the Operations Template template.');
|
||||||
|
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($newTemplate->id, $project->project_template_id);
|
||||||
|
$this->assertTrue($project->show_author);
|
||||||
|
$this->assertTrue($project->show_story_points);
|
||||||
|
$this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all());
|
||||||
|
$this->assertDatabaseHas('ticket_types', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'project_template_id' => $newTemplate->id,
|
||||||
|
'name' => 'Incident',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
'name' => 'Core Platform',
|
||||||
|
'show_author' => false,
|
||||||
|
'show_story_points' => false,
|
||||||
|
]);
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'name' => 'Workflow Template',
|
||||||
|
'slug' => 'workflow-template',
|
||||||
|
'copy_areas' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||||
|
],
|
||||||
|
'configuration_snapshot' => [
|
||||||
|
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||||
|
'show_author' => true,
|
||||||
|
'show_story_points' => true,
|
||||||
|
],
|
||||||
|
DuplicateProjectConfiguration::COPY_TICKET_TYPES => [
|
||||||
|
['name' => 'Bug', 'position' => 1, 'is_parent' => false],
|
||||||
|
['name' => 'Feature', 'position' => 2, 'is_parent' => false],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project->update(['project_template_id' => $template->id]);
|
||||||
|
$project->ticketTypes()->delete();
|
||||||
|
TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.general', $project))
|
||||||
|
->post(route('projects.settings.general.reset-template', $project), [
|
||||||
|
'confirmation_key' => 'CORE',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.general', $project))
|
||||||
|
->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.');
|
||||||
|
|
||||||
|
$project->refresh();
|
||||||
|
|
||||||
|
$this->assertSame($template->id, $project->project_template_id);
|
||||||
|
$this->assertTrue($project->show_author);
|
||||||
|
$this->assertTrue($project->show_story_points);
|
||||||
|
$this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all());
|
||||||
|
$this->assertDatabaseHas('ticket_types', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'project_template_id' => $template->id,
|
||||||
|
'name' => 'Bug',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_project_template_switch_requires_exact_project_key_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
$project->update([
|
||||||
|
'project_template_id' => ProjectTemplate::factory()->create()->id,
|
||||||
|
]);
|
||||||
|
$newTemplate = ProjectTemplate::factory()->create();
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['admin']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.general', $project))
|
||||||
|
->post(route('projects.settings.general.switch-template', $project), [
|
||||||
|
'confirmation_key' => 'core',
|
||||||
|
'source_template_id' => $newTemplate->id,
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.general', $project))
|
||||||
|
->assertSessionHasErrors('confirmation_key');
|
||||||
|
|
||||||
|
$this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure admins can delete projects and their dependent project-scoped data.
|
* Ensure admins can delete projects and their dependent project-scoped data.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||||
use App\Enums\NotificationChannel;
|
use App\Enums\NotificationChannel;
|
||||||
use App\Enums\NotificationEventType;
|
use App\Enums\NotificationEventType;
|
||||||
use App\Models\NotificationAction;
|
use App\Models\NotificationAction;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\ProjectTemplate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -41,6 +43,15 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
|
||||||
|
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||||
|
]),
|
||||||
|
'copy_areas' => [DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS],
|
||||||
|
'name' => 'Alert Template',
|
||||||
|
]);
|
||||||
|
$project->update(['project_template_id' => $template->id]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@@ -72,6 +83,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
->where('templateVariables.6.value', '$actor_name')
|
->where('templateVariables.6.value', '$actor_name')
|
||||||
->where('templateVariables.7.value', '$assignee_from')
|
->where('templateVariables.7.value', '$assignee_from')
|
||||||
->where('templateVariables.8.value', '$assignee_to')
|
->where('templateVariables.8.value', '$assignee_to')
|
||||||
|
->where('templateState.templateName', 'Alert Template')
|
||||||
|
->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS)
|
||||||
->missing('notificationActions.0.delivery_url'),
|
->missing('notificationActions.0.delivery_url'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user