Compare commits
21
Commits
v0.1.0
..
149748b03e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149748b03e | ||
|
|
3122e00ed8 | ||
|
|
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
|
||||
!**/composer.json
|
||||
!**/composer.lock
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
@@ -40,9 +41,9 @@ vendor
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
# Runtime-generated data must not become part of an image layer.
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
# Ignore development-related files and directories
|
||||
# For development environment, we will use mount bind to mount the source code to the container
|
||||
# So we don't need to copy the source code to the container for development
|
||||
tests/
|
||||
phpunit.xml
|
||||
phpunit.xml.dist
|
||||
|
||||
+1
-17
@@ -10,7 +10,7 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
LOG_STACK=stderr
|
||||
LOG_STACK=daily
|
||||
|
||||
# Optional Discord error logging (append `,discord_errors` to LOG_STACK to enable it)
|
||||
LOG_DISCORD_WEBHOOK_URL=
|
||||
@@ -38,22 +38,6 @@ MAIL_ENCRYPTION=tls
|
||||
MAIL_FROM_ADDRESS=no-reply@example.com
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=database
|
||||
SESSION_DRIVER=database
|
||||
|
||||
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}"
|
||||
|
||||
@@ -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
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - 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.
|
||||
- `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
|
||||
- `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.
|
||||
|
||||
-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;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Enums\DiscordAllowedMentionType;
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationEventType;
|
||||
@@ -15,6 +17,7 @@ class BuildProjectNotificationSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ListProjectNotificationActions $listProjectNotificationActions,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -33,6 +36,9 @@ class BuildProjectNotificationSettingsPayload
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||
]),
|
||||
'templateVariables' => $this->templateVariables(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
namespace App\Actions\ProjectBoard;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\TicketStatus;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateTicketStatusTransitions
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{allowed_ticket_status_ids?: array<int, int|string>|null} $validated
|
||||
*/
|
||||
@@ -19,6 +27,14 @@ class UpdateTicketStatusTransitions
|
||||
->all();
|
||||
|
||||
$ticketStatus->allowedTargetStatuses()->sync($allowedTicketStatusIds);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$ticketStatus->project,
|
||||
DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS,
|
||||
);
|
||||
|
||||
return $ticketStatus->fresh();
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
namespace App\Actions\ProjectTicketMetadata;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class UpdateProjectTicketMetadataSettings
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{show_author?: bool|null, show_story_points?: bool|null} $validated
|
||||
*/
|
||||
@@ -16,6 +24,14 @@ class UpdateProjectTicketMetadataSettings
|
||||
'show_author' => Arr::get($validated, 'show_author', false),
|
||||
'show_story_points' => Arr::get($validated, 'show_story_points', false),
|
||||
]);
|
||||
$this->projectTemplateOverrideManager->mark(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
$this->refreshProjectBaseTemplate->handle(
|
||||
$project,
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
);
|
||||
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ use App\Models\Project;
|
||||
|
||||
class BuildProjectBoardSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -27,6 +31,9 @@ class BuildProjectBoardSettingsPayload
|
||||
)->resolve(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW,
|
||||
]),
|
||||
'ticketStatuses' => TicketStatusResource::collection(
|
||||
$project->ticketStatuses()
|
||||
->with(['allowedTargetStatuses.boardColumn', 'boardColumn'])
|
||||
|
||||
@@ -13,6 +13,10 @@ use App\Models\TicketFieldDefinition;
|
||||
|
||||
class BuildProjectTicketMetadataSettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -23,6 +27,13 @@ class BuildProjectTicketMetadataSettingsPayload
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'error' => $error,
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
]),
|
||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||
|
||||
@@ -7,9 +7,9 @@ use App\Http\Resources\TicketPriorityResource;
|
||||
use App\Http\Resources\TicketReferenceResource;
|
||||
use App\Http\Resources\TicketStatusResource;
|
||||
use App\Http\Resources\TicketTypeResource;
|
||||
use App\Models\Ticket;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
|
||||
class BuildProjectTicketOptionsPayload
|
||||
|
||||
@@ -6,25 +6,85 @@ use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CreateProject
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectBaseTemplate $createProjectBaseTemplate,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{key: string, name: string} $attributes
|
||||
* @param array{key: string, name: string, creation_mode?: string, source_project_id?: int|string|null, source_template_id?: int|string|null, copy_areas?: array<int, string>|null} $attributes
|
||||
*/
|
||||
public function handle(array $attributes, User $actor): Project
|
||||
{
|
||||
$project = Project::query()->create($attributes);
|
||||
return DB::transaction(function () use ($attributes, $actor): Project {
|
||||
$project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
|
||||
$creationMode = Arr::get($attributes, 'creation_mode', 'scratch');
|
||||
$sourceTemplateId = Arr::get($attributes, 'source_template_id');
|
||||
$baseTemplate = null;
|
||||
$sourceTemplate = null;
|
||||
|
||||
$this->initializeProjectTicketMetadata->handle($project);
|
||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||
if ($creationMode === 'template' && $sourceTemplateId !== null) {
|
||||
$sourceTemplate = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail((int) $sourceTemplateId);
|
||||
|
||||
return $project;
|
||||
$project->forceFill([
|
||||
'project_template_id' => $sourceTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
} else {
|
||||
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $baseTemplate->id,
|
||||
'template_overrides' => [],
|
||||
])->saveQuietly();
|
||||
}
|
||||
|
||||
$project = $project->fresh() ?? $project;
|
||||
|
||||
$this->projectTemplateOverrideManager->withoutRecording(
|
||||
fn () => $this->initializeProjectTicketMetadata->handle($project),
|
||||
);
|
||||
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
|
||||
|
||||
if ($creationMode === 'duplicate' && Arr::get($attributes, 'source_project_id') !== null) {
|
||||
$sourceProject = Project::query()->findOrFail((int) Arr::get($attributes, 'source_project_id'));
|
||||
|
||||
$this->duplicateProjectConfiguration->handle(
|
||||
$sourceProject,
|
||||
$project,
|
||||
array_values(Arr::get($attributes, 'copy_areas', [])),
|
||||
);
|
||||
}
|
||||
|
||||
if ($creationMode === 'template' && $sourceTemplate !== null) {
|
||||
$this->duplicateProjectConfiguration->apply(
|
||||
$project->fresh() ?? $project,
|
||||
$sourceTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
if ($baseTemplate !== null) {
|
||||
$baseTemplate->forceFill([
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
|
||||
$project->fresh() ?? $project,
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
),
|
||||
])->save();
|
||||
}
|
||||
|
||||
return $project->fresh() ?? $project;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
|
||||
class CreateProjectBaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(Project $project): ProjectTemplate
|
||||
{
|
||||
$copyAreas = DuplicateProjectConfiguration::copyableAreas();
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
'name' => "{$project->name} base template",
|
||||
'slug' => strtolower($project->key).'-base-template',
|
||||
'is_published' => false,
|
||||
'copy_areas' => $copyAreas,
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($project, $copyAreas),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class CreateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{name: string, slug: string, copy_areas?: array<int, string>} $attributes
|
||||
*/
|
||||
public function handle(Project $sourceProject, array $attributes): ProjectTemplate
|
||||
{
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
|
||||
return ProjectTemplate::query()->create([
|
||||
'project_id' => null,
|
||||
'source_project_id' => $sourceProject->id,
|
||||
'name' => $attributes['name'],
|
||||
'slug' => $attributes['slug'],
|
||||
'is_published' => true,
|
||||
'copy_areas' => $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas),
|
||||
'configuration_snapshot' => $this->duplicateProjectConfiguration->export($sourceProject, $copyAreas),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DuplicateProjectConfiguration
|
||||
{
|
||||
public const COPY_PROJECT_SETTINGS = 'project_settings';
|
||||
|
||||
public const COPY_BOARD_COLUMNS = 'board_columns';
|
||||
|
||||
public const COPY_TICKET_STATUSES = 'ticket_statuses';
|
||||
|
||||
public const COPY_STATUS_TRANSITIONS = 'status_transitions';
|
||||
|
||||
public const COPY_TICKET_TYPES = 'ticket_types';
|
||||
|
||||
public const COPY_TICKET_PRIORITIES = 'ticket_priorities';
|
||||
|
||||
public const COPY_TICKET_FIELDS = 'ticket_fields';
|
||||
|
||||
public const COPY_TICKET_LINK_TYPES = 'ticket_link_types';
|
||||
|
||||
public const COPY_REPOSITORY_CONNECTIONS = 'repository_connections';
|
||||
|
||||
public const COPY_NOTIFICATION_ACTIONS = 'notification_actions';
|
||||
|
||||
public const COPY_BOARD_WORKFLOW = 'board_workflow';
|
||||
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function copyableAreas(): array
|
||||
{
|
||||
return [
|
||||
self::COPY_PROJECT_SETTINGS,
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
self::COPY_TICKET_TYPES,
|
||||
self::COPY_TICKET_PRIORITIES,
|
||||
self::COPY_TICKET_FIELDS,
|
||||
self::COPY_TICKET_LINK_TYPES,
|
||||
self::COPY_REPOSITORY_CONNECTIONS,
|
||||
self::COPY_NOTIFICATION_ACTIONS,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function acceptedCopyAreas(): array
|
||||
{
|
||||
return [...self::copyableAreas(), self::COPY_BOARD_WORKFLOW];
|
||||
}
|
||||
|
||||
/** @return list<array{value: string, label: string, description: string}> */
|
||||
public static function copyAreaOptions(): array
|
||||
{
|
||||
return [
|
||||
['value' => self::COPY_PROJECT_SETTINGS, 'label' => 'Project settings', 'description' => 'General project preferences like story points and author visibility.'],
|
||||
['value' => self::COPY_BOARD_COLUMNS, 'label' => 'Board columns', 'description' => 'Columns and their order on the project board.'],
|
||||
['value' => self::COPY_TICKET_STATUSES, 'label' => 'Ticket statuses', 'description' => 'Statuses and their assignment to board columns.'],
|
||||
['value' => self::COPY_STATUS_TRANSITIONS, 'label' => 'Status transitions', 'description' => 'Allowed moves between ticket statuses.'],
|
||||
['value' => self::COPY_TICKET_TYPES, 'label' => 'Ticket types', 'description' => 'Available ticket types, including parent types.'],
|
||||
['value' => self::COPY_TICKET_PRIORITIES, 'label' => 'Ticket priorities', 'description' => 'Available ticket priorities.'],
|
||||
['value' => self::COPY_TICKET_FIELDS, 'label' => 'Custom ticket fields', 'description' => 'Custom field definitions without ticket values.'],
|
||||
['value' => self::COPY_TICKET_LINK_TYPES, 'label' => 'Ticket link types', 'description' => 'Relationship labels between tickets.'],
|
||||
['value' => self::COPY_REPOSITORY_CONNECTIONS, 'label' => 'Repository connections', 'description' => 'Repositories linked to the project.'],
|
||||
['value' => self::COPY_NOTIFICATION_ACTIONS, 'label' => 'Notification actions', 'description' => 'Notification rules and delivery settings.'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return list<string> */
|
||||
public function normalizeCopyAreas(array $copyAreas): array
|
||||
{
|
||||
if (in_array(self::COPY_BOARD_WORKFLOW, $copyAreas, true)) {
|
||||
$copyAreas = [
|
||||
...array_diff($copyAreas, [self::COPY_BOARD_WORKFLOW]),
|
||||
self::COPY_BOARD_COLUMNS,
|
||||
self::COPY_TICKET_STATUSES,
|
||||
self::COPY_STATUS_TRANSITIONS,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(array_intersect(self::copyableAreas(), array_unique($copyAreas)));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
public function normalizeConfiguration(array $configuration): array
|
||||
{
|
||||
$workflow = $configuration[self::COPY_BOARD_WORKFLOW] ?? null;
|
||||
|
||||
if (is_array($workflow)) {
|
||||
$statuses = $workflow['ticket_statuses'] ?? [];
|
||||
$configuration[self::COPY_BOARD_COLUMNS] ??= $workflow['board_columns'] ?? [];
|
||||
$configuration[self::COPY_TICKET_STATUSES] ??= array_map(
|
||||
static fn (array $status): array => array_diff_key($status, ['allowed_target_indexes' => true]),
|
||||
$statuses,
|
||||
);
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] ??= array_map(
|
||||
static fn (array $status): array => $status['allowed_target_indexes'] ?? [],
|
||||
$statuses,
|
||||
);
|
||||
unset($configuration[self::COPY_BOARD_WORKFLOW]);
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas @return array<string, mixed> */
|
||||
public function export(Project $sourceProject, array $copyAreas): array
|
||||
{
|
||||
$selectedAreas = $this->normalizeCopyAreas($copyAreas);
|
||||
|
||||
if ($selectedAreas === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sourceProject->load([
|
||||
'boardColumns', 'notificationActions', 'repositoryConnections', 'ticketFieldDefinitions',
|
||||
'ticketLinkTypes', 'ticketPriorities', 'ticketStatuses.allowedTargetStatuses', 'ticketTypes',
|
||||
]);
|
||||
$configuration = [];
|
||||
|
||||
if (in_array(self::COPY_PROJECT_SETTINGS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_PROJECT_SETTINGS] = [
|
||||
'show_author' => $sourceProject->show_author,
|
||||
'show_story_points' => $sourceProject->show_story_points,
|
||||
];
|
||||
}
|
||||
|
||||
$columnIndexes = $sourceProject->boardColumns->values()->mapWithKeys(
|
||||
static fn ($column, int $index): array => [$column->id => $index],
|
||||
);
|
||||
$statusIndexes = $sourceProject->ticketStatuses->values()->mapWithKeys(
|
||||
static fn ($status, int $index): array => [$status->id => $index],
|
||||
);
|
||||
|
||||
if (in_array(self::COPY_BOARD_COLUMNS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_BOARD_COLUMNS] = $sourceProject->boardColumns->values()->map(
|
||||
static fn ($column): array => ['name' => $column->name, 'position' => $column->position],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_TICKET_STATUSES, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_TICKET_STATUSES] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => [
|
||||
'board_column_index' => $status->board_column_id === null ? null : $columnIndexes->get($status->board_column_id),
|
||||
'name' => $status->name,
|
||||
'position' => $status->position,
|
||||
],
|
||||
)->all();
|
||||
}
|
||||
|
||||
if (in_array(self::COPY_STATUS_TRANSITIONS, $selectedAreas, true)) {
|
||||
$configuration[self::COPY_STATUS_TRANSITIONS] = $sourceProject->ticketStatuses->values()->map(
|
||||
static fn ($status): array => $status->allowedTargetStatuses->pluck('id')->map(
|
||||
static fn (int $id): ?int => $statusIndexes->get($id),
|
||||
)->filter(static fn (?int $index): bool => $index !== null)->values()->all(),
|
||||
)->all();
|
||||
}
|
||||
|
||||
$simpleAreas = [
|
||||
self::COPY_TICKET_TYPES => fn ($item): array => ['name' => $item->name, 'position' => $item->position, 'is_parent' => $item->is_parent],
|
||||
self::COPY_TICKET_PRIORITIES => fn ($item): array => ['name' => $item->name, 'position' => $item->position],
|
||||
self::COPY_TICKET_FIELDS => fn ($item): array => ['field_type' => $item->field_type, 'label' => $item->label, 'position' => $item->position],
|
||||
self::COPY_TICKET_LINK_TYPES => fn ($item): array => ['label' => $item->label, 'counterpart_label' => $item->counterpart_label, 'position' => $item->position],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => fn ($item): array => [
|
||||
'provider' => $item->provider->value, 'base_url' => $item->base_url,
|
||||
'provider_repository_id' => $item->provider_repository_id, 'owner_name' => $item->owner_name,
|
||||
'repository_name' => $item->repository_name, 'repository_full_name' => $item->repository_full_name,
|
||||
'repository_url' => $item->repository_url,
|
||||
],
|
||||
self::COPY_NOTIFICATION_ACTIONS => fn ($item): array => [
|
||||
'name' => $item->name, 'enabled' => $item->enabled, 'event_type' => $item->event_type->value,
|
||||
'channel' => $item->channel->value, 'channel_configuration' => $item->channel_configuration ?? [],
|
||||
'message_template' => $item->message_template, 'delivery_url' => $item->delivery_url,
|
||||
'filters' => $item->filters ?? [],
|
||||
],
|
||||
];
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => 'ticketTypes', self::COPY_TICKET_PRIORITIES => 'ticketPriorities',
|
||||
self::COPY_TICKET_FIELDS => 'ticketFieldDefinitions', self::COPY_TICKET_LINK_TYPES => 'ticketLinkTypes',
|
||||
self::COPY_REPOSITORY_CONNECTIONS => 'repositoryConnections', self::COPY_NOTIFICATION_ACTIONS => 'notificationActions',
|
||||
];
|
||||
|
||||
foreach ($simpleAreas as $area => $mapper) {
|
||||
if (in_array($area, $selectedAreas, true)) {
|
||||
$configuration[$area] = $sourceProject->{$relations[$area]}->values()->map($mapper)->all();
|
||||
}
|
||||
}
|
||||
|
||||
return $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $configuration
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
* @return list<string> areas which retained referenced local values
|
||||
*/
|
||||
public function apply(Project $targetProject, array $configuration, array $statusReplacements = []): array
|
||||
{
|
||||
$configuration = $this->normalizeConfiguration($configuration);
|
||||
|
||||
$blockedAreas = $this->projectTemplateOverrideManager->withoutRecording(function () use ($targetProject, $configuration, $statusReplacements): array {
|
||||
$blockedAreas = [];
|
||||
|
||||
if (isset($configuration[self::COPY_PROJECT_SETTINGS])) {
|
||||
$targetProject->forceFill($configuration[self::COPY_PROJECT_SETTINGS])->saveQuietly();
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_BOARD_COLUMNS])) {
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->boardColumns()->get(),
|
||||
$configuration[self::COPY_BOARD_COLUMNS],
|
||||
fn (array $item) => $targetProject->boardColumns()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
static fn (Model $column): bool => ! $column->ticketStatuses()->exists(),
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_BOARD_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_TICKET_STATUSES])) {
|
||||
$columns = $targetProject->boardColumns()->get()->values();
|
||||
$items = collect($configuration[self::COPY_TICKET_STATUSES])->map(static function (array $item) use ($columns): array {
|
||||
$columnIndex = $item['board_column_index'] ?? null;
|
||||
$item['board_column_id'] = $columnIndex === null ? null : $columns->get($columnIndex)?->id;
|
||||
unset($item['board_column_index']);
|
||||
|
||||
return $item;
|
||||
})->all();
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->ticketStatuses()->get(),
|
||||
$items,
|
||||
fn (array $item) => $targetProject->ticketStatuses()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
function (Model $status) use ($statusReplacements): bool {
|
||||
if (! $status->tickets()->exists()) {
|
||||
return true;
|
||||
}
|
||||
$replacementId = $statusReplacements[$status->id] ?? null;
|
||||
if ($replacementId === null) {
|
||||
return false;
|
||||
}
|
||||
$status->tickets()->update(['ticket_status_id' => $replacementId]);
|
||||
|
||||
return true;
|
||||
},
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = self::COPY_TICKET_STATUSES;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($configuration[self::COPY_STATUS_TRANSITIONS])) {
|
||||
$statuses = $targetProject->ticketStatuses()->get()->values();
|
||||
foreach ($configuration[self::COPY_STATUS_TRANSITIONS] as $index => $targets) {
|
||||
$status = $statuses->get($index);
|
||||
if ($status !== null) {
|
||||
$status->allowedTargetStatuses()->sync(collect($targets)->map(fn (int $target): ?int => $statuses->get($target)?->id)->filter()->all());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$relations = [
|
||||
self::COPY_TICKET_TYPES => ['ticketTypes', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_PRIORITIES => ['ticketPriorities', fn (Model $item): bool => ! $item->tickets()->exists()],
|
||||
self::COPY_TICKET_FIELDS => ['ticketFieldDefinitions', fn (Model $item): bool => ! $item->ticketFieldValues()->exists()],
|
||||
self::COPY_TICKET_LINK_TYPES => ['ticketLinkTypes', fn (Model $item): bool => ! $item->ticketLinks()->exists()],
|
||||
self::COPY_REPOSITORY_CONNECTIONS => ['repositoryConnections', null],
|
||||
self::COPY_NOTIFICATION_ACTIONS => ['notificationActions', null],
|
||||
];
|
||||
|
||||
foreach ($relations as $area => [$relation, $canDelete]) {
|
||||
if (! isset($configuration[$area])) {
|
||||
continue;
|
||||
}
|
||||
$blocked = $this->syncModels(
|
||||
$targetProject->{$relation}()->get(),
|
||||
$configuration[$area],
|
||||
fn (array $item) => $targetProject->{$relation}()->create($item),
|
||||
static fn (Model $model, array $item) => $model->forceFill($item)->saveQuietly(),
|
||||
$canDelete,
|
||||
);
|
||||
if ($blocked) {
|
||||
$blockedAreas[] = $area;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($targetProject->configurationRelations() as $relation) {
|
||||
$targetProject->{$relation}()->update(['project_template_id' => $targetProject->project_template_id]);
|
||||
}
|
||||
|
||||
return array_values(array_unique($blockedAreas));
|
||||
});
|
||||
|
||||
foreach ($blockedAreas as $area) {
|
||||
$this->projectTemplateOverrideManager->mark($targetProject->fresh(), $area);
|
||||
}
|
||||
|
||||
return $blockedAreas;
|
||||
}
|
||||
|
||||
/** @param list<string> $copyAreas */
|
||||
public function handle(Project $sourceProject, Project $targetProject, array $copyAreas): void
|
||||
{
|
||||
$this->apply($targetProject, $this->export($sourceProject, $copyAreas));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Model> $models
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function syncModels(EloquentCollection $models, array $items, callable $create, callable $update, ?callable $canDelete = null): bool
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
$model = $models->get($index);
|
||||
$model === null ? $create($item) : $update($model, $item);
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
foreach ($models->slice(count($items)) as $model) {
|
||||
if ($canDelete !== null && ! $canDelete($model)) {
|
||||
$blocked = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
$model->deleteQuietly();
|
||||
}
|
||||
|
||||
return $blocked;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class ProjectTemplateOverrideManager
|
||||
{
|
||||
private static int $suppressionDepth = 0;
|
||||
|
||||
public function mark(Project $project, string $area): void
|
||||
{
|
||||
if (self::$suppressionDepth > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || ! $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$overrides = array_values(array_unique([
|
||||
...($project->template_overrides ?? []),
|
||||
$area,
|
||||
]));
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
*/
|
||||
public function clear(Project $project, array $areas): void
|
||||
{
|
||||
$remaining = array_values(array_diff($project->template_overrides ?? [], $areas));
|
||||
$project->forceFill(['template_overrides' => $remaining])->saveQuietly();
|
||||
}
|
||||
|
||||
public function withoutRecording(callable $callback): mixed
|
||||
{
|
||||
self::$suppressionDepth++;
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
self::$suppressionDepth--;
|
||||
}
|
||||
}
|
||||
|
||||
public function isRecording(): bool
|
||||
{
|
||||
return self::$suppressionDepth === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
class RefreshProjectBaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
public function handle(Project $project, string $area): void
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
if ($project->projectTemplate === null || $project->projectTemplate->is_published) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshot = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$project->projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
|
||||
$project->projectTemplate->forceFill([
|
||||
'configuration_snapshot' => array_replace(
|
||||
$snapshot,
|
||||
$this->duplicateProjectConfiguration->export($project->fresh(), [$area]),
|
||||
),
|
||||
])->saveQuietly();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
|
||||
class ResolveProjectTemplateState
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $areas
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function handle(Project $project, ?array $areas = null): ?array
|
||||
{
|
||||
$project->loadMissing('projectTemplate');
|
||||
|
||||
/** @var ProjectTemplate|null $projectTemplate */
|
||||
$projectTemplate = $project->projectTemplate;
|
||||
|
||||
if ($projectTemplate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$selectedAreas = $areas === null
|
||||
? DuplicateProjectConfiguration::copyableAreas()
|
||||
: $this->duplicateProjectConfiguration->normalizeCopyAreas($areas);
|
||||
|
||||
$normalizedTemplateConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateConfiguration = collect($normalizedTemplateConfiguration)
|
||||
->only($selectedAreas)
|
||||
->all();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($project, $selectedAreas);
|
||||
$persistedOverrides = $projectTemplate->is_published
|
||||
? array_values(array_intersect($project->template_overrides ?? [], $selectedAreas))
|
||||
: [];
|
||||
|
||||
$overriddenAreas = [];
|
||||
$inheritedAreas = [];
|
||||
|
||||
foreach ($selectedAreas as $area) {
|
||||
$label = $this->labelForArea($area);
|
||||
$hasTemplateValue = array_key_exists($area, $templateConfiguration);
|
||||
|
||||
if (! $hasTemplateValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! in_array($area, $persistedOverrides, true)) {
|
||||
$inheritedAreas[] = [
|
||||
'label' => $label,
|
||||
'value' => $area,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$overriddenAreas[] = [
|
||||
'label' => $label,
|
||||
'value' => $area,
|
||||
];
|
||||
}
|
||||
|
||||
$overriddenAreaValues = array_flip(array_column($overriddenAreas, 'value'));
|
||||
$inheritedAreaValues = array_flip(array_column($inheritedAreas, 'value'));
|
||||
|
||||
return [
|
||||
'effectiveConfiguration' => array_replace($templateConfiguration, array_intersect_key($currentConfiguration, $overriddenAreaValues)),
|
||||
'inheritedAreas' => $inheritedAreas,
|
||||
'inheritedConfiguration' => array_intersect_key($templateConfiguration, $inheritedAreaValues),
|
||||
'inheritsFromTemplate' => $projectTemplate->is_published,
|
||||
'overriddenAreas' => $overriddenAreas,
|
||||
'overriddenConfiguration' => array_intersect_key($currentConfiguration, $overriddenAreaValues),
|
||||
'resetRequirements' => $this->resetRequirements($project, $templateConfiguration, $persistedOverrides),
|
||||
'templateConfiguration' => $templateConfiguration,
|
||||
'templateId' => $projectTemplate->id,
|
||||
'templateName' => $projectTemplate->name,
|
||||
'templateSlug' => $projectTemplate->slug,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $templateConfiguration
|
||||
* @param list<string> $persistedOverrides
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resetRequirements(Project $project, array $templateConfiguration, array $persistedOverrides): array
|
||||
{
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $persistedOverrides, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$templateStatuses = $templateConfiguration[DuplicateProjectConfiguration::COPY_TICKET_STATUSES] ?? [];
|
||||
if (! is_array($templateStatuses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$replacementOptions = $statuses->take(count($templateStatuses))->map(
|
||||
static fn ($status): array => ['id' => $status->id, 'name' => $status->name],
|
||||
)->all();
|
||||
$removedStatuses = $statuses->slice(count($templateStatuses))
|
||||
->filter(static fn ($status): bool => $status->tickets_count > 0)
|
||||
->map(static fn ($status): array => [
|
||||
'id' => $status->id,
|
||||
'name' => $status->name,
|
||||
'ticketCount' => $status->tickets_count,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $removedStatuses === [] ? [] : [
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES => [
|
||||
'removedStatuses' => $removedStatuses,
|
||||
'replacementOptions' => $replacementOptions,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function labelForArea(string $area): string
|
||||
{
|
||||
foreach (DuplicateProjectConfiguration::copyAreaOptions() as $option) {
|
||||
if ($option['value'] === $area) {
|
||||
return $option['label'];
|
||||
}
|
||||
}
|
||||
|
||||
return $area;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTemplate
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{name: string, copy_areas?: array<int, string>} $attributes
|
||||
*/
|
||||
public function handle(ProjectTemplate $projectTemplate, array $attributes): ProjectTemplate
|
||||
{
|
||||
$projectTemplate->loadMissing('sourceProject');
|
||||
|
||||
$sourceProject = $projectTemplate->sourceProject;
|
||||
abort_if($sourceProject === null, 422, 'This template cannot be edited because the source project is missing.');
|
||||
|
||||
$copyAreas = array_values(Arr::get($attributes, 'copy_areas', []));
|
||||
$normalizedCopyAreas = $this->duplicateProjectConfiguration->normalizeCopyAreas($copyAreas);
|
||||
$previousConfiguration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$projectTemplate->configuration_snapshot ?? [],
|
||||
);
|
||||
$nextConfiguration = $this->duplicateProjectConfiguration->export($sourceProject, $normalizedCopyAreas);
|
||||
$affectedAreas = collect(array_unique([...array_keys($previousConfiguration), ...array_keys($nextConfiguration)]))
|
||||
->filter(fn (string $area): bool => ($previousConfiguration[$area] ?? null) !== ($nextConfiguration[$area] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
DB::transaction(function () use ($projectTemplate, $attributes, $normalizedCopyAreas, $nextConfiguration, $previousConfiguration, $affectedAreas): void {
|
||||
ProjectTemplate::query()->whereKey($projectTemplate->id)->lockForUpdate()->firstOrFail();
|
||||
|
||||
$projectTemplate->forceFill([
|
||||
'name' => $attributes['name'],
|
||||
'copy_areas' => $normalizedCopyAreas,
|
||||
'configuration_snapshot' => $nextConfiguration,
|
||||
])->save();
|
||||
|
||||
Project::query()
|
||||
->where('project_template_id', $projectTemplate->id)
|
||||
->orderBy('id')
|
||||
->chunkById(100, function ($linkedProjects) use ($affectedAreas, $nextConfiguration, $previousConfiguration): void {
|
||||
foreach ($linkedProjects as $linkedProject) {
|
||||
$linkedProject = Project::query()->whereKey($linkedProject->id)->lockForUpdate()->firstOrFail();
|
||||
$currentConfiguration = $this->duplicateProjectConfiguration->export($linkedProject, $affectedAreas);
|
||||
$overrides = $linkedProject->template_overrides ?? [];
|
||||
$replacementConfiguration = [];
|
||||
|
||||
foreach ($affectedAreas as $area) {
|
||||
if (in_array($area, $overrides, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $nextConfiguration)) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($area, $previousConfiguration)
|
||||
&& array_key_exists($area, $currentConfiguration)
|
||||
&& $currentConfiguration[$area] !== []) {
|
||||
$this->projectTemplateOverrideManager->mark($linkedProject, $area);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementConfiguration[$area] = $nextConfiguration[$area];
|
||||
}
|
||||
|
||||
if ($replacementConfiguration !== []) {
|
||||
$this->duplicateProjectConfiguration->apply($linkedProject, $replacementConfiguration);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $projectTemplate->fresh(['sourceProject'])->loadCount('linkedProjects');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Projects;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdateProjectTemplateLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
) {}
|
||||
|
||||
public function switch(Project $project, ProjectTemplate $template): void
|
||||
{
|
||||
DB::transaction(function () use ($project, $template): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$localAreas = array_values(array_diff(
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
array_keys($configuration),
|
||||
));
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $template->id,
|
||||
'template_overrides' => $localAreas,
|
||||
])->saveQuietly();
|
||||
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply($project, $configuration);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $areas
|
||||
* @param array<int|string, int|string> $statusReplacements
|
||||
*/
|
||||
public function reset(Project $project, array $areas, array $statusReplacements = []): void
|
||||
{
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
if ($template === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($project, $template, $areas, $statusReplacements): void {
|
||||
$project = Project::query()->whereKey($project->id)->lockForUpdate()->firstOrFail();
|
||||
$configuration = collect($this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
))->only($areas)->all();
|
||||
$blockedAreas = $this->duplicateProjectConfiguration->apply(
|
||||
$project,
|
||||
$configuration,
|
||||
$statusReplacements,
|
||||
);
|
||||
$this->projectTemplateOverrideManager->clear(
|
||||
$project->fresh(),
|
||||
array_values(array_diff(array_keys($configuration), $blockedAreas)),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ namespace App\Actions\Repositories;
|
||||
|
||||
use App\Actions\Git\Data\GitRepository;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Http\Resources\AvailableGitRepositorySourceResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\RepositoryConnectionResource;
|
||||
@@ -18,6 +20,7 @@ class BuildProjectRepositorySettingsPayload
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,6 +44,9 @@ class BuildProjectRepositorySettingsPayload
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'repositoryConnections' => RepositoryConnectionResource::collection($repositoryConnections)->resolve(),
|
||||
'status' => $status,
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project, [
|
||||
DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,9 @@ class CreateProjectTicket
|
||||
$nextSequenceNumber = (int) Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->max('sequence_number') + 1;
|
||||
$ticketStatus = TicketStatus::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
$ticketStatusQuery = TicketStatus::query()->where('project_id', $project->id);
|
||||
|
||||
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
|
||||
|
||||
$ticket = Ticket::query()->create([
|
||||
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
|
||||
|
||||
@@ -30,10 +30,9 @@ class UpdateUserAvatar
|
||||
$color = $this->colorFor($user);
|
||||
$path = sprintf('avatars/generated/%d-%s.svg', $user->id, (string) Str::uuid());
|
||||
|
||||
$stored = Storage::disk($this->avatarDisk())->put(
|
||||
$stored = Storage::disk('public')->put(
|
||||
$path,
|
||||
$this->svg($this->initials($user->name), $color),
|
||||
['ContentType' => 'image/svg+xml'],
|
||||
);
|
||||
|
||||
if (! $stored) {
|
||||
@@ -54,7 +53,7 @@ class UpdateUserAvatar
|
||||
public function upload(User $user, UploadedFile $avatar): User
|
||||
{
|
||||
$oldPath = $user->avatar_path;
|
||||
$path = $avatar->store('avatars/uploads', $this->avatarDisk());
|
||||
$path = $avatar->store('avatars/uploads', 'public');
|
||||
|
||||
if (! is_string($path)) {
|
||||
throw new RuntimeException('Uploaded avatar could not be stored.');
|
||||
@@ -91,14 +90,7 @@ class UpdateUserAvatar
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk($this->avatarDisk())->delete($path);
|
||||
}
|
||||
|
||||
private function avatarDisk(): string
|
||||
{
|
||||
$disk = config('filesystems.avatar_disk', 'public');
|
||||
|
||||
return is_string($disk) && $disk !== '' ? $disk : 'public';
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function initials(string $name): string
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
#[Signature('app:create-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
|
||||
@@ -26,30 +25,20 @@ class CreateAdminUser extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
DB::beginTransaction();
|
||||
$user = User::create([
|
||||
'name' => $this->argument('name'),
|
||||
'email' => $this->argument('email'),
|
||||
'password' => Hash::make($this->argument('password')),
|
||||
]);
|
||||
|
||||
try {
|
||||
$user = User::create([
|
||||
'name' => $this->argument('name'),
|
||||
'email' => $this->argument('email'),
|
||||
'password' => Hash::make($this->argument('password')),
|
||||
]);
|
||||
$this->avatars->generate($user);
|
||||
|
||||
$this->avatars->generate($user);
|
||||
$user->permissionAssignments()->create([
|
||||
'permission' => Permission::GlobalAdmin,
|
||||
]);
|
||||
|
||||
$user->permissionAssignments()->create([
|
||||
'permission' => Permission::GlobalAdmin,
|
||||
]);
|
||||
$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;
|
||||
} catch (\Exception $exception) {
|
||||
$this->error('Admin User could not be created: ', $exception->getMessage());
|
||||
DB::rollBack();
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,23 @@ use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\BuildProjectBacklogPayload;
|
||||
use App\Actions\Projects\CreateProject;
|
||||
use App\Actions\Projects\DeleteProject;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ListVisibleProjects;
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use App\Actions\Projects\UpdateProjectTemplateLink;
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\DeleteProjectRequest;
|
||||
use App\Http\Requests\ResetProjectTemplateRequest;
|
||||
use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\SwitchProjectTemplateRequest;
|
||||
use App\Http\Requests\SyncProjectPermissionsRequest;
|
||||
use App\Http\Resources\PermissionOptionResource;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
use App\Http\Resources\TeamResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -30,8 +37,11 @@ class ProjectController extends Controller
|
||||
private readonly BuildProjectBacklogPayload $buildProjectBacklogPayload,
|
||||
private readonly CreateProject $createProject,
|
||||
private readonly DeleteProject $deleteProject,
|
||||
private readonly DuplicateProjectConfiguration $duplicateProjectConfiguration,
|
||||
private readonly ListVisibleProjects $listVisibleProjects,
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly ResolveProjectTemplateState $resolveProjectTemplateState,
|
||||
private readonly UpdateProjectTemplateLink $updateProjectTemplateLink,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -51,7 +61,23 @@ class ProjectController extends Controller
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/Create');
|
||||
$sourceTemplates = ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
$selectedTemplateId = $request->integer('template');
|
||||
|
||||
return Inertia::render('projects/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'sourceProjects' => ProjectResource::collection(
|
||||
$this->listVisibleProjects->handle($request->user()),
|
||||
)->resolve(),
|
||||
'sourceTemplates' => ProjectTemplateResource::collection($sourceTemplates)->resolve(),
|
||||
'selectedSourceTemplateId' => $sourceTemplates->contains('id', $selectedTemplateId)
|
||||
? $selectedTemplateId
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreProjectRequest $request): RedirectResponse
|
||||
@@ -73,6 +99,7 @@ class ProjectController extends Controller
|
||||
return Inertia::render('projects/Show', [
|
||||
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -95,10 +122,58 @@ class ProjectController extends Controller
|
||||
{
|
||||
return Inertia::render('projects/settings/General', [
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
'sourceTemplates' => ProjectTemplateResource::collection(
|
||||
ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
'status' => session('status'),
|
||||
'templateState' => $this->resolveProjectTemplateState->handle($project),
|
||||
]);
|
||||
}
|
||||
|
||||
public function switchTemplate(SwitchProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$template = ProjectTemplate::query()
|
||||
->published()
|
||||
->findOrFail((int) Arr::get($validated, 'source_template_id'));
|
||||
|
||||
$this->updateProjectTemplateLink->switch($project, $template);
|
||||
|
||||
return to_route('projects.settings.general', $project)
|
||||
->with('status', "{$project->name} now uses the {$template->name} template.");
|
||||
}
|
||||
|
||||
public function resetTemplate(ResetProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$template = $project->projectTemplate;
|
||||
|
||||
abort_if($template === null, 422, 'This project has no linked template to reset.');
|
||||
|
||||
$configuration = $this->duplicateProjectConfiguration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(Arr::get($validated, 'areas', array_keys($configuration)));
|
||||
$statusReplacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
$this->updateProjectTemplateLink->reset(
|
||||
$project->load('projectTemplate'),
|
||||
$areas,
|
||||
$statusReplacements,
|
||||
);
|
||||
|
||||
$status = Arr::has($validated, 'areas')
|
||||
? "Selected settings were reset to the {$template->name} template."
|
||||
: "{$project->name} was reset to the {$template->name} template baseline.";
|
||||
|
||||
return back()->with('status', $status);
|
||||
}
|
||||
|
||||
public function access(Request $request, Project $project): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Projects\CreateProjectTemplate;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\UpdateProjectTemplate;
|
||||
use App\Http\Requests\StoreProjectTemplateRequest;
|
||||
use App\Http\Requests\UpdateProjectTemplateRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Http\Resources\ProjectTemplateResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProjectTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CreateProjectTemplate $createProjectTemplate,
|
||||
private readonly UpdateProjectTemplate $updateProjectTemplate,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('project-templates/Index', [
|
||||
'status' => session('status'),
|
||||
'templates' => ProjectTemplateResource::collection(
|
||||
ProjectTemplate::query()
|
||||
->published()
|
||||
->with('sourceProject')
|
||||
->withCount('linkedProjects')
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(ProjectTemplate $projectTemplate): Response
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$projectTemplate->load('sourceProject')->loadCount('linkedProjects');
|
||||
|
||||
return Inertia::render('project-templates/Edit', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'template' => ProjectTemplateResource::make($projectTemplate)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Project $project): Response
|
||||
{
|
||||
return Inertia::render('project-templates/Create', [
|
||||
'copyAreaOptions' => DuplicateProjectConfiguration::copyAreaOptions(),
|
||||
'project' => ProjectResource::make($project)->resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreProjectTemplateRequest $request, Project $project): RedirectResponse
|
||||
{
|
||||
$template = $this->createProjectTemplate->handle($project, $request->validated());
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$template->name} was created.");
|
||||
}
|
||||
|
||||
public function update(UpdateProjectTemplateRequest $request, ProjectTemplate $projectTemplate): RedirectResponse
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$template = $this->updateProjectTemplate->handle($projectTemplate, $request->validated());
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$template->name} was updated.");
|
||||
}
|
||||
|
||||
public function destroy(ProjectTemplate $projectTemplate): RedirectResponse
|
||||
{
|
||||
abort_if(! $projectTemplate->is_published, 404);
|
||||
|
||||
$templateName = $projectTemplate->name;
|
||||
$projectTemplate->delete();
|
||||
|
||||
return to_route('project-templates.index')
|
||||
->with('status', "Template {$templateName} was deleted.");
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ class DestroyProjectTicketTypeRequest extends FormRequest
|
||||
'replacement_ticket_type_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ class MoveProjectTicketRequest extends FormRequest
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class ResetProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'areas' => ['sometimes', 'array', 'min:1'],
|
||||
'areas.*' => ['required', 'string', Rule::in(DuplicateProjectConfiguration::copyableAreas())],
|
||||
'confirmation_key' => [
|
||||
'required_without:areas',
|
||||
'string',
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'status_replacements' => ['sometimes', 'array'],
|
||||
'status_replacements.*' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable(Validator): void> */
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
$validated = $validator->validated();
|
||||
$areas = array_values(Arr::get(
|
||||
$validated,
|
||||
'areas',
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
));
|
||||
|
||||
if (! in_array(DuplicateProjectConfiguration::COPY_TICKET_STATUSES, $areas, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->loadMissing('projectTemplate');
|
||||
$configuration = app(DuplicateProjectConfiguration::class)->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$templateStatuses = Arr::get(
|
||||
$configuration,
|
||||
DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
[],
|
||||
);
|
||||
$statuses = $project->ticketStatuses()->withCount('tickets')->get()->values();
|
||||
$allowedReplacementIds = $statuses->take(count($templateStatuses))->pluck('id');
|
||||
$replacements = Arr::get($validated, 'status_replacements', []);
|
||||
|
||||
foreach ($statuses->slice(count($templateStatuses)) as $status) {
|
||||
if ($status->tickets_count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacementId = (int) Arr::get($replacements, (string) $status->id, 0);
|
||||
if (! $allowedReplacementIds->contains($replacementId)) {
|
||||
$validator->errors()->add(
|
||||
"status_replacements.{$status->id}",
|
||||
"Choose a replacement status for {$status->name}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'confirmation_key.in' => 'Enter the exact project key to reset this project to its template baseline.',
|
||||
'areas.*.in' => 'Choose a valid template area to reset.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -20,12 +24,21 @@ class StoreProjectRequest extends FormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$key = Arr::get($this->all(), 'key');
|
||||
$creationMode = Arr::get($this->all(), 'creation_mode', 'scratch');
|
||||
$copyAreas = Arr::get($this->all(), 'copy_areas', []);
|
||||
|
||||
if ($key !== null) {
|
||||
$this->merge([
|
||||
'key' => strtoupper(trim((string) $key)),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'creation_mode' => is_string($creationMode) ? trim($creationMode) : 'scratch',
|
||||
'copy_areas' => is_array($copyAreas)
|
||||
? array_values($copyAreas)
|
||||
: ($copyAreas === null || $copyAreas === '' ? [] : [(string) $copyAreas]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +49,7 @@ class StoreProjectRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'creation_mode' => ['required', 'string', Rule::in(['scratch', 'duplicate', 'template'])],
|
||||
'key' => [
|
||||
'required',
|
||||
'string',
|
||||
@@ -44,6 +58,51 @@ class StoreProjectRequest extends FormRequest
|
||||
Rule::unique('projects', 'key'),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'source_project_id' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'duplicate'),
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('projects', 'id'),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = Project::query()->find($value);
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
if (! $permissionService->allows($this->user(), Permission::ProjectRead, $project)) {
|
||||
$fail('Select a project you can access as the duplication source.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'source_template_id' => [
|
||||
Rule::requiredIf(fn (): bool => $this->input('creation_mode') === 'template'),
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('project_templates', 'id')->where(fn ($query) => $query->where('is_published', true)),
|
||||
],
|
||||
'copy_areas' => ['nullable', 'array'],
|
||||
'copy_areas.*' => [
|
||||
'string',
|
||||
Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'source_project_id.required' => 'Select a source project to duplicate from.',
|
||||
'source_template_id.required' => 'Select a template to create the project from.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->filled('slug')) {
|
||||
$this->merge([
|
||||
'slug' => Str::slug((string) $this->input('slug')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('project_templates', 'slug')],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
@@ -22,9 +24,18 @@ class StoreProjectTicketBranchRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
||||
'repository_connection_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +66,23 @@ class StoreProjectTicketRequest extends FormRequest
|
||||
'ticket_priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_status_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
|
||||
@@ -38,7 +38,9 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
'ticket_link_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_link_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ class StoreTicketStatusRequest extends FormRequest
|
||||
'board_column_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
Rule::exists('board_columns', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('board_columns', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SwitchProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'confirmation_key' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([$project->key]),
|
||||
],
|
||||
'source_template_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists(ProjectTemplate::class, 'id')->where('is_published', true),
|
||||
Rule::notIn(array_filter([$project->project_template_id])),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'confirmation_key.in' => 'Enter the exact project key to confirm the template switch.',
|
||||
'source_template_id.not_in' => 'Choose a different published template to switch this project.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateProjectTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('create-project') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var ProjectTemplate|null $projectTemplate */
|
||||
$projectTemplate = $this->route('projectTemplate');
|
||||
$requiresImpactConfirmation = $projectTemplate?->linkedProjects()->exists() ?? false;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'copy_areas' => ['required', 'array', 'min:1'],
|
||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||
'confirm_linked_projects_update' => [
|
||||
Rule::requiredIf($requiresImpactConfirmation),
|
||||
'accepted',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'confirm_linked_projects_update.accepted' => 'Acknowledge the linked project warning before saving template changes.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,9 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'field_values.*.ticket_field_definition_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_field_definitions', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'field_values.*.value_number' => ['nullable', 'numeric'],
|
||||
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
|
||||
@@ -81,12 +83,16 @@ class UpdateProjectTicketRequest extends FormRequest
|
||||
'ticket_priority_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_priorities', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'ticket_type_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('ticket_types', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_types', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'story_points' => ['nullable', 'integer', 'min:0'],
|
||||
|
||||
@@ -34,7 +34,9 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
|
||||
'allowed_ticket_status_ids' => ['nullable', 'array'],
|
||||
'allowed_ticket_status_ids.*' => [
|
||||
'integer',
|
||||
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||
Rule::exists('ticket_statuses', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
{
|
||||
@@ -22,9 +24,18 @@ class ValidateProjectTicketBranchRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Project $project */
|
||||
$project = $this->route('project');
|
||||
|
||||
return [
|
||||
'branch_name' => ['required', 'string', 'max:255'],
|
||||
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
|
||||
'repository_connection_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('repository_connections', 'id')->where(
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Projects\ResolveProjectTemplateState;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ProjectResource extends JsonResource
|
||||
{
|
||||
@@ -14,10 +16,33 @@ class ProjectResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$this->resource->loadMissing('projectTemplate');
|
||||
|
||||
$templateState = app(ResolveProjectTemplateState::class)->handle($this->resource);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'key' => $this->key,
|
||||
'project_template_id' => $this->project_template_id,
|
||||
'project_template' => $this->projectTemplate === null
|
||||
? null
|
||||
: ProjectTemplateResource::make($this->projectTemplate)->resolve(),
|
||||
'template_state' => $templateState === null
|
||||
? null
|
||||
: [
|
||||
'effective_configuration' => Arr::get($templateState, 'effectiveConfiguration', []),
|
||||
'inherited_areas' => Arr::get($templateState, 'inheritedAreas', []),
|
||||
'inherited_configuration' => Arr::get($templateState, 'inheritedConfiguration', []),
|
||||
'inherits_from_template' => Arr::get($templateState, 'inheritsFromTemplate', false),
|
||||
'overridden_areas' => Arr::get($templateState, 'overriddenAreas', []),
|
||||
'overridden_configuration' => Arr::get($templateState, 'overriddenConfiguration', []),
|
||||
'reset_requirements' => Arr::get($templateState, 'resetRequirements', []),
|
||||
'template_configuration' => Arr::get($templateState, 'templateConfiguration', []),
|
||||
'template_id' => Arr::get($templateState, 'templateId'),
|
||||
'template_name' => Arr::get($templateState, 'templateName'),
|
||||
'template_slug' => Arr::get($templateState, 'templateSlug'),
|
||||
],
|
||||
'show_author' => $this->show_author,
|
||||
'show_story_points' => $this->show_story_points,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ProjectTemplateResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
if ($this->resource instanceof ProjectTemplate) {
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'project_id' => $this->project_id,
|
||||
'source_project_id' => $this->source_project_id,
|
||||
'source_project' => $this->relationLoaded('sourceProject') && $this->sourceProject !== null
|
||||
? ProjectResource::make($this->sourceProject)->resolve()
|
||||
: null,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'is_published' => $this->is_published,
|
||||
'copy_areas' => $configuration->normalizeCopyAreas($this->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$this->configuration_snapshot ?? [],
|
||||
),
|
||||
'linked_project_count' => $this->whenCounted('linkedProjects'),
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => Arr::get($this->resource, 'id'),
|
||||
'project_id' => Arr::get($this->resource, 'project_id'),
|
||||
'source_project_id' => Arr::get($this->resource, 'source_project_id'),
|
||||
'source_project' => Arr::get($this->resource, 'source_project'),
|
||||
'name' => Arr::get($this->resource, 'name'),
|
||||
'slug' => Arr::get($this->resource, 'slug'),
|
||||
'is_published' => Arr::get($this->resource, 'is_published', true),
|
||||
'copy_areas' => Arr::get($this->resource, 'copy_areas', []),
|
||||
'configuration_snapshot' => Arr::get($this->resource, 'configuration_snapshot', []),
|
||||
'linked_project_count' => Arr::get($this->resource, 'linked_project_count'),
|
||||
'created_at' => Arr::get($this->resource, 'created_at'),
|
||||
'updated_at' => Arr::get($this->resource, 'updated_at'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\BoardColumnFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
class BoardColumn extends Model
|
||||
{
|
||||
/** @use HasFactory<BoardColumnFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $boardColumn): void {
|
||||
$boardColumn->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -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\NotificationEventType;
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\NotificationActionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'message_template',
|
||||
'name',
|
||||
'project_id',
|
||||
'project_template_id',
|
||||
])]
|
||||
#[Hidden(['delivery_url'])]
|
||||
class NotificationAction extends Model
|
||||
{
|
||||
/** @use HasFactory<NotificationActionFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
|
||||
+30
-1
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['name', 'key', 'show_author', 'show_story_points'])]
|
||||
#[Fillable(['name', 'key', 'project_template_id', 'show_author', 'show_story_points', 'template_overrides'])]
|
||||
class Project extends Model
|
||||
{
|
||||
/** @use HasFactory<ProjectFactory> */
|
||||
@@ -22,6 +24,7 @@ class Project extends Model
|
||||
return [
|
||||
'show_author' => 'boolean',
|
||||
'show_story_points' => 'boolean',
|
||||
'template_overrides' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,6 +47,17 @@ class Project extends Model
|
||||
return $this->morphMany(PermissionAssignment::class, 'scope');
|
||||
}
|
||||
|
||||
public function projectTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProjectTemplate::class, 'project_template_id');
|
||||
}
|
||||
|
||||
public function baseTemplate(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProjectTemplate::class)
|
||||
->where('is_published', false);
|
||||
}
|
||||
|
||||
public function boardColumns(): HasMany
|
||||
{
|
||||
return $this->hasMany(BoardColumn::class)
|
||||
@@ -95,6 +109,21 @@ class Project extends Model
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function configurationRelations(): array
|
||||
{
|
||||
return [
|
||||
'boardColumns',
|
||||
'notificationActions',
|
||||
'repositoryConnections',
|
||||
'ticketFieldDefinitions',
|
||||
'ticketLinkTypes',
|
||||
'ticketPriorities',
|
||||
'ticketStatuses',
|
||||
'ticketTypes',
|
||||
];
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class)
|
||||
|
||||
@@ -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\GitProviderKey;
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\RepositoryConnectionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
'project_template_id',
|
||||
'provider',
|
||||
'provider_repository_id',
|
||||
'repository_full_name',
|
||||
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
|
||||
{
|
||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketFieldDefinitionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
|
||||
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketFieldDefinition extends Model
|
||||
{
|
||||
/**
|
||||
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
|
||||
|
||||
/** @use HasFactory<TicketFieldDefinitionFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketFieldDefinition): void {
|
||||
$ticketFieldDefinition->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketLinkTypeFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
|
||||
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketLinkType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketLinkTypeFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketLinkType): void {
|
||||
$ticketLinkType->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function counterpartLabel(): string
|
||||
{
|
||||
|
||||
@@ -2,20 +2,36 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketPriorityFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
|
||||
class TicketPriority extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketPriorityFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketPriority): void {
|
||||
$ticketPriority->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketStatusFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])]
|
||||
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
|
||||
class TicketStatus extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketStatusFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketStatus): void {
|
||||
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
|
||||
$ticketStatus->project_template_id = BoardColumn::query()
|
||||
->whereKey($ticketStatus->board_column_id)
|
||||
->value('project_template_id');
|
||||
}
|
||||
|
||||
$ticketStatus->syncProjectTemplateOwnership();
|
||||
$ticketStatus->slug = Str::slug($ticketStatus->name);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\SyncsProjectTemplateOwnership;
|
||||
use Database\Factories\TicketTypeFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
|
||||
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
|
||||
class TicketType extends Model
|
||||
{
|
||||
/** @use HasFactory<TicketTypeFactory> */
|
||||
use HasFactory;
|
||||
use SyncsProjectTemplateOwnership;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $ticketType): void {
|
||||
$ticketType->syncProjectTemplateOwnership();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
+3
-19
@@ -77,28 +77,12 @@ class User extends Authenticatable
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->morphMany(PermissionAssignment::class, 'assignee');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Actions\Projects\ProjectTemplateOverrideManager;
|
||||
use App\Actions\Projects\RefreshProjectBaseTemplate;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProjectConfigurationObserver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectTemplateOverrideManager $projectTemplateOverrideManager,
|
||||
private readonly RefreshProjectBaseTemplate $refreshProjectBaseTemplate,
|
||||
) {}
|
||||
|
||||
public function saved(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
public function deleted(Model $model): void
|
||||
{
|
||||
$this->markOverride($model);
|
||||
}
|
||||
|
||||
private function markOverride(Model $model): void
|
||||
{
|
||||
if (! $this->projectTemplateOverrideManager->isRecording()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project = $model->project()->first();
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$area = match (true) {
|
||||
$model instanceof BoardColumn => DuplicateProjectConfiguration::COPY_BOARD_COLUMNS,
|
||||
$model instanceof TicketStatus => DuplicateProjectConfiguration::COPY_TICKET_STATUSES,
|
||||
$model instanceof TicketType => DuplicateProjectConfiguration::COPY_TICKET_TYPES,
|
||||
$model instanceof TicketPriority => DuplicateProjectConfiguration::COPY_TICKET_PRIORITIES,
|
||||
$model instanceof TicketFieldDefinition => DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||
$model instanceof TicketLinkType => DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||
$model instanceof RepositoryConnection => DuplicateProjectConfiguration::COPY_REPOSITORY_CONNECTIONS,
|
||||
$model instanceof NotificationAction => DuplicateProjectConfiguration::COPY_NOTIFICATION_ACTIONS,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($area === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshProjectBaseTemplate->handle($project, $area);
|
||||
$this->projectTemplateOverrideManager->mark($project, $area);
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,18 @@ use App\Actions\Notifications\TicketCreated;
|
||||
use App\Actions\Notifications\TicketStatusChanged;
|
||||
use App\Actions\Permissions\PermissionService;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\BoardColumn;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\Team;
|
||||
use App\Models\TicketFieldDefinition;
|
||||
use App\Models\TicketLinkType;
|
||||
use App\Models\TicketPriority;
|
||||
use App\Models\TicketStatus;
|
||||
use App\Models\TicketType;
|
||||
use App\Models\User;
|
||||
use App\Observers\ProjectConfigurationObserver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
@@ -36,6 +45,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
foreach ([
|
||||
BoardColumn::class,
|
||||
NotificationAction::class,
|
||||
RepositoryConnection::class,
|
||||
TicketFieldDefinition::class,
|
||||
TicketLinkType::class,
|
||||
TicketPriority::class,
|
||||
TicketStatus::class,
|
||||
TicketType::class,
|
||||
] as $model) {
|
||||
$model::observe(ProjectConfigurationObserver::class);
|
||||
}
|
||||
|
||||
if ($this->app->environment() !== 'local') {
|
||||
URL::forceScheme('https');
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"skills": [
|
||||
"fortify-development",
|
||||
"laravel-best-practices",
|
||||
"mcp-development",
|
||||
"wayfinder-development",
|
||||
"inertia-vue-development",
|
||||
"tailwindcss-development"
|
||||
|
||||
@@ -17,8 +17,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
|
||||
+3
-4
@@ -12,11 +12,10 @@
|
||||
"php": "^8.4",
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/fortify": "^1.34",
|
||||
"laravel/framework": "^v13.26.1",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/mcp": "^0.6",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"scalar/laravel": "^0.2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -52,7 +51,7 @@
|
||||
],
|
||||
"dev": [
|
||||
"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": [
|
||||
"npm run build:ssr",
|
||||
@@ -67,8 +66,8 @@
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm audit --audit-level=high",
|
||||
"npm run lint:check",
|
||||
"npm run format:check",
|
||||
"npm run types:check",
|
||||
"@test"
|
||||
],
|
||||
|
||||
Generated
+332
-791
File diff suppressed because it is too large
Load Diff
@@ -15,12 +15,6 @@ return [
|
||||
|
||||
'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
|
||||
|
||||
@@ -23,6 +23,7 @@ class ProjectFactory extends Factory
|
||||
'key' => Str::upper(fake()->unique()->lexify('?????')),
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
'template_overrides' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<ProjectTemplate>
|
||||
*/
|
||||
class ProjectTemplateFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'source_project_id' => Project::factory(),
|
||||
'name' => Str::title(fake()->unique()->words(3, true)),
|
||||
'slug' => fake()->unique()->slug(),
|
||||
'copy_areas' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS],
|
||||
'configuration_snapshot' => [
|
||||
DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [
|
||||
'show_author' => true,
|
||||
'show_story_points' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_templates', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('source_project_id')->nullable()->constrained('projects')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->json('copy_areas');
|
||||
$table->json('configuration_snapshot');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['source_project_id', 'name']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('project_templates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\CreateProjectBaseTemplate;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('project_templates', function (Blueprint $table): void {
|
||||
$table->foreignId('project_id')->nullable()->after('id')->constrained('projects')->nullOnDelete();
|
||||
$table->boolean('is_published')->default(true)->after('slug');
|
||||
});
|
||||
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->foreignId('project_template_id')->nullable()->after('key')->constrained('project_templates')->nullOnDelete();
|
||||
});
|
||||
|
||||
$projects = Project::query()->whereNull('project_template_id')->get();
|
||||
$createProjectBaseTemplate = app(CreateProjectBaseTemplate::class);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$baseTemplate = $createProjectBaseTemplate->handle($project);
|
||||
|
||||
$project->forceFill([
|
||||
'project_template_id' => $baseTemplate->id,
|
||||
])->save();
|
||||
}
|
||||
|
||||
ProjectTemplate::query()
|
||||
->whereNull('project_id')
|
||||
->update(['is_published' => true]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Project::query()->update(['project_template_id' => null]);
|
||||
|
||||
ProjectTemplate::query()->where('is_published', false)->delete();
|
||||
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('project_template_id');
|
||||
});
|
||||
|
||||
Schema::table('project_templates', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('project_id');
|
||||
$table->dropColumn('is_published');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private array $tables = [
|
||||
'board_columns',
|
||||
'ticket_field_definitions',
|
||||
'ticket_link_types',
|
||||
'ticket_priorities',
|
||||
'ticket_statuses',
|
||||
'ticket_types',
|
||||
'repository_connections',
|
||||
'notification_actions',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->tables as $table) {
|
||||
Schema::table($table, function (Blueprint $table): void {
|
||||
$table->foreignId('project_template_id')
|
||||
->nullable()
|
||||
->after('project_id')
|
||||
->constrained('project_templates')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
$projects = DB::table('projects')
|
||||
->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id')
|
||||
->select([
|
||||
'projects.id',
|
||||
'projects.key',
|
||||
'projects.name',
|
||||
'projects.project_template_id',
|
||||
'project_templates.project_id as template_project_id',
|
||||
'project_templates.is_published as template_is_published',
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$projectTemplateId = $project->project_template_id;
|
||||
|
||||
if ($projectTemplateId === null || (
|
||||
! (bool) $project->template_is_published
|
||||
&& (int) ($project->template_project_id ?? 0) !== (int) $project->id
|
||||
)) {
|
||||
$projectTemplateId = DB::table('project_templates')->insertGetId([
|
||||
'project_id' => $project->id,
|
||||
'source_project_id' => $project->id,
|
||||
'name' => $project->name.' base template',
|
||||
'slug' => Str::lower($project->key).'-base-template',
|
||||
'is_published' => false,
|
||||
'copy_areas' => json_encode([
|
||||
'project_settings',
|
||||
'board_workflow',
|
||||
'ticket_types',
|
||||
'ticket_priorities',
|
||||
'ticket_fields',
|
||||
'ticket_link_types',
|
||||
'repository_connections',
|
||||
'notification_actions',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('projects')
|
||||
->where('id', $project->id)
|
||||
->update([
|
||||
'project_template_id' => $projectTemplateId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->tables as $table) {
|
||||
DB::table($table)
|
||||
->where('project_id', $project->id)
|
||||
->update(['project_template_id' => $projectTemplateId]);
|
||||
}
|
||||
|
||||
$projectModel = Project::query()->find($project->id);
|
||||
|
||||
if ($projectModel !== null && ! (bool) $project->template_is_published) {
|
||||
DB::table('project_templates')
|
||||
->where('id', $projectTemplateId)
|
||||
->update([
|
||||
'configuration_snapshot' => json_encode(
|
||||
app(DuplicateProjectConfiguration::class)->export(
|
||||
$projectModel,
|
||||
DuplicateProjectConfiguration::copyableAreas(),
|
||||
),
|
||||
JSON_THROW_ON_ERROR,
|
||||
),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (array_reverse($this->tables) as $table) {
|
||||
Schema::table($table, function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('project_template_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Projects\DuplicateProjectConfiguration;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectTemplate;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->json('template_overrides')->default('[]')->after('project_template_id');
|
||||
});
|
||||
|
||||
$configuration = app(DuplicateProjectConfiguration::class);
|
||||
|
||||
ProjectTemplate::query()->eachById(function (ProjectTemplate $template) use ($configuration): void {
|
||||
$template->forceFill([
|
||||
'copy_areas' => $configuration->normalizeCopyAreas($template->copy_areas ?? []),
|
||||
'configuration_snapshot' => $configuration->normalizeConfiguration(
|
||||
$template->configuration_snapshot ?? [],
|
||||
),
|
||||
])->saveQuietly();
|
||||
});
|
||||
|
||||
Project::query()
|
||||
->whereHas('projectTemplate', fn ($query) => $query->where('is_published', true))
|
||||
->with('projectTemplate')
|
||||
->eachById(function (Project $project) use ($configuration): void {
|
||||
$templateConfiguration = $configuration->normalizeConfiguration(
|
||||
$project->projectTemplate?->configuration_snapshot ?? [],
|
||||
);
|
||||
$areas = array_values(array_unique([
|
||||
...array_keys($templateConfiguration),
|
||||
...DuplicateProjectConfiguration::copyableAreas(),
|
||||
]));
|
||||
$currentConfiguration = $configuration->export($project, $areas);
|
||||
$overrides = collect($areas)
|
||||
->filter(fn (string $area): bool => ! array_key_exists($area, $templateConfiguration)
|
||||
|| ($currentConfiguration[$area] ?? null) !== $templateConfiguration[$area])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$project->forceFill(['template_overrides' => $overrides])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropColumn('template_overrides');
|
||||
});
|
||||
}
|
||||
};
|
||||
+12
-32
@@ -1,38 +1,33 @@
|
||||
services:
|
||||
web:
|
||||
container_name: kanbangit-web
|
||||
image: kanbangit-app:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
command: nginx
|
||||
dockerfile: ./docker/nginx/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- kanbangit-storage:/var/www/storage:ro
|
||||
- kanbangit-public-assets:/var/www/public/build:ro
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
PHP_FPM_UPSTREAM: php-fpm:9000
|
||||
networks:
|
||||
- kanbangit-web
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8080/up"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
depends_on:
|
||||
php-fpm:
|
||||
condition: service_healthy
|
||||
|
||||
php-fpm:
|
||||
container_name: kanbangit-php-fpm
|
||||
image: kanbangit-app:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: runtime
|
||||
dockerfile: ./docker/php-fpm/Dockerfile
|
||||
target: production
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- kanbangit-storage:/var/www/storage
|
||||
- kanbangit-public-assets:/var/www/public/build
|
||||
env_file:
|
||||
- .env.production
|
||||
networks:
|
||||
@@ -47,23 +42,6 @@ services:
|
||||
mariadb:
|
||||
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:
|
||||
container_name: kanbangit-db
|
||||
image: mariadb:11.4
|
||||
@@ -87,3 +65,5 @@ networks:
|
||||
|
||||
volumes:
|
||||
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/**',
|
||||
'public/build/**',
|
||||
'public/hot',
|
||||
'resources/js/actions/**',
|
||||
'resources/js/components/ui/**',
|
||||
'resources/js/routes/**',
|
||||
'resources/js/wayfinder/**',
|
||||
'resources/views/mail/**',
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+198
-497
File diff suppressed because it is too large
Load Diff
@@ -19,10 +19,7 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_KEY" value="base64:11ujGYe5vzbwqcdFDVQC6PuDgd7c8PKD9kWt9HH9Bvs="/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="AVATAR_FILESYSTEM_DISK" value="public"/>
|
||||
<env name="AVATAR_URLS_TEMPORARY" value="false"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Folder,
|
||||
FolderGit2,
|
||||
LayoutGrid,
|
||||
Layers3,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { dashboard } from '@/routes';
|
||||
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 teamsIndex } from '@/routes/teams';
|
||||
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) {
|
||||
items.push({
|
||||
title: 'Users',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
areas: string[];
|
||||
projectId: number;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
}>();
|
||||
|
||||
const form = useForm<{
|
||||
areas: string[];
|
||||
status_replacements: Record<string, number | undefined>;
|
||||
}>({
|
||||
areas: [],
|
||||
status_replacements: {},
|
||||
});
|
||||
|
||||
const activeArea = computed(() => form.areas[0]);
|
||||
|
||||
const inheritedAreas = computed(
|
||||
() =>
|
||||
props.templateState?.inheritedAreas.filter((area) =>
|
||||
props.areas.includes(area.value),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const overriddenAreas = computed(
|
||||
() =>
|
||||
props.templateState?.overriddenAreas.filter((area) =>
|
||||
props.areas.includes(area.value),
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
const statusRequirement = computed(
|
||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
||||
);
|
||||
|
||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
||||
if (area.value !== 'ticket_statuses') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
statusRequirement.value?.removedStatuses.every(
|
||||
(status) =>
|
||||
form.status_replacements[String(status.id)] !== undefined,
|
||||
) ?? true
|
||||
);
|
||||
};
|
||||
|
||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
||||
if (!canReset(area)) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.areas = [area.value];
|
||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => form.reset(),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
v-if="
|
||||
templateState?.inheritsFromTemplate &&
|
||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||
"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Template inheritance: {{ templateState.templateName }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Every setting area inherits independently. Local changes stay
|
||||
unchanged when the template is updated until that area is reset.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="area in inheritedAreas"
|
||||
:key="`inherited-${area.value}`"
|
||||
variant="outline"
|
||||
>
|
||||
{{ area.label }} · inherited
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="area in overriddenAreas"
|
||||
:key="`overridden-${area.value}`"
|
||||
class="space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Badge>{{ area.label }} · template overridden</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="form.processing || !canReset(area)"
|
||||
@click="resetArea(area)"
|
||||
>
|
||||
{{
|
||||
activeArea === area.value && form.processing
|
||||
? 'Resetting…'
|
||||
: 'Reset to template'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
||||
class="space-y-3"
|
||||
>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Choose where tickets in local statuses should move
|
||||
before those statuses are removed.
|
||||
</p>
|
||||
<div
|
||||
v-for="status in statusRequirement.removedStatuses"
|
||||
:key="status.id"
|
||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
||||
>
|
||||
<Label :for="`replacement-${status.id}`">
|
||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
||||
</Label>
|
||||
<div class="grid gap-1">
|
||||
<select
|
||||
:id="`replacement-${status.id}`"
|
||||
v-model="
|
||||
form.status_replacements[String(status.id)]
|
||||
"
|
||||
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<option :value="undefined" disabled>
|
||||
Select replacement status
|
||||
</option>
|
||||
<option
|
||||
v-for="option in statusRequirement.replacementOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
form.errors[
|
||||
`status_replacements.${status.id}`
|
||||
]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputError :message="form.errors.areas" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,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">
|
||||
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 Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
@@ -15,7 +16,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
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[] = [
|
||||
{
|
||||
@@ -27,6 +30,43 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
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>
|
||||
|
||||
<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">
|
||||
<Heading
|
||||
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>
|
||||
@@ -49,28 +89,82 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form
|
||||
v-bind="ProjectController.store.form()"
|
||||
class="space-y-6"
|
||||
v-slot="{ errors, processing }"
|
||||
>
|
||||
<form class="space-y-6" @submit.prevent="submit">
|
||||
<div class="grid gap-3">
|
||||
<Label>How do you want to start?</Label>
|
||||
<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">
|
||||
<Label for="name">Project name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
v-model="form.name"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Kanban foundation"
|
||||
/>
|
||||
<InputError :message="errors.name" />
|
||||
<InputError :message="form.errors.name" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="key">Project key</Label>
|
||||
<Input
|
||||
id="key"
|
||||
name="key"
|
||||
v-model="form.key"
|
||||
required
|
||||
maxlength="5"
|
||||
placeholder="CORE"
|
||||
@@ -78,15 +172,111 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Example ticket keys will look like
|
||||
<span class="text-foreground font-medium"
|
||||
>CORE-1</span
|
||||
>.
|
||||
<span class="text-foreground font-medium">CORE-1</span>.
|
||||
</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 class="flex items-center gap-3">
|
||||
<Button type="submit" :disabled="processing">
|
||||
<Button type="submit" :disabled="form.processing">
|
||||
Create project
|
||||
</Button>
|
||||
|
||||
@@ -94,7 +284,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
<Link :href="index()">Cancel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
notifications as notificationSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import { create as projectTemplateCreate } from '@/routes/projects/templates';
|
||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectShowPageProps>();
|
||||
@@ -94,12 +95,48 @@ const projectAreas = [
|
||||
</Link>
|
||||
</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">
|
||||
<Link :href="index()">Back to projects</Link>
|
||||
</Button>
|
||||
</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">
|
||||
<Button as-child>
|
||||
<Link :href="boardSettings(project.id)">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -138,6 +139,16 @@ const statusAllowsTarget = (
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<TemplateInheritancePanel
|
||||
:areas="[
|
||||
'board_columns',
|
||||
'ticket_statuses',
|
||||
'status_transitions',
|
||||
]"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow model</CardTitle>
|
||||
|
||||
@@ -43,20 +43,58 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const switchTemplateForm = useForm({
|
||||
confirmation_key: '',
|
||||
source_template_id: props.sourceTemplates.find(
|
||||
(template) => template.id !== props.project.project_template_id,
|
||||
)?.id,
|
||||
});
|
||||
|
||||
const deleteForm = useForm({
|
||||
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 isDeleteConfirmationValid = computed(
|
||||
() => deleteForm.confirmation_key === props.project.key,
|
||||
);
|
||||
|
||||
const submitSwitchTemplate = (): void => {
|
||||
if (!canSwitchTemplate.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchTemplateForm.post(
|
||||
ProjectController.switchTemplate.url(props.project.id),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const submitDelete = (): void => {
|
||||
if (!isDeleteConfirmationValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteForm.submit(ProjectController.destroy(props.project.id), {
|
||||
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
@@ -152,6 +190,153 @@ const submitDelete = (): void => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template baseline</CardTitle>
|
||||
<CardDescription>
|
||||
Switch this project to another published template. Use
|
||||
the inheritance panels to reset individual settings to
|
||||
the currently linked template.
|
||||
</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.'
|
||||
: '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">
|
||||
Switching templates can replace inherited board
|
||||
workflow, ticket metadata, repositories, and
|
||||
notifications.
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Form, Head, Link } from '@inertiajs/vue3';
|
||||
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -115,6 +116,18 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<TemplateInheritancePanel
|
||||
:areas="[
|
||||
'project_settings',
|
||||
'ticket_types',
|
||||
'ticket_priorities',
|
||||
'ticket_fields',
|
||||
'ticket_link_types',
|
||||
]"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
@@ -673,7 +686,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
required
|
||||
placeholder="Blocked by"
|
||||
/>
|
||||
<InputError :message="errors.counterpart_name" />
|
||||
<InputError
|
||||
:message="errors.counterpart_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :disabled="processing">
|
||||
@@ -719,7 +734,9 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
||||
<Input
|
||||
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
||||
name="counterpart_name"
|
||||
:default-value="ticketLinkType.counterpart_label"
|
||||
:default-value="
|
||||
ticketLinkType.counterpart_label
|
||||
"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, reactive } from 'vue';
|
||||
import ProjectNotificationSettingsController from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -89,7 +90,8 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
let filterStateCounter = 0;
|
||||
|
||||
const nextFilterStateId = (): string => `notification-filter-${filterStateCounter++}`;
|
||||
const nextFilterStateId = (): string =>
|
||||
`notification-filter-${filterStateCounter++}`;
|
||||
|
||||
const parseInFilterValues = (value: string): string[] =>
|
||||
value
|
||||
@@ -100,7 +102,8 @@ const parseInFilterValues = (value: string): string[] =>
|
||||
const supportsFilterValue = (operator: string): boolean =>
|
||||
!['exists', 'not_exists'].includes(operator);
|
||||
|
||||
const supportsArrayFilterValue = (operator: string): boolean => operator === 'in';
|
||||
const supportsArrayFilterValue = (operator: string): boolean =>
|
||||
operator === 'in';
|
||||
|
||||
const createFilter = (): NotificationFilterFormState => ({
|
||||
attribute: defaultFilterAttribute,
|
||||
@@ -159,8 +162,9 @@ const resetCreateFilters = (): void => {
|
||||
createFilterState.filters = [];
|
||||
};
|
||||
|
||||
const editFilters = (notificationActionId: number): NotificationFilterFormState[] =>
|
||||
editFilterState[notificationActionId] ?? [];
|
||||
const editFilters = (
|
||||
notificationActionId: number,
|
||||
): NotificationFilterFormState[] => editFilterState[notificationActionId] ?? [];
|
||||
|
||||
const addEditFilter = (notificationActionId: number): void => {
|
||||
editFilterState[notificationActionId] = [
|
||||
@@ -169,10 +173,13 @@ const addEditFilter = (notificationActionId: number): void => {
|
||||
];
|
||||
};
|
||||
|
||||
const removeEditFilter = (notificationActionId: number, index: number): void => {
|
||||
editFilterState[notificationActionId] = editFilters(notificationActionId).filter(
|
||||
(_, candidateIndex) => candidateIndex !== index,
|
||||
);
|
||||
const removeEditFilter = (
|
||||
notificationActionId: number,
|
||||
index: number,
|
||||
): void => {
|
||||
editFilterState[notificationActionId] = editFilters(
|
||||
notificationActionId,
|
||||
).filter((_, candidateIndex) => candidateIndex !== index);
|
||||
};
|
||||
|
||||
const createSerializedFilters = computed<string>(() =>
|
||||
@@ -305,6 +312,12 @@ const notificationActionTimestamp = (
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<TemplateInheritancePanel
|
||||
:areas="['notification_actions']"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
@@ -517,7 +530,10 @@ const notificationActionTimestamp = (
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="createFilterState.filters.length === 0"
|
||||
v-if="
|
||||
createFilterState.filters.length ===
|
||||
0
|
||||
"
|
||||
class="text-muted-foreground text-sm"
|
||||
>
|
||||
No filters configured. This action runs
|
||||
@@ -526,8 +542,7 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
filter, filterIndex
|
||||
) in createFilterState.filters"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
@@ -546,8 +561,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
:key="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
:value="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
@@ -569,8 +588,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
:key="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
:value="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
@@ -581,7 +604,11 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
v-if="
|
||||
supportsFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
@@ -636,7 +663,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
:message="
|
||||
notificationFilterError(errors)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1003,8 +1032,7 @@ const notificationActionTimestamp = (
|
||||
|
||||
<div
|
||||
v-for="(
|
||||
filter,
|
||||
filterIndex
|
||||
filter, filterIndex
|
||||
) in editFilters(notificationAction.id)"
|
||||
:key="filter.id"
|
||||
class="grid gap-3 rounded-md border p-3"
|
||||
@@ -1023,8 +1051,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterAttributeOption in filterAttributeOptions"
|
||||
:key="filterAttributeOption.value"
|
||||
:value="filterAttributeOption.value"
|
||||
:key="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
:value="
|
||||
filterAttributeOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterAttributeOption.label
|
||||
@@ -1046,8 +1078,12 @@ const notificationActionTimestamp = (
|
||||
>
|
||||
<option
|
||||
v-for="filterOperatorOption in filterOperatorOptions"
|
||||
:key="filterOperatorOption.value"
|
||||
:value="filterOperatorOption.value"
|
||||
:key="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
:value="
|
||||
filterOperatorOption.value
|
||||
"
|
||||
>
|
||||
{{
|
||||
filterOperatorOption.label
|
||||
@@ -1058,7 +1094,11 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="supportsFilterValue(filter.operator)"
|
||||
v-if="
|
||||
supportsFilterValue(
|
||||
filter.operator,
|
||||
)
|
||||
"
|
||||
class="grid gap-2"
|
||||
>
|
||||
<Label
|
||||
@@ -1118,7 +1158,9 @@ const notificationActionTimestamp = (
|
||||
</div>
|
||||
|
||||
<InputError
|
||||
:message="notificationFilterError(errors)"
|
||||
:message="
|
||||
notificationFilterError(errors)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, watch } from 'vue';
|
||||
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -277,6 +278,12 @@ const submitUpdate = (): void => {
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<TemplateInheritancePanel
|
||||
:areas="['repository_connections']"
|
||||
:project-id="project.id"
|
||||
:template-state="templateState"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Git access</CardTitle>
|
||||
@@ -294,9 +301,9 @@ const submitUpdate = (): void => {
|
||||
</Link>
|
||||
</Button>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Without a matching personal connection, {{ appName }} only
|
||||
shows the repository name and links you to your user
|
||||
settings.
|
||||
Without a matching personal connection,
|
||||
{{ appName }} only shows the repository name and links
|
||||
you to your user settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -621,8 +628,8 @@ const submitUpdate = (): void => {
|
||||
>
|
||||
This project link is visible, but you need a
|
||||
matching Git connection in your user
|
||||
settings before {{ appName }} can open or query
|
||||
this repository for you.
|
||||
settings before {{ appName }} can open or
|
||||
query this repository for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
@@ -657,9 +664,9 @@ const submitUpdate = (): void => {
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Detected personal sources</CardTitle>
|
||||
<CardDescription>
|
||||
{{ appName }} loads repositories from your personal
|
||||
Git connections and offers them for project
|
||||
linking.
|
||||
{{ appName }} loads repositories from your
|
||||
personal Git connections and offers them for
|
||||
project linking.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||
|
||||
export type ProjectModel = {
|
||||
id: number;
|
||||
name: string;
|
||||
key: string;
|
||||
project_template_id?: number | null;
|
||||
project_template?: ProjectTemplateModel | null;
|
||||
show_author: boolean;
|
||||
show_story_points: boolean;
|
||||
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 { NotificationActionTemplateVariableModel } from '@/types/models/notification-action-template-variable-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 { TeamModel } from '@/types/models/team-model';
|
||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||
@@ -32,14 +33,72 @@ export type AdminUserEditPageProps = {
|
||||
user: UserModel;
|
||||
};
|
||||
|
||||
export type ProjectDuplicateCopyAreaOption = {
|
||||
description: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
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>;
|
||||
resetRequirements?: Record<
|
||||
string,
|
||||
{
|
||||
removedStatuses: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
ticketCount: number;
|
||||
}>;
|
||||
replacementOptions: Array<{ id: number; name: string }>;
|
||||
}
|
||||
>;
|
||||
templateConfiguration?: Record<string, unknown>;
|
||||
templateId: number;
|
||||
templateName: string;
|
||||
templateSlug: string;
|
||||
};
|
||||
|
||||
export type ProjectShowPageProps = {
|
||||
canManageAccess: boolean;
|
||||
project: ProjectModel;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
};
|
||||
|
||||
export type PermissionOption = {
|
||||
@@ -71,12 +130,15 @@ export type ProjectBoardSettingsPageProps = {
|
||||
boardColumns: BoardColumnModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
ticketStatuses: TicketStatusModel[];
|
||||
};
|
||||
|
||||
export type ProjectGeneralSettingsPageProps = {
|
||||
project: ProjectModel;
|
||||
sourceTemplates: ProjectTemplateModel[];
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
};
|
||||
|
||||
export type ProjectBoardPageProps = {
|
||||
@@ -97,6 +159,7 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
||||
project: ProjectModel;
|
||||
error?: boolean;
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||
ticketLinkTypes: TicketLinkTypeModel[];
|
||||
ticketPriorities: TicketPriorityModel[];
|
||||
@@ -108,6 +171,7 @@ export type ProjectRepositorySettingsPageProps = {
|
||||
project: ProjectModel;
|
||||
repositoryConnections: RepositoryConnectionModel[];
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
};
|
||||
|
||||
export type ProjectNotificationSettingsPageProps = {
|
||||
@@ -119,6 +183,7 @@ export type ProjectNotificationSettingsPageProps = {
|
||||
notificationActions: NotificationActionModel[];
|
||||
project: ProjectModel;
|
||||
status?: string | null;
|
||||
templateState?: ProjectTemplateState | null;
|
||||
templateVariables: NotificationActionTemplateVariableModel[];
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||
|
||||
require __DIR__.'/web/admin.php';
|
||||
require __DIR__.'/web/project-templates.php';
|
||||
require __DIR__.'/web/projects.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');
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user