Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2807a28c | ||
|
|
8a9113a7af | ||
|
|
0a974cfd49 | ||
|
|
538b17b9b8 | ||
|
|
b2789b4670 | ||
|
|
7c6981dc0a | ||
|
|
b8409a702c | ||
|
|
2acbdb4760 | ||
|
|
9faf02e2c4 | ||
|
|
0941745883 | ||
|
|
5a10c85e61 | ||
|
|
fb4faa6070 | ||
|
|
b226ee8d48 | ||
|
|
ac553f0087 | ||
|
|
28d20882ac | ||
|
|
208989d5fc | ||
|
|
d2f659de8f | ||
|
|
f461439ce6 | ||
|
|
3eea473336 | ||
|
|
bfec4f464e | ||
|
|
2edee702d4 | ||
|
|
9b90c24224 | ||
|
|
3869e5b8a6 | ||
|
|
196623bb67 | ||
|
|
bc6a113acf | ||
|
|
1377ee8510 | ||
|
|
2409c3b18f | ||
|
|
f61298fda4 | ||
|
|
7a4c6f3865 | ||
|
|
79c78b3434 | ||
|
|
38438b4700 | ||
|
|
fed0b657c0 | ||
|
|
1fe0c0b2d9 | ||
|
|
463cb7fe36 | ||
|
|
3bf5a0c508 | ||
|
|
32de1f425a | ||
|
|
1af98cd56e | ||
|
|
aadf159ac5 | ||
|
|
5c47453d4d |
@@ -33,9 +33,6 @@
|
|||||||
vendor
|
vendor
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.env.example
|
|
||||||
.env.mariadb
|
|
||||||
.env.mariadb.example
|
|
||||||
.env.production
|
.env.production
|
||||||
LICENSE
|
LICENSE
|
||||||
README.md
|
README.md
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
MARIADB_DATABASE=kanbangit
|
|
||||||
MARIADB_USER=kanbangit
|
|
||||||
MARIADB_PASSWORD=
|
|
||||||
MARIADB_ROOT_PASSWORD=
|
|
||||||
@@ -1,72 +1,18 @@
|
|||||||
name: "Lint and Test API"
|
name: test_and_check_app
|
||||||
description: "Run Pint and the Pest test suite for the Laravel API"
|
description: Build the test image and run all application checks
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Setup PHP
|
- name: Test and check app
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
shell: bash
|
||||||
with:
|
|
||||||
php-version: "8.5"
|
|
||||||
extensions: mbstring, pdo_sqlite
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Verify Composer lockfile
|
|
||||||
run: |
|
run: |
|
||||||
if [ ! -f composer.lock ]; then
|
set -Eeuo pipefail
|
||||||
echo "::error::api/composer.lock not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Get Composer cache directory
|
docker buildx build \
|
||||||
id: composer_cache
|
--file Dockerfile \
|
||||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
--target test \
|
||||||
shell: bash
|
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||||
|
--progress plain \
|
||||||
- name: Cache Composer packages
|
--load \
|
||||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
.
|
||||||
with:
|
|
||||||
path: ${{ steps.composer_cache.outputs.dir }}
|
|
||||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('api/composer.lock') }}
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
|
||||||
with:
|
|
||||||
package-json-file: package.json
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: "pnpm"
|
|
||||||
cache-dependency-path: "pnpm-lock.yaml"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Prepare environment file
|
|
||||||
run: cp .env.example .env
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --no-progress
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Generate application key
|
|
||||||
run: php artisan key:generate
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Generate Wayfinder routes and actions
|
|
||||||
run: php artisan wayfinder:generate --with-form
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Build Vite app
|
|
||||||
run: pnpm run build
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Run ci check
|
|
||||||
run: composer run ci:check
|
|
||||||
shell: bash
|
|
||||||
|
|||||||
+2
-1
@@ -61,7 +61,8 @@ RUN composer install --prefer-dist --no-interaction --no-progress \
|
|||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
|
||||||
RUN touch .env \
|
RUN cp .env.example .env \
|
||||||
|
&& php artisan key:generate\
|
||||||
&& composer audit --locked --no-interaction \
|
&& composer audit --locked --no-interaction \
|
||||||
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
&& COMPOSER_ALLOW_SUPERUSER=1 composer ci:check
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ class BuildProjectTicketMetadataSettingsPayload
|
|||||||
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
DuplicateProjectConfiguration::COPY_TICKET_FIELDS,
|
||||||
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES,
|
||||||
]),
|
]),
|
||||||
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->get())->resolve(),
|
'ticketFieldDefinitions' => TicketFieldDefinitionResource::collection($project->ticketFieldDefinitions()->withCount('ticketFieldValues')->get())->resolve(),
|
||||||
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->get())->resolve(),
|
'ticketLinkTypes' => TicketLinkTypeResource::collection($project->ticketLinkTypes()->withCount('ticketLinks')->get())->resolve(),
|
||||||
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
'ticketPriorities' => TicketPriorityResource::collection($project->ticketPriorities()->get())->resolve(),
|
||||||
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->withCount('tickets')->get())->resolve(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
|
|||||||
'copy_areas' => ['required', 'array', 'min:1'],
|
'copy_areas' => ['required', 'array', 'min:1'],
|
||||||
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
|
||||||
'confirm_linked_projects_update' => [
|
'confirm_linked_projects_update' => [
|
||||||
|
Rule::excludeIf(! $requiresImpactConfirmation),
|
||||||
Rule::requiredIf($requiresImpactConfirmation),
|
Rule::requiredIf($requiresImpactConfirmation),
|
||||||
'accepted',
|
'accepted',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class TicketFieldDefinitionResource extends JsonResource
|
|||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'project_id' => $this->project_id,
|
'project_id' => $this->project_id,
|
||||||
|
'ticket_field_values_count' => $this->when(isset($this->ticket_field_values_count), (int) $this->ticket_field_values_count),
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class TicketLinkTypeResource extends JsonResource
|
|||||||
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
'counterpart_label' => $this->counterpart_label ?: $this->label,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'project_id' => $this->project_id,
|
'project_id' => $this->project_id,
|
||||||
|
'ticket_links_count' => $this->when(isset($this->ticket_links_count), (int) $this->ticket_links_count),
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
'updated_at' => $this->updated_at?->toISOString(),
|
'updated_at' => $this->updated_at?->toISOString(),
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -33,5 +33,5 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
$exceptions->dontFlash(['api_token']);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -1,39 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
import { useAppearance } from '@/composables/useAppearance';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = computed(() => [
|
||||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
{
|
||||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
value: 'light' as const,
|
||||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
Icon: Sun,
|
||||||
] as const;
|
label: t('appearance.light'),
|
||||||
|
description: t('appearance.lightDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark' as const,
|
||||||
|
Icon: Moon,
|
||||||
|
label: t('appearance.dark'),
|
||||||
|
description: t('appearance.darkDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system' as const,
|
||||||
|
Icon: Monitor,
|
||||||
|
label: t('appearance.system'),
|
||||||
|
description: t('appearance.systemDescription'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<fieldset class="min-w-0">
|
||||||
role="group"
|
<legend class="sr-only">{{ t('appearance.label') }}</legend>
|
||||||
:aria-label="t('appearance.label')"
|
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
<label
|
||||||
>
|
v-for="{ value, Icon, label, description } in tabs"
|
||||||
<button
|
|
||||||
v-for="{ value, Icon, label } in tabs"
|
|
||||||
:key="value"
|
:key="value"
|
||||||
type="button"
|
class="relative min-w-0"
|
||||||
:aria-pressed="appearance === value"
|
|
||||||
@click="updateAppearance(value)"
|
|
||||||
:class="[
|
|
||||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
||||||
appearance === value
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
<input
|
||||||
<span>{{ t(label) }}</span>
|
type="radio"
|
||||||
</button>
|
name="appearance"
|
||||||
|
:value="value"
|
||||||
|
:checked="appearance === value"
|
||||||
|
:aria-describedby="`appearance-${value}-description`"
|
||||||
|
class="peer sr-only"
|
||||||
|
@change="updateAppearance(value)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-between gap-3">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="Icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="appearance === value"
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">{{ label }}</span>
|
||||||
|
<span
|
||||||
|
:id="`appearance-${value}-description`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { Form } from '@inertiajs/vue3';
|
import { Form } from '@inertiajs/vue3';
|
||||||
import { useTemplateRef } from 'vue';
|
import { useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -17,30 +17,41 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const passwordInput = useTemplateRef('passwordInput');
|
const passwordInput = useTemplateRef('passwordInput');
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<Card
|
||||||
<Heading
|
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
variant="small"
|
|
||||||
title="Delete account"
|
|
||||||
description="Delete your account and all of its resources"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
|
|
||||||
>
|
>
|
||||||
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
<p class="font-medium">Warning</p>
|
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
|
||||||
<p class="text-sm">
|
<p class="text-muted-foreground">
|
||||||
Please proceed with caution, this cannot be undone.
|
{{ t('profile.delete.description') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<p class="text-destructive font-semibold">
|
||||||
|
{{ t('profile.delete.warning') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('profile.delete.warningDescription') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
<Button variant="destructive" data-test="delete-user-button"
|
<Button
|
||||||
>Delete account</Button
|
variant="destructive"
|
||||||
|
class="justify-self-start"
|
||||||
|
data-test="delete-user-button"
|
||||||
|
>{{ t('profile.delete.action') }}</Button
|
||||||
>
|
>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -55,28 +66,23 @@ const passwordInput = useTemplateRef('passwordInput');
|
|||||||
v-slot="{ errors, processing, reset, clearErrors }"
|
v-slot="{ errors, processing, reset, clearErrors }"
|
||||||
>
|
>
|
||||||
<DialogHeader class="space-y-3">
|
<DialogHeader class="space-y-3">
|
||||||
<DialogTitle
|
<DialogTitle>{{
|
||||||
>Are you sure you want to delete your
|
t('profile.delete.confirmTitle')
|
||||||
account?</DialogTitle
|
}}</DialogTitle>
|
||||||
>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Once your account is deleted, all of its
|
{{ t('profile.delete.confirmDescription') }}
|
||||||
resources and data will also be permanently
|
|
||||||
deleted. Please enter your password to confirm
|
|
||||||
you would like to permanently delete your
|
|
||||||
account.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="password" class="sr-only"
|
<Label for="password" class="sr-only">{{
|
||||||
>Password</Label
|
t('profile.delete.password')
|
||||||
>
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
ref="passwordInput"
|
ref="passwordInput"
|
||||||
placeholder="Password"
|
:placeholder="t('profile.delete.password')"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.password" />
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +98,7 @@ const passwordInput = useTemplateRef('passwordInput');
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ t('common.cancel') }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
|
||||||
@@ -102,12 +108,13 @@ const passwordInput = useTemplateRef('passwordInput');
|
|||||||
:disabled="processing"
|
:disabled="processing"
|
||||||
data-test="confirm-delete-user-button"
|
data-test="confirm-delete-user-button"
|
||||||
>
|
>
|
||||||
Delete account
|
{{ t('profile.delete.confirmAction') }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { KeyRound, UserRound } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
save,
|
||||||
|
store,
|
||||||
|
} from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
user?: UserModel | null;
|
||||||
|
}>(),
|
||||||
|
{ user: null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{ cancel: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const isEdit = computed(() => props.mode === 'edit');
|
||||||
|
const confirmationOpen = ref(false);
|
||||||
|
const form = useForm(
|
||||||
|
'ManagedUser:' + props.mode + ':' + (props.user?.id ?? 'new'),
|
||||||
|
{
|
||||||
|
name: props.user?.name ?? '',
|
||||||
|
email: props.user?.email ?? '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
must_change_password_on_next_login:
|
||||||
|
props.user?.must_change_password_on_next_login ?? true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
|
||||||
|
function performSubmit(): void {
|
||||||
|
confirmationOpen.value = false;
|
||||||
|
protection.submit(
|
||||||
|
isEdit.value && props.user ? save(props.user.id) : store(),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSubmit(): void {
|
||||||
|
if (isEdit.value) {
|
||||||
|
confirmationOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
performSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfirmation(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
|
confirmationOpen.value = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<UserRound class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<h2 class="text-title">
|
||||||
|
{{ t('adminUsers.form.detailsTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.editDescription'
|
||||||
|
: 'adminUsers.form.createDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-page grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="requestSubmit"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="performSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('adminUsers.form.detailsTitle') }}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-name">{{
|
||||||
|
t('adminUsers.form.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="managed-user-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="name"
|
||||||
|
:placeholder="
|
||||||
|
t('adminUsers.form.namePlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="managed-user-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-email">{{
|
||||||
|
t('adminUsers.form.email')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="managed-user-email"
|
||||||
|
v-model="form.email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
:placeholder="
|
||||||
|
t('adminUsers.form.emailPlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.email"
|
||||||
|
aria-describedby="managed-user-email-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-email-error"
|
||||||
|
:message="form.errors.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="managed-user-password-title"
|
||||||
|
class="gap-column grid min-w-0 border-t pt-6"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<KeyRound
|
||||||
|
class="text-muted-foreground mt-0.5 size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0">
|
||||||
|
<h3
|
||||||
|
id="managed-user-password-title"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.passwordTitle') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.passwordEditHelp'
|
||||||
|
: 'adminUsers.form.passwordCreateHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="managed-user-password">{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.newPassword'
|
||||||
|
: 'adminUsers.form.password',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="managed-user-password"
|
||||||
|
v-model="form.password"
|
||||||
|
name="password"
|
||||||
|
:required="!isEdit"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.passwordEditPlaceholder'
|
||||||
|
: 'adminUsers.form.passwordPlaceholder',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="!!form.errors.password"
|
||||||
|
aria-describedby="managed-user-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-password-error"
|
||||||
|
:message="form.errors.password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label
|
||||||
|
for="managed-user-password-confirmation"
|
||||||
|
>{{
|
||||||
|
t('adminUsers.form.confirmPassword')
|
||||||
|
}}</Label
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
id="managed-user-password-confirmation"
|
||||||
|
v-model="form.password_confirmation"
|
||||||
|
name="password_confirmation"
|
||||||
|
:required="!isEdit"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'adminUsers.form.confirmPasswordPlaceholder',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.password_confirmation
|
||||||
|
"
|
||||||
|
aria-describedby="managed-user-password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-password-confirmation-error"
|
||||||
|
:message="form.errors.password_confirmation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-2 border-t pt-6">
|
||||||
|
<Label
|
||||||
|
for="managed-user-force-password-change"
|
||||||
|
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="managed-user-force-password-change"
|
||||||
|
v-model="
|
||||||
|
form.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
name="must_change_password_on_next_login"
|
||||||
|
:invalid="
|
||||||
|
!!form.errors
|
||||||
|
.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
aria-describedby="managed-user-force-password-change-help managed-user-force-password-change-error"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-1">
|
||||||
|
<span>{{
|
||||||
|
t('adminUsers.form.forcePasswordChange')
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
id="managed-user-force-password-change-help"
|
||||||
|
class="text-caption text-muted-foreground font-normal"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'adminUsers.form.forcePasswordChangeHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
id="managed-user-force-password-change-error"
|
||||||
|
:message="
|
||||||
|
form.errors.must_change_password_on_next_login
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
@click="emit('cancel')"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
class="sm:min-w-36"
|
||||||
|
data-test="managed-user-submit"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
isEdit
|
||||||
|
? 'adminUsers.form.reviewChanges'
|
||||||
|
: 'adminUsers.form.createAction',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog :open="confirmationOpen" @update:open="updateConfirmation">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('adminUsers.form.confirmTitle', {
|
||||||
|
name: form.name || props.user?.name || '',
|
||||||
|
})
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('adminUsers.form.confirmDescription') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div
|
||||||
|
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.confirmWarning') }}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="confirmationOpen = false"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:loading="form.processing"
|
||||||
|
data-test="confirm-managed-user-update"
|
||||||
|
@click="performSubmit"
|
||||||
|
>
|
||||||
|
{{ t('adminUsers.form.confirmAction') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -1,123 +1,169 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
||||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
import { ref } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||||
|
|
||||||
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
|
const { t } = useLocalization();
|
||||||
const isRecoveryCodesVisible = ref<boolean>(false);
|
const {
|
||||||
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
|
recoveryCodesList,
|
||||||
|
fetchRecoveryCodes,
|
||||||
|
clearRecoveryCodes,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
|
const visible = ref(false);
|
||||||
|
const showRegenerate = ref(false);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
|
||||||
const toggleRecoveryCodesVisibility = async () => {
|
function hide(): void {
|
||||||
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
|
visible.value = false;
|
||||||
await fetchRecoveryCodes();
|
clearRecoveryCodes();
|
||||||
|
}
|
||||||
|
function toggle(): void {
|
||||||
|
if (visible.value) {
|
||||||
|
hide();
|
||||||
|
} else {
|
||||||
|
visible.value = true;
|
||||||
|
void fetchRecoveryCodes();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
|
function closeRegenerate(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
if (isRecoveryCodesVisible.value) {
|
showRegenerate.value = open;
|
||||||
await nextTick();
|
protection.clearFeedback();
|
||||||
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
function regenerate(): void {
|
||||||
onMounted(async () => {
|
protection.submit(regenerateRecoveryCodes(), {
|
||||||
if (!recoveryCodesList.value.length) {
|
preserveScroll: true,
|
||||||
await fetchRecoveryCodes();
|
onBefore: hide,
|
||||||
}
|
onSuccess: () => {
|
||||||
});
|
showRegenerate.value = false;
|
||||||
|
visible.value = true;
|
||||||
|
return fetchRecoveryCodes();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card class="w-full">
|
<section
|
||||||
<CardHeader>
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
<CardTitle class="flex gap-3">
|
aria-labelledby="recovery-title"
|
||||||
<LockKeyhole class="size-4" />2FA recovery codes
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Recovery codes let you regain access if you lose your 2FA
|
|
||||||
device. Store them in a secure password manager.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div
|
|
||||||
class="flex select-none flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
|
<div class="gap-stack grid">
|
||||||
<component
|
<h3
|
||||||
:is="isRecoveryCodesVisible ? EyeOff : Eye"
|
id="recovery-title"
|
||||||
class="size-4"
|
class="flex items-center gap-2 font-semibold"
|
||||||
/>
|
|
||||||
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
|
|
||||||
codes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
|
|
||||||
v-bind="regenerateRecoveryCodes.form()"
|
|
||||||
method="post"
|
|
||||||
:options="{ preserveScroll: true }"
|
|
||||||
@success="fetchRecoveryCodes"
|
|
||||||
#default="{ processing }"
|
|
||||||
>
|
>
|
||||||
<Button
|
<LockKeyhole class="text-primary size-4" />{{
|
||||||
variant="secondary"
|
t('security.recoveryTitle')
|
||||||
type="submit"
|
}}
|
||||||
:disabled="processing"
|
</h3>
|
||||||
>
|
<p class="text-body text-muted-foreground">
|
||||||
<RefreshCw /> Regenerate codes
|
{{ t('security.recoveryDescription') }}
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'relative overflow-hidden transition-all duration-300',
|
|
||||||
isRecoveryCodesVisible
|
|
||||||
? 'h-auto opacity-100'
|
|
||||||
: 'h-0 opacity-0',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div v-if="errors?.length" class="mt-6">
|
|
||||||
<AlertError :errors="errors" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="mt-3 space-y-3">
|
|
||||||
<div
|
|
||||||
ref="recoveryCodeSectionRef"
|
|
||||||
class="bg-muted grid gap-1 rounded-lg p-4 font-mono text-sm"
|
|
||||||
>
|
|
||||||
<div v-if="!recoveryCodesList.length" class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="n in 8"
|
|
||||||
:key="n"
|
|
||||||
class="bg-muted-foreground/20 h-4 animate-pulse rounded"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-for="(code, index) in recoveryCodesList"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
{{ code }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted-foreground select-none text-xs">
|
|
||||||
Each recovery code can be used once to access your
|
|
||||||
account and will be removed after use. If you need more,
|
|
||||||
click
|
|
||||||
<span class="font-bold">Regenerate codes</span> above.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:aria-expanded="visible"
|
||||||
|
aria-controls="recovery-codes"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="toggle"
|
||||||
|
><component :is="visible ? EyeOff : Eye" />{{
|
||||||
|
t(
|
||||||
|
visible
|
||||||
|
? 'security.hideRecovery'
|
||||||
|
: 'security.viewRecovery',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-if="visible && recoveryCodesList.length"
|
||||||
|
variant="secondary"
|
||||||
|
@click="showRegenerate = true"
|
||||||
|
><RefreshCw />{{ t('security.regenerate') }}</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
||||||
</Card>
|
<p
|
||||||
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-4"
|
||||||
|
>
|
||||||
|
{{ t('security.loadingRecovery') }}
|
||||||
|
</p>
|
||||||
|
<template v-else-if="errors.length">
|
||||||
|
<AlertError :errors="errors" />
|
||||||
|
<Button
|
||||||
|
class="justify-self-start"
|
||||||
|
variant="outline"
|
||||||
|
@click="fetchRecoveryCodes"
|
||||||
|
>{{ t('common.retry') }}</Button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ul
|
||||||
|
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
||||||
|
:aria-label="t('security.recoveryTitle')"
|
||||||
|
>
|
||||||
|
<li v-for="code in recoveryCodesList" :key="code">
|
||||||
|
{{ code }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('security.recoveryOnce') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('security.regenerateTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('security.regenerateDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
@retry="regenerate"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="closeRegenerate(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button :loading="form.processing" @click="regenerate">{{
|
||||||
|
t('security.regenerate')
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,297 +1,261 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { useClipboard } from '@vueuse/core';
|
|
||||||
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
||||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||||
import AlertError from '@/components/AlertError.vue';
|
import AlertError from '@/components/AlertError.vue';
|
||||||
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
InputOTP,
|
import { Label } from '@/components/ui/label';
|
||||||
InputOTPGroup,
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
InputOTPSlot,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
} from '@/components/ui/input-otp';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||||
import { confirm } from '@/routes/two-factor';
|
import { confirm } from '@/routes/two-factor';
|
||||||
import type { TwoFactorConfigContent } from '@/types';
|
|
||||||
|
|
||||||
type Props = {
|
const props = defineProps<{
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
twoFactorEnabled: boolean;
|
twoFactorEnabled: boolean;
|
||||||
};
|
}>();
|
||||||
|
|
||||||
const { resolvedAppearance } = useAppearance();
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
const isOpen = defineModel<boolean>('isOpen');
|
const isOpen = defineModel<boolean>('isOpen');
|
||||||
|
const { t } = useLocalization();
|
||||||
const { copy, copied } = useClipboard();
|
const {
|
||||||
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
|
qrCodeSvg,
|
||||||
useTwoFactorAuth();
|
manualSetupKey,
|
||||||
|
hasSetupData,
|
||||||
const showVerificationStep = ref(false);
|
clearSetupData,
|
||||||
const code = ref<string>('');
|
fetchSetupData,
|
||||||
|
errors,
|
||||||
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
|
loading,
|
||||||
|
} = useTwoFactorAuth();
|
||||||
const modalConfig = computed<TwoFactorConfigContent>(() => {
|
const form = useForm({ code: '' }).dontRemember('code');
|
||||||
if (props.twoFactorEnabled) {
|
const protection = useFormProtection(form, { isDirty: () => false });
|
||||||
return {
|
const verification = ref(false);
|
||||||
title: 'Two-factor authentication enabled',
|
const copied = ref(false);
|
||||||
description:
|
const copyError = ref(false);
|
||||||
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
|
const codeInput = useTemplateRef('codeInput');
|
||||||
buttonText: 'Close',
|
const title = computed(() =>
|
||||||
};
|
t(
|
||||||
}
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabled'
|
||||||
if (showVerificationStep.value) {
|
: verification.value
|
||||||
return {
|
? 'security.verifyTitle'
|
||||||
title: 'Verify authentication code',
|
: 'security.setupTitle',
|
||||||
description: 'Enter the 6-digit code from your authenticator app',
|
),
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Enable two-factor authentication',
|
|
||||||
description:
|
|
||||||
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
|
|
||||||
buttonText: 'Continue',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleModalNextStep = () => {
|
|
||||||
if (props.requiresConfirmation) {
|
|
||||||
showVerificationStep.value = true;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
pinInputContainerRef.value?.querySelector('input')?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSetupData();
|
|
||||||
isOpen.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetModalState = () => {
|
|
||||||
if (props.twoFactorEnabled) {
|
|
||||||
clearSetupData();
|
|
||||||
}
|
|
||||||
|
|
||||||
showVerificationStep.value = false;
|
|
||||||
code.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => isOpen.value,
|
|
||||||
async (isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
resetModalState();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!qrCodeSvg.value) {
|
|
||||||
await fetchSetupData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
const description = computed(() =>
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.setupEnabledDescription'
|
||||||
|
: verification.value
|
||||||
|
? 'security.verifyDescription'
|
||||||
|
: 'security.setupDescription',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (!form.processing) {
|
||||||
|
isOpen.value = open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
||||||
|
close(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verification.value = true;
|
||||||
|
await nextTick();
|
||||||
|
codeInput.value?.$el?.focus();
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
protection.submit(confirm(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
errorBag: 'confirmTwoFactorAuthentication',
|
||||||
|
onSuccess: () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = manualSetupKey.value;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyError.value = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
if (isOpen.value && manualSetupKey.value === key) {
|
||||||
|
copied.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (isOpen.value) {
|
||||||
|
copyError.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
verification.value = false;
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
clearSetupData();
|
||||||
|
if (open) {
|
||||||
|
void fetchSetupData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="isOpen" @update:open="isOpen = $event">
|
<Dialog :open="isOpen" @update:open="close">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent
|
||||||
<DialogHeader class="flex items-center justify-center">
|
class="sm:max-w-md"
|
||||||
<div
|
:show-close-button="!form.processing"
|
||||||
class="border-border bg-card mb-3 w-auto rounded-full border p-0.5 shadow-sm"
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
>
|
>
|
||||||
<div
|
<DialogHeader>
|
||||||
class="border-border bg-muted relative overflow-hidden rounded-full border p-2.5"
|
<ScanLine class="text-primary size-8" />
|
||||||
>
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
<div
|
<DialogDescription>{{ description }}</DialogDescription>
|
||||||
class="absolute inset-0 grid grid-cols-5 opacity-50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="i in 5"
|
|
||||||
:key="`col-${i}`"
|
|
||||||
class="border-border border-r last:border-r-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 grid grid-rows-5 opacity-50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="i in 5"
|
|
||||||
:key="`row-${i}`"
|
|
||||||
class="border-border border-b last:border-b-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ScanLine
|
|
||||||
class="text-foreground relative z-20 size-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
|
|
||||||
<DialogDescription class="text-center">
|
|
||||||
{{ modalConfig.description }}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<template v-if="!verification">
|
||||||
<div
|
<p
|
||||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
v-if="loading"
|
||||||
|
role="status"
|
||||||
|
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
||||||
>
|
>
|
||||||
<template v-if="!showVerificationStep">
|
{{ t('security.loadingSetup') }}
|
||||||
<AlertError v-if="errors?.length" :errors="errors" />
|
</p>
|
||||||
<template v-else>
|
<div v-else-if="errors.length" class="gap-stack grid">
|
||||||
<div
|
<AlertError :errors="errors" />
|
||||||
class="relative mx-auto flex max-w-md items-center overflow-hidden"
|
<Button variant="outline" @click="fetchSetupData">{{
|
||||||
>
|
t('common.retry')
|
||||||
<div
|
}}</Button>
|
||||||
class="border-border relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!qrCodeSvg"
|
|
||||||
class="bg-background absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center"
|
|
||||||
>
|
|
||||||
<Spinner class="size-6" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
||||||
<div
|
<div
|
||||||
v-else
|
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
||||||
class="relative z-10 overflow-hidden border p-5"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-html="qrCodeSvg"
|
v-html="qrCodeSvg"
|
||||||
class="flex aspect-square size-full items-center justify-center"
|
|
||||||
:style="{
|
|
||||||
filter:
|
|
||||||
resolvedAppearance === 'dark'
|
|
||||||
? 'invert(1) brightness(1.5)'
|
|
||||||
: undefined,
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="grid min-w-0 gap-2">
|
||||||
</div>
|
<Label for="setup-key">{{
|
||||||
</div>
|
t('security.manualKey')
|
||||||
|
}}</Label>
|
||||||
<div class="flex w-full items-center space-x-5">
|
<div class="flex min-w-0 gap-2">
|
||||||
<Button class="w-full" @click="handleModalNextStep">
|
<Input
|
||||||
{{ modalConfig.buttonText }}
|
id="setup-key"
|
||||||
</Button>
|
:model-value="manualSetupKey"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative flex w-full items-center justify-center"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="bg-border absolute inset-0 top-1/2 h-px w-full"
|
|
||||||
/>
|
|
||||||
<span class="bg-card relative px-2 py-1"
|
|
||||||
>or, enter the code manually</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-full items-center justify-center space-x-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="border-border flex w-full items-stretch overflow-hidden rounded-xl border"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!manualSetupKey"
|
|
||||||
class="bg-muted flex h-full w-full items-center justify-center p-3"
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
readonly
|
||||||
:value="manualSetupKey"
|
class="min-w-0 font-mono text-xs"
|
||||||
class="bg-background text-foreground h-full w-full p-3"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="copy(manualSetupKey || '')"
|
variant="outline"
|
||||||
class="border-border hover:bg-muted relative block h-auto border-l px-3"
|
:aria-label="
|
||||||
>
|
t(
|
||||||
<Check
|
copied
|
||||||
|
? 'security.copied'
|
||||||
|
: 'security.copyKey',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
v-if="copied"
|
v-if="copied"
|
||||||
class="w-4 text-green-500"
|
role="status"
|
||||||
|
class="text-caption text-success-foreground"
|
||||||
|
>
|
||||||
|
{{ t('security.copied') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
v-if="copyError"
|
||||||
|
:message="t('security.copyError')"
|
||||||
/>
|
/>
|
||||||
<Copy v-else class="w-4" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="requiresConfirmation && !twoFactorEnabled"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('security.setupPendingDescription') }}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="close(false)">{{
|
||||||
|
t('common.close')
|
||||||
|
}}</Button>
|
||||||
|
<Button v-if="hasSetupData" @click="next">{{
|
||||||
|
t(
|
||||||
|
twoFactorEnabled || !requiresConfirmation
|
||||||
|
? 'common.done'
|
||||||
|
: 'security.continue',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
<form v-else class="gap-column grid" @submit.prevent="submit">
|
||||||
|
<AuthFormFeedback
|
||||||
<template v-else>
|
:state="protection.state.value"
|
||||||
<Form
|
:response-status="protection.responseStatus.value"
|
||||||
v-bind="confirm.form()"
|
@retry="submit"
|
||||||
error-bag="confirmTwoFactorAuthentication"
|
/>
|
||||||
reset-on-error
|
<div class="grid gap-2">
|
||||||
@finish="code = ''"
|
<Label for="authentication-code">{{
|
||||||
@success="isOpen = false"
|
t('security.authenticationCode')
|
||||||
v-slot="{ errors, processing }"
|
}}</Label>
|
||||||
>
|
<Input
|
||||||
<input type="hidden" name="code" :value="code" />
|
id="authentication-code"
|
||||||
<div
|
ref="codeInput"
|
||||||
ref="pinInputContainerRef"
|
v-model="form.code"
|
||||||
class="relative w-full space-y-3"
|
name="code"
|
||||||
>
|
type="text"
|
||||||
<div
|
inputmode="numeric"
|
||||||
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
|
autocomplete="one-time-code"
|
||||||
>
|
pattern="[0-9]{6}"
|
||||||
<InputOTP
|
maxlength="6"
|
||||||
id="otp"
|
required
|
||||||
v-model="code"
|
:disabled="form.processing"
|
||||||
:maxlength="6"
|
:aria-invalid="!!form.errors.code"
|
||||||
:disabled="processing"
|
aria-describedby="authentication-code-error"
|
||||||
>
|
class="font-mono text-lg tracking-widest"
|
||||||
<InputOTPGroup>
|
/>
|
||||||
<InputOTPSlot
|
<InputError
|
||||||
v-for="index in 6"
|
id="authentication-code-error"
|
||||||
:key="index"
|
:message="form.errors.code"
|
||||||
:index="index - 1"
|
|
||||||
/>
|
/>
|
||||||
</InputOTPGroup>
|
|
||||||
</InputOTP>
|
|
||||||
<InputError :message="errors?.code" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
<div class="flex w-full items-center space-x-5">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-auto flex-1"
|
:disabled="form.processing"
|
||||||
@click="showVerificationStep = false"
|
@click="
|
||||||
:disabled="processing"
|
verification = false;
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
"
|
||||||
|
>{{ t('common.back') }}</Button
|
||||||
>
|
>
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-auto flex-1"
|
:disabled="form.code.length !== 6"
|
||||||
:disabled="processing || code.length < 6"
|
:loading="form.processing"
|
||||||
|
>{{ t('security.confirm') }}</Button
|
||||||
>
|
>
|
||||||
Confirm
|
</DialogFooter>
|
||||||
</Button>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroyPriority,
|
||||||
|
destroyType,
|
||||||
|
storePriority,
|
||||||
|
storeType,
|
||||||
|
updatePriority,
|
||||||
|
updateType,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
|
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
target: MetadataEditorTarget | null;
|
||||||
|
types: TicketTypeModel[];
|
||||||
|
priorities: TicketPriorityModel[];
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
position: 1,
|
||||||
|
is_parent: false,
|
||||||
|
replacement_ticket_type_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deletionError = ref<string | null>(null);
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const entries = computed(() =>
|
||||||
|
props.target?.kind === 'type' ? props.types : props.priorities,
|
||||||
|
);
|
||||||
|
const replacements = computed(() =>
|
||||||
|
props.types.filter((type) => type.id !== props.target?.entry?.id),
|
||||||
|
);
|
||||||
|
const needsReplacement = computed(
|
||||||
|
() =>
|
||||||
|
deleting.value &&
|
||||||
|
props.target?.kind === 'type' &&
|
||||||
|
(props.target.entry?.tickets_count ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const title = computed(() => {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (target.action === 'create') {
|
||||||
|
return t(
|
||||||
|
target.kind === 'type'
|
||||||
|
? 'metadata.addType'
|
||||||
|
: 'metadata.addPriority',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
target.action === 'edit'
|
||||||
|
? 'metadata.editEntry'
|
||||||
|
: 'metadata.deleteEntry',
|
||||||
|
{ name: target.entry.name },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.defaults({
|
||||||
|
name: target.entry?.name ?? '',
|
||||||
|
position: target.entry?.position ?? 1,
|
||||||
|
is_parent: target.entry?.is_parent ?? false,
|
||||||
|
replacement_ticket_type_id: '',
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
deletionError.value = null;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
form.processing ||
|
||||||
|
(deleting.value && entries.value.length <= 1)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.kind === 'type'
|
||||||
|
? target.action === 'create'
|
||||||
|
? storeType(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateType([props.projectId, target.entry.id])
|
||||||
|
: destroyType([props.projectId, target.entry.id])
|
||||||
|
: target.action === 'create'
|
||||||
|
? storePriority(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updatePriority([props.projectId, target.entry.id])
|
||||||
|
: destroyPriority([props.projectId, target.entry.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? target.kind === 'type'
|
||||||
|
? {
|
||||||
|
replacement_ticket_type_id:
|
||||||
|
data.replacement_ticket_type_id || null,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
: target.action === 'edit'
|
||||||
|
? { name: data.name, position: data.position }
|
||||||
|
: target.kind === 'type'
|
||||||
|
? { name: data.name, is_parent: data.is_parent }
|
||||||
|
: { name: data.name },
|
||||||
|
);
|
||||||
|
deletionError.value = null;
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: (page) => {
|
||||||
|
if (page.props.error) {
|
||||||
|
deletionError.value =
|
||||||
|
typeof page.props.status === 'string'
|
||||||
|
? page.props.status
|
||||||
|
: t('common.error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
deleting
|
||||||
|
? target?.kind === 'type'
|
||||||
|
? 'metadata.deleteTypeHelp'
|
||||||
|
: 'metadata.deletePriorityHelp'
|
||||||
|
: 'metadata.editHelp',
|
||||||
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="deletionError ? 'idle' : protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="deletionError"
|
||||||
|
role="alert"
|
||||||
|
class="text-destructive text-sm"
|
||||||
|
>
|
||||||
|
{{ deletionError }}
|
||||||
|
</p>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<template v-if="!deleting">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="metadata-name">{{
|
||||||
|
t('metadata.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="metadata-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="metadata-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="metadata-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="target?.action === 'edit'"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label for="metadata-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="metadata-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="entries.length"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="metadata-position-help metadata-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="metadata-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('metadata.positionHelp', {
|
||||||
|
count: entries.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="metadata-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="target?.kind === 'type'" class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
v-if="target.action === 'create'"
|
||||||
|
for="metadata-parent"
|
||||||
|
class="flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="metadata-parent"
|
||||||
|
v-model="form.is_parent"
|
||||||
|
name="is_parent"
|
||||||
|
:invalid="!!form.errors.is_parent"
|
||||||
|
aria-describedby="metadata-parent-help metadata-parent-error"
|
||||||
|
/>{{ t('metadata.allowChildren') }}
|
||||||
|
</Label>
|
||||||
|
<p v-else class="text-sm font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
form.is_parent
|
||||||
|
? 'metadata.parentType'
|
||||||
|
: 'metadata.standardType',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="metadata-parent-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.parentFixed') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="metadata-parent-error"
|
||||||
|
:message="form.errors.is_parent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="needsReplacement" class="grid gap-2">
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
t('metadata.replacementHelp', {
|
||||||
|
count: target?.entry?.tickets_count ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Label for="metadata-replacement">{{
|
||||||
|
t('metadata.replacement')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="metadata-replacement"
|
||||||
|
v-model="form.replacement_ticket_type_id"
|
||||||
|
name="replacement_ticket_type_id"
|
||||||
|
required
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors.replacement_ticket_type_id
|
||||||
|
"
|
||||||
|
aria-describedby="metadata-replacement-help metadata-replacement-error"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ t('metadata.chooseType') }}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="entry in replacements"
|
||||||
|
:key="entry.id"
|
||||||
|
:value="entry.id"
|
||||||
|
>
|
||||||
|
{{ entry.name
|
||||||
|
}}{{
|
||||||
|
entry.is_parent
|
||||||
|
? ` (${t('metadata.parentType')})`
|
||||||
|
: ''
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p
|
||||||
|
id="metadata-replacement-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.parentReplacement') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="metadata-replacement-error"
|
||||||
|
:message="form.errors.replacement_ticket_type_id"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="deleting && entries.length <= 1"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LockKeyhole, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type {
|
||||||
|
MetadataEditorTarget,
|
||||||
|
MetadataEntry,
|
||||||
|
MetadataKind,
|
||||||
|
} from '@/types/project-metadata';
|
||||||
|
|
||||||
|
defineProps<{ kind: MetadataKind; entries: MetadataEntry[] }>();
|
||||||
|
defineEmits<{ edit: [target: MetadataEditorTarget] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||||
|
:aria-labelledby="`metadata-${kind}`"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 :id="`metadata-${kind}`" class="font-semibold">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.types'
|
||||||
|
: 'metadata.priorities',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{{ entries.length }}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
@click="$emit('edit', { kind, action: 'create' })"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.addType'
|
||||||
|
: 'metadata.addPriority',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.typesDescription'
|
||||||
|
: 'metadata.prioritiesDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<ol
|
||||||
|
class="divide-y"
|
||||||
|
:aria-label="
|
||||||
|
t(kind === 'type' ? 'metadata.types' : 'metadata.priorities')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-3 py-4"
|
||||||
|
:data-metadata-entry="`${kind}-${entry.id}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.positionValue', {
|
||||||
|
position: entry.position,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ entry.position }}</span
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 basis-28 gap-1">
|
||||||
|
<p class="break-words font-medium">{{ entry.name }}</p>
|
||||||
|
<div
|
||||||
|
v-if="kind === 'type'"
|
||||||
|
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
t(
|
||||||
|
entry.is_parent
|
||||||
|
? 'metadata.parentType'
|
||||||
|
: 'metadata.standardType',
|
||||||
|
)
|
||||||
|
}}</Badge>
|
||||||
|
<span>{{
|
||||||
|
t('metadata.assignedTickets', {
|
||||||
|
count: entry.tickets_count ?? 0,
|
||||||
|
})
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.editEntry', { name: entry.name })
|
||||||
|
"
|
||||||
|
@click="$emit('edit', { kind, action: 'edit', entry })"
|
||||||
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:disabled="entries.length <= 1"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.deleteEntry', { name: entry.name })
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
entries.length <= 1
|
||||||
|
? `metadata-${kind}-protection`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', { kind, action: 'delete', entry })
|
||||||
|
"
|
||||||
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p
|
||||||
|
v-if="entries.length <= 1"
|
||||||
|
:id="`metadata-${kind}-protection`"
|
||||||
|
class="text-caption text-muted-foreground flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<LockKeyhole class="size-4 shrink-0" aria-hidden="true" />{{
|
||||||
|
t(
|
||||||
|
kind === 'type'
|
||||||
|
? 'metadata.lastType'
|
||||||
|
: 'metadata.lastPriority',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
store,
|
||||||
|
update,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectNotificationSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { notificationLabel } from '@/lib/notification-labels';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
serializeNotificationFilters,
|
||||||
|
supportsFilterValue,
|
||||||
|
} from '@/lib/notification-filters';
|
||||||
|
import type { ProjectNotificationSettingsPageProps } from '@/types';
|
||||||
|
import type {
|
||||||
|
NotificationEditorTarget,
|
||||||
|
NotificationFilterFormState,
|
||||||
|
} from '@/types/project-notifications';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
target: NotificationEditorTarget | null;
|
||||||
|
settings: ProjectNotificationSettingsPageProps;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
event_type: '',
|
||||||
|
channel: '',
|
||||||
|
enabled: true,
|
||||||
|
delivery_url: '',
|
||||||
|
message_template: '',
|
||||||
|
allowed_mentions: [] as string[],
|
||||||
|
filters: [] as NotificationFilterFormState[],
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const title = computed(() =>
|
||||||
|
t(
|
||||||
|
deleting.value
|
||||||
|
? 'notifications.delete'
|
||||||
|
: props.target?.action === 'edit'
|
||||||
|
? 'notifications.edit'
|
||||||
|
: 'notifications.create',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const inputClass =
|
||||||
|
'border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm focus-visible:ring-2';
|
||||||
|
let nextFilterId = 0;
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = target.entry;
|
||||||
|
const mentions = entry?.channel_configuration.allowed_mentions;
|
||||||
|
form.defaults({
|
||||||
|
name: entry?.name ?? '',
|
||||||
|
event_type:
|
||||||
|
entry?.event_type ??
|
||||||
|
props.settings.eventTypeOptions[0]?.value ??
|
||||||
|
'',
|
||||||
|
channel:
|
||||||
|
entry?.channel ??
|
||||||
|
props.settings.channelOptions.find(
|
||||||
|
(option) => option.value === 'webhook',
|
||||||
|
)?.value ??
|
||||||
|
props.settings.channelOptions[0]?.value ??
|
||||||
|
'',
|
||||||
|
enabled: entry?.enabled ?? true,
|
||||||
|
delivery_url: '',
|
||||||
|
message_template: entry?.message_template ?? '',
|
||||||
|
allowed_mentions: Array.isArray(mentions)
|
||||||
|
? mentions.filter(
|
||||||
|
(value): value is string => typeof value === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
filters: (entry?.filters ?? []).map((filter) => ({
|
||||||
|
id: nextFilterId++,
|
||||||
|
attribute: filter.attribute,
|
||||||
|
operator: filter.operator,
|
||||||
|
value: Array.isArray(filter.value)
|
||||||
|
? filter.value.join(', ')
|
||||||
|
: (filter.value ?? ''),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function addFilter(): void {
|
||||||
|
form.filters.push({
|
||||||
|
id: nextFilterId++,
|
||||||
|
attribute: props.settings.filterAttributeOptions[0]?.value ?? '',
|
||||||
|
operator: 'equals',
|
||||||
|
value: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeFilter(index: number): void {
|
||||||
|
form.filters.splice(index, 1);
|
||||||
|
form.clearErrors(
|
||||||
|
...(Object.keys(form.errors).filter((key) =>
|
||||||
|
key.startsWith('filters'),
|
||||||
|
) as (keyof typeof form.errors)[]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function errorFor(prefix: string): string | undefined {
|
||||||
|
return Object.entries(form.errors).find(
|
||||||
|
([key]) => key === prefix || key.startsWith(`${prefix}.`),
|
||||||
|
)?.[1];
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const project = props.settings.project.id;
|
||||||
|
const route =
|
||||||
|
target.action === 'create'
|
||||||
|
? store(project)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? update([project, target.entry.id])
|
||||||
|
: destroy([project, target.entry.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
deleting.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
name: data.name,
|
||||||
|
event_type: data.event_type,
|
||||||
|
channel: data.channel,
|
||||||
|
enabled: data.enabled,
|
||||||
|
delivery_url: data.delivery_url || null,
|
||||||
|
channel_configuration:
|
||||||
|
data.channel === 'discord'
|
||||||
|
? { allowed_mentions: data.allowed_mentions }
|
||||||
|
: {},
|
||||||
|
message_template:
|
||||||
|
data.channel === 'discord' ? data.message_template : null,
|
||||||
|
filters: serializeNotificationFilters(data.filters),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
class="sm:max-w-2xl"
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
deleting
|
||||||
|
? t('notifications.deleteHelp', {
|
||||||
|
name: target?.entry?.name ?? '',
|
||||||
|
})
|
||||||
|
: t('notifications.editorHelp')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-5"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<fieldset
|
||||||
|
v-if="!deleting"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-5"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="notification-name">{{
|
||||||
|
t('notifications.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="notification-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="notification-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="notification-event">{{
|
||||||
|
t('notifications.event')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="notification-event"
|
||||||
|
v-model="form.event_type"
|
||||||
|
name="event_type"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="!!form.errors.event_type"
|
||||||
|
aria-describedby="notification-event-error"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in settings.eventTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'event',
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
id="notification-event-error"
|
||||||
|
:message="form.errors.event_type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="notification-channel">{{
|
||||||
|
t('notifications.channel')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="notification-channel"
|
||||||
|
v-model="form.channel"
|
||||||
|
name="channel"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="!!form.errors.channel"
|
||||||
|
aria-describedby="notification-channel-error"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in settings.channelOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'channel',
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
id="notification-channel-error"
|
||||||
|
:message="form.errors.channel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-4"
|
||||||
|
aria-labelledby="notification-delivery-title"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="notification-delivery-title"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('notifications.delivery') }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="target?.action === 'edit'" class="text-sm">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
target.entry.has_delivery_url
|
||||||
|
? 'notifications.keepUrl'
|
||||||
|
: 'notifications.urlMissing',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<Label for="notification-url">{{
|
||||||
|
t(
|
||||||
|
target?.action === 'edit'
|
||||||
|
? 'notifications.replaceUrl'
|
||||||
|
: 'notifications.url',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="notification-url"
|
||||||
|
v-model="form.delivery_url"
|
||||||
|
name="delivery_url"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:spellcheck="false"
|
||||||
|
:required="target?.action === 'create'"
|
||||||
|
maxlength="2048"
|
||||||
|
:aria-invalid="!!form.errors.delivery_url"
|
||||||
|
aria-describedby="notification-url-help notification-url-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="notification-url-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('notifications.secretHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="notification-url-error"
|
||||||
|
:message="form.errors.delivery_url"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
v-if="form.channel === 'discord'"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
aria-labelledby="notification-message-title"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label
|
||||||
|
id="notification-message-title"
|
||||||
|
for="notification-message"
|
||||||
|
>{{ t('notifications.message') }}</Label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="notification-message"
|
||||||
|
v-model="form.message_template"
|
||||||
|
name="message_template"
|
||||||
|
rows="4"
|
||||||
|
required
|
||||||
|
maxlength="2000"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="!!form.errors.message_template"
|
||||||
|
aria-describedby="notification-message-help notification-message-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="notification-message-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('notifications.messageHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="notification-message-error"
|
||||||
|
:message="form.errors.message_template"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<details class="rounded-lg border p-3">
|
||||||
|
<summary
|
||||||
|
class="focus-visible:ring-ring cursor-pointer rounded font-medium focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
{{ t('notifications.variables') }}
|
||||||
|
</summary>
|
||||||
|
<dl class="mt-3 grid min-w-0 gap-3 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="variable in settings.templateVariables"
|
||||||
|
:key="variable.value"
|
||||||
|
class="grid min-w-0 gap-1"
|
||||||
|
>
|
||||||
|
<dt class="break-all font-mono">
|
||||||
|
{{ variable.value }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-muted-foreground">
|
||||||
|
{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'variable',
|
||||||
|
variable.value.slice(1),
|
||||||
|
variable.description,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
<fieldset
|
||||||
|
class="grid gap-2"
|
||||||
|
aria-describedby="notification-mentions-help notification-mentions-error"
|
||||||
|
>
|
||||||
|
<legend class="mb-2 text-sm font-medium">
|
||||||
|
{{ t('notifications.mentions') }}
|
||||||
|
</legend>
|
||||||
|
<label
|
||||||
|
v-for="option in settings.discordAllowedMentionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex min-h-11 items-center gap-3 rounded-lg border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.allowed_mentions"
|
||||||
|
type="checkbox"
|
||||||
|
:value="option.value"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errorFor('channel_configuration')
|
||||||
|
"
|
||||||
|
class="accent-primary size-4"
|
||||||
|
/>{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'mention',
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
id="notification-mentions-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('notifications.mentionsHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="notification-mentions-error"
|
||||||
|
:message="errorFor('channel_configuration')"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
class="grid min-w-0 gap-3 border-t pt-4"
|
||||||
|
aria-labelledby="notification-filters-title"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="notification-filters-title"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('notifications.filters') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('notifications.filtersHelp') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!form.filters.length" class="text-sm">
|
||||||
|
{{ t('notifications.noFilters') }}
|
||||||
|
</p>
|
||||||
|
<fieldset
|
||||||
|
v-for="(filter, index) in form.filters"
|
||||||
|
:key="filter.id"
|
||||||
|
class="bg-muted/25 grid min-w-0 gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<legend class="px-1 text-sm font-medium">
|
||||||
|
{{
|
||||||
|
t('notifications.filterNumber', {
|
||||||
|
number: index + 1,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</legend>
|
||||||
|
<div class="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`filter-attribute-${filter.id}`"
|
||||||
|
>{{
|
||||||
|
t('notifications.attribute')
|
||||||
|
}}</Label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
:id="`filter-attribute-${filter.id}`"
|
||||||
|
v-model="filter.attribute"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errorFor(`filters.${index}`)
|
||||||
|
"
|
||||||
|
:aria-describedby="`filter-error-${filter.id}`"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in settings.filterAttributeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'attribute',
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label
|
||||||
|
:for="`filter-operator-${filter.id}`"
|
||||||
|
>{{
|
||||||
|
t('notifications.operator')
|
||||||
|
}}</Label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
:id="`filter-operator-${filter.id}`"
|
||||||
|
v-model="filter.operator"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errorFor(`filters.${index}`)
|
||||||
|
"
|
||||||
|
:aria-describedby="`filter-error-${filter.id}`"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in settings.filterOperatorOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
notificationLabel(
|
||||||
|
t,
|
||||||
|
'operator',
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="supportsFilterValue(filter.operator)"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label :for="`filter-value-${filter.id}`">{{
|
||||||
|
t(
|
||||||
|
filter.operator === 'in'
|
||||||
|
? 'notifications.values'
|
||||||
|
: 'notifications.value',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<textarea
|
||||||
|
:id="`filter-value-${filter.id}`"
|
||||||
|
v-model="filter.value"
|
||||||
|
rows="2"
|
||||||
|
:class="inputClass"
|
||||||
|
:aria-invalid="
|
||||||
|
!!errorFor(`filters.${index}`)
|
||||||
|
"
|
||||||
|
:aria-describedby="`filter-error-${filter.id}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
:id="`filter-error-${filter.id}`"
|
||||||
|
:message="errorFor(`filters.${index}`)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="justify-self-end"
|
||||||
|
:aria-label="
|
||||||
|
t('notifications.removeFilterNumber', {
|
||||||
|
number: index + 1,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="removeFilter(index)"
|
||||||
|
>{{ t('notifications.removeFilter') }}</Button
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
<InputError :message="form.errors.filters" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="justify-self-start"
|
||||||
|
@click="addFilter"
|
||||||
|
>{{ t('notifications.addFilter') }}</Button
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
<div class="grid gap-2 border-t pt-4">
|
||||||
|
<Label
|
||||||
|
for="notification-enabled"
|
||||||
|
class="flex min-h-11 items-center gap-3"
|
||||||
|
><Checkbox
|
||||||
|
id="notification-enabled"
|
||||||
|
v-model="form.enabled"
|
||||||
|
:invalid="!!form.errors.enabled"
|
||||||
|
aria-describedby="notification-enabled-help notification-enabled-error"
|
||||||
|
/>{{ t('notifications.enabled') }}</Label
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
id="notification-enabled-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('notifications.enabledHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="notification-enabled-error"
|
||||||
|
:message="form.errors.enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<InputError v-else :message="errorFor('notificationAction')" />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('notifications.confirmDelete')
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { Layers3, LockKeyhole } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||||
|
import { index, update } from '@/routes/project-templates';
|
||||||
|
import { show } from '@/routes/projects';
|
||||||
|
import { store } from '@/routes/projects/templates';
|
||||||
|
import type { ProjectDuplicateCopyAreaOption } from '@/types';
|
||||||
|
|
||||||
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
copyAreaOptions: ProjectDuplicateCopyAreaOption[];
|
||||||
|
project?: ProjectModel;
|
||||||
|
template?: ProjectTemplateModel;
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const { areaText } = useProjectTemplateAreas();
|
||||||
|
const source = computed(() => props.template?.source_project ?? props.project);
|
||||||
|
const linkedCount = computed(() => props.template?.linked_project_count ?? 0);
|
||||||
|
const form = useForm(
|
||||||
|
`ProjectTemplate:${props.template ? `edit:${props.template.id}` : `create:${props.project?.id}`}`,
|
||||||
|
{
|
||||||
|
name:
|
||||||
|
props.template?.name ??
|
||||||
|
t('templates.defaultName', { project: props.project?.name ?? '' }),
|
||||||
|
slug:
|
||||||
|
props.template?.slug ??
|
||||||
|
`${props.project?.key.toLowerCase()}-template`,
|
||||||
|
copy_areas: props.template
|
||||||
|
? [...props.template.copy_areas]
|
||||||
|
: props.copyAreaOptions.map((area) => area.value),
|
||||||
|
confirm_linked_projects_update: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const copyAreasError = computed(
|
||||||
|
() =>
|
||||||
|
form.errors.copy_areas ??
|
||||||
|
Object.entries(form.errors).find(([key]) =>
|
||||||
|
key.startsWith('copy_areas.'),
|
||||||
|
)?.[1],
|
||||||
|
);
|
||||||
|
const cancelTarget = computed(() =>
|
||||||
|
props.template ? index() : show(props.project!.id),
|
||||||
|
);
|
||||||
|
function submit(): void {
|
||||||
|
if (
|
||||||
|
!source.value ||
|
||||||
|
(linkedCount.value > 0 && !form.confirm_linked_projects_update)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.transform((data) =>
|
||||||
|
props.template
|
||||||
|
? {
|
||||||
|
name: data.name,
|
||||||
|
copy_areas: data.copy_areas,
|
||||||
|
...(linkedCount.value > 0
|
||||||
|
? {
|
||||||
|
confirm_linked_projects_update:
|
||||||
|
data.confirm_linked_projects_update,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: { name: data.name, slug: data.slug, copy_areas: data.copy_areas },
|
||||||
|
);
|
||||||
|
protection.submit(
|
||||||
|
props.template ? update(props.template.id) : store(props.project!.id),
|
||||||
|
{ preserveScroll: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="gap-page grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<FeedbackState
|
||||||
|
v-if="!source"
|
||||||
|
state="error"
|
||||||
|
:title="t('templates.missingSource')"
|
||||||
|
:description="t('templates.missingSourceHelp')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="gap-page grid min-w-0 items-start lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||||
|
>
|
||||||
|
<div class="gap-page grid min-w-0">
|
||||||
|
<section
|
||||||
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="template-details-title"
|
||||||
|
>
|
||||||
|
<h2 id="template-details-title" class="text-title">
|
||||||
|
{{ t('templates.details') }}
|
||||||
|
</h2>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="name">{{ t('templates.name') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:disabled="form.processing || !source"
|
||||||
|
:placeholder="t('templates.namePlaceholder')"
|
||||||
|
:invalid="!!form.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<Label for="slug"
|
||||||
|
><LockKeyhole
|
||||||
|
v-if="template"
|
||||||
|
class="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{{ t('templates.slug') }}</Label
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
v-model="form.slug"
|
||||||
|
name="slug"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:disabled="!!template || form.processing || !source"
|
||||||
|
:placeholder="t('templates.slugPlaceholder')"
|
||||||
|
:invalid="!!form.errors.slug"
|
||||||
|
aria-describedby="slug-help slug-error"
|
||||||
|
:spellcheck="false"
|
||||||
|
autocapitalize="none"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="slug-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
template
|
||||||
|
? 'templates.slugLocked'
|
||||||
|
: 'templates.slugHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="slug-error"
|
||||||
|
:message="form.errors.slug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<fieldset
|
||||||
|
class="bg-card min-w-0 rounded-md border p-5"
|
||||||
|
:disabled="form.processing || !source"
|
||||||
|
aria-describedby="areas-help copy-areas-error"
|
||||||
|
>
|
||||||
|
<legend class="text-title px-1">
|
||||||
|
{{ t('templates.groups') }}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
id="areas-help"
|
||||||
|
class="text-caption text-muted-foreground mb-4"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
template
|
||||||
|
? 'templates.editGroupsHelp'
|
||||||
|
: 'templates.createGroupsHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="gap-stack grid sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="area in copyAreaOptions"
|
||||||
|
:key="area.value"
|
||||||
|
class="hover:border-primary/45 has-checked:border-primary has-checked:bg-accent/50 flex min-h-11 min-w-0 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.copy_areas"
|
||||||
|
type="checkbox"
|
||||||
|
:value="area.value"
|
||||||
|
:aria-labelledby="`area-${area.value}`"
|
||||||
|
:aria-describedby="`area-${area.value}-help copy-areas-error`"
|
||||||
|
:aria-invalid="!!copyAreasError"
|
||||||
|
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="gap-stack grid min-w-0">
|
||||||
|
<span
|
||||||
|
:id="`area-${area.value}`"
|
||||||
|
class="text-body font-semibold"
|
||||||
|
>{{
|
||||||
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'label',
|
||||||
|
area.label,
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:id="`area-${area.value}-help`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>{{
|
||||||
|
areaText(
|
||||||
|
area.value,
|
||||||
|
'description',
|
||||||
|
area.description,
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="copy-areas-error"
|
||||||
|
class="mt-3"
|
||||||
|
:message="copyAreasError"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<aside
|
||||||
|
class="bg-card gap-column grid min-w-0 rounded-md border p-5"
|
||||||
|
aria-labelledby="template-impact-title"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="template-impact-title"
|
||||||
|
class="text-title flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Layers3 class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('templates.impact')
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<dl class="gap-column grid">
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectCreation.sourceProject') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
|
{{
|
||||||
|
source?.name ?? t('templates.sourceUnavailable')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
<dd v-if="source">
|
||||||
|
<Badge variant="outline">{{ source.key }}</Badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid" aria-live="polite">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('templates.groups') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
t('projectCreation.selectedCount', {
|
||||||
|
count: form.copy_areas.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="template" class="gap-stack grid">
|
||||||
|
<dt class="text-caption text-muted-foreground">
|
||||||
|
{{ t('templates.inheritance') }}
|
||||||
|
</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
t('templates.linkedCount', {
|
||||||
|
count: linkedCount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground border-t pt-4">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
template
|
||||||
|
? 'templates.refreshHelp'
|
||||||
|
: 'templates.cleanStart',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
v-if="linkedCount > 0"
|
||||||
|
class="border-warning/50 bg-warning/10 gap-column grid rounded-md border p-5"
|
||||||
|
aria-labelledby="linked-title"
|
||||||
|
>
|
||||||
|
<h2 id="linked-title" class="text-title">
|
||||||
|
{{ t('templates.linkedWarning') }}
|
||||||
|
</h2>
|
||||||
|
<p id="linked-help" class="text-body">
|
||||||
|
{{ t('templates.linkedImpact', { count: linkedCount }) }}
|
||||||
|
</p>
|
||||||
|
<label class="flex min-h-11 cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
id="confirm-linked"
|
||||||
|
v-model="form.confirm_linked_projects_update"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="form.processing || !source"
|
||||||
|
:aria-invalid="!!form.errors.confirm_linked_projects_update"
|
||||||
|
aria-describedby="linked-help linked-error"
|
||||||
|
class="accent-primary mt-0.5 size-5 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{{ t('templates.acknowledge') }}</span>
|
||||||
|
</label>
|
||||||
|
<InputError
|
||||||
|
id="linked-error"
|
||||||
|
:message="form.errors.confirm_linked_projects_update"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<footer
|
||||||
|
class="flex flex-col-reverse gap-3 border-t pt-5 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline" :disabled="form.processing"
|
||||||
|
><Link :href="cancelTarget">{{
|
||||||
|
t('common.cancel')
|
||||||
|
}}</Link></Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('forms.saving')"
|
||||||
|
:disabled="
|
||||||
|
!source ||
|
||||||
|
(linkedCount > 0 && !form.confirm_linked_projects_update)
|
||||||
|
"
|
||||||
|
>{{
|
||||||
|
t(template ? 'templates.saveChanges' : 'templates.saveNew')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, RotateCcw } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { resetTemplate } from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
import type { ProjectTemplateState, ProjectTemplateStateArea } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
projectId: number;
|
projectId: number;
|
||||||
templateState?: ProjectTemplateState | null;
|
templateState?: ProjectTemplateState | null;
|
||||||
}>();
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const form = useForm<{
|
const form = useForm<{
|
||||||
areas: string[];
|
areas: string[];
|
||||||
status_replacements: Record<string, number | undefined>;
|
status_replacements: Record<string, number | undefined>;
|
||||||
@@ -28,51 +34,82 @@ const form = useForm<{
|
|||||||
areas: [],
|
areas: [],
|
||||||
status_replacements: {},
|
status_replacements: {},
|
||||||
});
|
});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
const activeArea = computed(() => form.areas[0]);
|
const pendingArea = ref<ProjectTemplateStateArea | null>(null);
|
||||||
|
|
||||||
const inheritedAreas = computed(
|
const inheritedAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.inheritedAreas.filter((area) =>
|
props.templateState?.inheritedAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const overriddenAreas = computed(
|
const overriddenAreas = computed(
|
||||||
() =>
|
() =>
|
||||||
props.templateState?.overriddenAreas.filter((area) =>
|
props.templateState?.overriddenAreas.filter((area) =>
|
||||||
props.areas.includes(area.value),
|
props.areas.includes(area.value),
|
||||||
) ?? [],
|
) ?? [],
|
||||||
);
|
);
|
||||||
|
const statusRequirement = computed(() =>
|
||||||
const statusRequirement = computed(
|
pendingArea.value?.value === 'ticket_statuses'
|
||||||
() => props.templateState?.resetRequirements?.ticket_statuses,
|
? props.templateState?.resetRequirements?.ticket_statuses
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
const canReset = computed(
|
||||||
const canReset = (area: ProjectTemplateStateArea): boolean => {
|
() =>
|
||||||
if (area.value !== 'ticket_statuses') {
|
statusRequirement.value?.removedStatuses.every((status) =>
|
||||||
return true;
|
statusRequirement.value?.replacementOptions.some(
|
||||||
}
|
(option) =>
|
||||||
|
option.id === form.status_replacements[String(status.id)],
|
||||||
return (
|
),
|
||||||
statusRequirement.value?.removedStatuses.every(
|
) ?? true,
|
||||||
(status) =>
|
);
|
||||||
form.status_replacements[String(status.id)] !== undefined,
|
const areaKeys = {
|
||||||
) ?? true
|
project_settings: 'inheritance.area.projectSettings',
|
||||||
);
|
board_columns: 'inheritance.area.boardColumns',
|
||||||
};
|
ticket_statuses: 'inheritance.area.ticketStatuses',
|
||||||
|
status_transitions: 'inheritance.area.statusTransitions',
|
||||||
const resetArea = (area: ProjectTemplateStateArea): void => {
|
ticket_types: 'inheritance.area.ticketTypes',
|
||||||
if (!canReset(area)) {
|
ticket_priorities: 'inheritance.area.ticketPriorities',
|
||||||
|
ticket_fields: 'inheritance.area.ticketFields',
|
||||||
|
ticket_link_types: 'inheritance.area.ticketLinkTypes',
|
||||||
|
repository_connections: 'inheritance.area.repositoryConnections',
|
||||||
|
notification_actions: 'inheritance.area.notificationActions',
|
||||||
|
} as const;
|
||||||
|
function areaLabel(area: ProjectTemplateStateArea): string {
|
||||||
|
const key = areaKeys[area.value as keyof typeof areaKeys];
|
||||||
|
return key ? t(key) : area.label;
|
||||||
|
}
|
||||||
|
function openReset(area: ProjectTemplateStateArea): void {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
pendingArea.value = area;
|
||||||
|
}
|
||||||
|
function closeReset(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pendingArea.value = null;
|
||||||
form.areas = [area.value];
|
form.resetAndClearErrors();
|
||||||
form.post(ProjectController.resetTemplate.url(props.projectId), {
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
function resetArea(): void {
|
||||||
|
if (
|
||||||
|
!pendingArea.value ||
|
||||||
|
!canReset.value ||
|
||||||
|
!overriddenAreas.value.some(
|
||||||
|
(area) => area.value === pendingArea.value?.value,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.areas = [pendingArea.value.value];
|
||||||
|
protection.submit(resetTemplate(props.projectId), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => form.reset(),
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
pendingArea.value = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -81,75 +118,166 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
|||||||
templateState?.inheritsFromTemplate &&
|
templateState?.inheritsFromTemplate &&
|
||||||
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
(inheritedAreas.length > 0 || overriddenAreas.length > 0)
|
||||||
"
|
"
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
<CardTitle>
|
<div class="flex items-center gap-2">
|
||||||
Template inheritance: {{ templateState.templateName }}
|
<Layers3
|
||||||
</CardTitle>
|
class="text-primary size-5 shrink-0"
|
||||||
<CardDescription>
|
aria-hidden="true"
|
||||||
Every setting area inherits independently. Local changes stay
|
/>
|
||||||
unchanged when the template is updated until that area is reset.
|
<h2 class="text-title">{{ t('inheritance.title') }}</h2>
|
||||||
</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>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
<div
|
{{
|
||||||
v-for="area in overriddenAreas"
|
t('inheritance.description', {
|
||||||
:key="`overridden-${area.value}`"
|
template: templateState.templateName,
|
||||||
class="space-y-3 rounded-lg border p-4"
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.help') }}
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
class="grid min-w-0 divide-y"
|
||||||
|
:aria-label="t('inheritance.areas')"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<li
|
||||||
<Badge>{{ area.label }} · template overridden</Badge>
|
v-for="area in inheritedAreas"
|
||||||
|
:key="area.value"
|
||||||
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3 py-3"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
|
areaLabel(area)
|
||||||
|
}}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="border-primary/25 text-primary"
|
||||||
|
>{{ t('projectSettings.inherited') }}</Badge
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="area in overriddenAreas"
|
||||||
|
:key="area.value"
|
||||||
|
class="gap-stack grid min-w-0 py-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 break-words font-medium">{{
|
||||||
|
areaLabel(area)
|
||||||
|
}}</span>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
t('projectSettings.override')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.overrideHelp') }}
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="form.processing || !canReset(area)"
|
class="shrink-0"
|
||||||
@click="resetArea(area)"
|
:disabled="form.processing"
|
||||||
|
:aria-label="
|
||||||
|
t('inheritance.resetArea', {
|
||||||
|
area: areaLabel(area),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openReset(area)"
|
||||||
>
|
>
|
||||||
{{
|
<RotateCcw aria-hidden="true" />{{
|
||||||
activeArea === area.value && form.processing
|
t('inheritance.reset')
|
||||||
? 'Resetting…'
|
|
||||||
: 'Reset to template'
|
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
<div
|
</ul>
|
||||||
v-if="area.value === 'ticket_statuses' && statusRequirement"
|
</CardContent>
|
||||||
class="space-y-3"
|
<Dialog :open="pendingArea !== null" @update:open="closeReset">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
>
|
>
|
||||||
<p class="text-muted-foreground text-sm">
|
<DialogHeader>
|
||||||
Choose where tickets in local statuses should move
|
<DialogTitle>{{
|
||||||
before those statuses are removed.
|
t('inheritance.confirmTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('inheritance.confirmDescription', {
|
||||||
|
area: pendingArea ? areaLabel(pendingArea) : '',
|
||||||
|
template: templateState.templateName,
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="resetArea"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
form.processing
|
||||||
|
? t('inheritance.resetting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="resetArea"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.areas ?? form.errors['areas.0']"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('inheritance.confirmTitle') }}
|
||||||
|
</legend>
|
||||||
|
<div
|
||||||
|
v-if="statusRequirement"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('inheritance.statusHelp') }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="status in statusRequirement.removedStatuses"
|
v-for="status in statusRequirement.removedStatuses"
|
||||||
:key="status.id"
|
:key="status.id"
|
||||||
class="grid gap-2 sm:grid-cols-2 sm:items-center"
|
class="grid gap-2"
|
||||||
>
|
>
|
||||||
<Label :for="`replacement-${status.id}`">
|
<Label :for="'replacement-' + status.id">{{
|
||||||
{{ status.name }} ({{ status.ticketCount }} tickets)
|
t('inheritance.statusLabel', {
|
||||||
</Label>
|
status: status.name,
|
||||||
<div class="grid gap-1">
|
count: status.ticketCount,
|
||||||
|
})
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
:id="`replacement-${status.id}`"
|
:id="'replacement-' + status.id"
|
||||||
v-model="
|
v-model="
|
||||||
form.status_replacements[String(status.id)]
|
form.status_replacements[
|
||||||
|
String(status.id)
|
||||||
|
]
|
||||||
|
"
|
||||||
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
|
:aria-invalid="
|
||||||
|
!!form.errors[
|
||||||
|
`status_replacements.${status.id}`
|
||||||
|
]
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
'replacement-error-' + 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>
|
<option :value="undefined" disabled>
|
||||||
Select replacement status
|
{{ t('inheritance.selectStatus') }}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="option in statusRequirement.replacementOptions"
|
v-for="option in statusRequirement.replacementOptions"
|
||||||
@@ -160,6 +288,7 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError
|
||||||
|
:id="'replacement-error-' + status.id"
|
||||||
:message="
|
:message="
|
||||||
form.errors[
|
form.errors[
|
||||||
`status_replacements.${status.id}`
|
`status_replacements.${status.id}`
|
||||||
@@ -168,10 +297,29 @@ const resetArea = (area: ProjectTemplateStateArea): void => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-3 text-sm"
|
||||||
<InputError :message="form.errors.areas" />
|
>
|
||||||
</div>
|
{{ t('inheritance.resetWarning') }}
|
||||||
</CardContent>
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeReset(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" :disabled="!canReset">{{
|
||||||
|
t(
|
||||||
|
form.processing
|
||||||
|
? 'inheritance.resetting'
|
||||||
|
: 'inheritance.reset',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { update } from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { ProjectModel } from '@/types/models/project-model';
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectModel }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
show_author: props.project.show_author,
|
||||||
|
show_story_points: props.project.show_story_points,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
watch(
|
||||||
|
() => [props.project.show_author, props.project.show_story_points],
|
||||||
|
() => {
|
||||||
|
form.defaults({
|
||||||
|
show_author: props.project.show_author,
|
||||||
|
show_story_points: props.project.show_story_points,
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function submit(): void {
|
||||||
|
protection.submit(update(props.project.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => form.defaults(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="bg-card grid min-w-0 gap-4 rounded-xl border p-4 sm:p-6"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">{{ t('metadata.display') }}</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('metadata.displayDescription') }}
|
||||||
|
</p>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<fieldset :disabled="form.processing" class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="option in ['show_author', 'show_story_points'] as const"
|
||||||
|
:key="option"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
:for="option"
|
||||||
|
class="bg-muted/35 flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
:id="option"
|
||||||
|
v-model="form[option]"
|
||||||
|
:name="option"
|
||||||
|
:invalid="!!form.errors[option]"
|
||||||
|
:aria-describedby="`${option}-help ${option}-error`"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-1">
|
||||||
|
<span>{{
|
||||||
|
t(
|
||||||
|
option === 'show_author'
|
||||||
|
? 'metadata.author'
|
||||||
|
: 'metadata.storyPoints',
|
||||||
|
)
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
:id="`${option}-help`"
|
||||||
|
class="text-muted-foreground text-sm font-normal"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
option === 'show_author'
|
||||||
|
? 'metadata.authorHelp'
|
||||||
|
: 'metadata.storyPointsHelp',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
:id="`${option}-error`"
|
||||||
|
:message="form.errors[option]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
class="w-full sm:w-fit"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{ t('common.save') }}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { CircleAlert } from 'lucide-vue-next';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroyFieldDefinition,
|
||||||
|
storeFieldDefinition,
|
||||||
|
updateFieldDefinition,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||||
|
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
|
||||||
|
import type { TicketFieldTypeOption } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
target: TicketFieldDefinitionEditorTarget | null;
|
||||||
|
definitions: TicketFieldDefinitionModel[];
|
||||||
|
fieldTypeOptions: TicketFieldTypeOption[];
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
label: '',
|
||||||
|
field_type: '',
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.target?.action === 'create') {
|
||||||
|
return t('metadata.addField');
|
||||||
|
}
|
||||||
|
if (props.target?.action === 'edit') {
|
||||||
|
return t('metadata.editField', { name: props.target.entry.label });
|
||||||
|
}
|
||||||
|
return props.target
|
||||||
|
? t('metadata.deleteField', { name: props.target.entry.label })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
const values =
|
||||||
|
target?.action === 'edit'
|
||||||
|
? {
|
||||||
|
label: target.entry.label,
|
||||||
|
field_type: target.entry.field_type,
|
||||||
|
position: target.entry.position,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: '',
|
||||||
|
field_type: props.fieldTypeOptions[0]?.value ?? '',
|
||||||
|
position: props.definitions.length + 1,
|
||||||
|
};
|
||||||
|
form.defaults(values);
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.action === 'create'
|
||||||
|
? storeFieldDefinition(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateFieldDefinition([props.projectId, target.entry.id])
|
||||||
|
: destroyFieldDefinition([props.projectId, target.entry.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? {}
|
||||||
|
: target.action === 'create'
|
||||||
|
? { label: data.label, field_type: data.field_type }
|
||||||
|
: data,
|
||||||
|
);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
deleting
|
||||||
|
? 'metadata.deleteFieldHelp'
|
||||||
|
: 'metadata.fieldEditorHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<div
|
||||||
|
v-if="deleting && target?.entry"
|
||||||
|
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<CircleAlert
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
t('metadata.fieldDeletionImpact', {
|
||||||
|
count:
|
||||||
|
target.entry.ticket_field_values_count ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<fieldset
|
||||||
|
v-if="!deleting"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="field-definition-label">{{
|
||||||
|
t('metadata.fieldLabel')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="field-definition-label"
|
||||||
|
v-model="form.label"
|
||||||
|
name="label"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.label"
|
||||||
|
aria-describedby="field-definition-label-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="field-definition-label-error"
|
||||||
|
:message="form.errors.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="field-definition-type">{{
|
||||||
|
t('metadata.fieldType')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="field-definition-type"
|
||||||
|
v-model="form.field_type"
|
||||||
|
name="field_type"
|
||||||
|
required
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full min-w-0 rounded-md border px-3 text-sm focus-visible:ring-2"
|
||||||
|
:aria-invalid="!!form.errors.field_type"
|
||||||
|
aria-describedby="field-definition-type-help field-definition-type-error"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in fieldTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p
|
||||||
|
id="field-definition-type-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.fieldTypeHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="field-definition-type-error"
|
||||||
|
:message="form.errors.field_type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="target?.action === 'edit'" class="grid gap-2">
|
||||||
|
<Label for="field-definition-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="field-definition-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="definitions.length"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="field-definition-position-help field-definition-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="field-definition-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('metadata.positionHelp', {
|
||||||
|
count: definitions.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="field-definition-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Braces, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import TicketFieldDefinitionEditorDialog from '@/components/projects/TicketFieldDefinitionEditorDialog.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||||
|
import type { TicketFieldDefinitionEditorTarget } from '@/types/project-metadata';
|
||||||
|
import type { TicketFieldTypeOption } from '@/types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
definitions: TicketFieldDefinitionModel[];
|
||||||
|
fieldTypeOptions: TicketFieldTypeOption[];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const target = ref<TicketFieldDefinitionEditorTarget | null>(null);
|
||||||
|
const fieldTypeLabels = computed(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
props.fieldTypeOptions.map((option) => [
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
function fieldTypeLabel(fieldType: string): string {
|
||||||
|
return fieldTypeLabels.value.get(fieldType) ?? fieldType;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||||
|
aria-labelledby="metadata-custom-fields"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<Braces
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 id="metadata-custom-fields" class="font-semibold">
|
||||||
|
{{ t('metadata.customFields') }}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{{ definitions.length }}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
@click="target = { action: 'create' }"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('metadata.addField')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('metadata.customFieldsDescription') }}
|
||||||
|
</p>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="definitions.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('metadata.noCustomFields')"
|
||||||
|
:description="t('metadata.noCustomFieldsDescription')"
|
||||||
|
/>
|
||||||
|
<ol v-else class="divide-y" :aria-label="t('metadata.customFields')">
|
||||||
|
<li
|
||||||
|
v-for="definition in definitions"
|
||||||
|
:key="definition.id"
|
||||||
|
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
|
||||||
|
:data-ticket-field-definition="definition.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.positionValue', {
|
||||||
|
position: definition.position,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ definition.position }}</span
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<p class="break-words font-medium">
|
||||||
|
{{ definition.label }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-caption text-muted-foreground flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Badge variant="outline">{{
|
||||||
|
fieldTypeLabel(definition.field_type)
|
||||||
|
}}</Badge>
|
||||||
|
<span>{{
|
||||||
|
t('metadata.storedValues', {
|
||||||
|
count:
|
||||||
|
definition.ticket_field_values_count ?? 0,
|
||||||
|
})
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.editField', { name: definition.label })
|
||||||
|
"
|
||||||
|
@click="target = { action: 'edit', entry: definition }"
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.deleteField', {
|
||||||
|
name: definition.label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
target = { action: 'delete', entry: definition }
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<TicketFieldDefinitionEditorDialog
|
||||||
|
:definitions="definitions"
|
||||||
|
:field-type-options="fieldTypeOptions"
|
||||||
|
:project-id="projectId"
|
||||||
|
:target="target"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowRight, CircleAlert } from 'lucide-vue-next';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroyLinkType,
|
||||||
|
storeLinkType,
|
||||||
|
updateLinkType,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
target: TicketLinkTypeEditorTarget | null;
|
||||||
|
linkTypes: TicketLinkTypeModel[];
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
counterpart_name: '',
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.target?.action === 'create') {
|
||||||
|
return t('metadata.addLinkType');
|
||||||
|
}
|
||||||
|
if (props.target?.action === 'edit') {
|
||||||
|
return t('metadata.editLinkType', { name: props.target.entry.label });
|
||||||
|
}
|
||||||
|
return props.target
|
||||||
|
? t('metadata.deleteLinkType', { name: props.target.entry.label })
|
||||||
|
: '';
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
const values =
|
||||||
|
target?.action === 'edit'
|
||||||
|
? {
|
||||||
|
name: target.entry.label,
|
||||||
|
counterpart_name: target.entry.counterpart_label,
|
||||||
|
position: target.entry.position,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: '',
|
||||||
|
counterpart_name: '',
|
||||||
|
position: props.linkTypes.length + 1,
|
||||||
|
};
|
||||||
|
form.defaults(values);
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.action === 'create'
|
||||||
|
? storeLinkType(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateLinkType([props.projectId, target.entry.id])
|
||||||
|
: destroyLinkType([props.projectId, target.entry.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? {}
|
||||||
|
: target.action === 'create'
|
||||||
|
? {
|
||||||
|
name: data.name,
|
||||||
|
counterpart_name: data.counterpart_name,
|
||||||
|
}
|
||||||
|
: data,
|
||||||
|
);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle class="break-words">{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
deleting
|
||||||
|
? 'metadata.deleteLinkTypeHelp'
|
||||||
|
: 'metadata.linkTypeEditorHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<div
|
||||||
|
v-if="deleting && target?.entry"
|
||||||
|
class="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-3 rounded-lg border p-4 text-sm"
|
||||||
|
>
|
||||||
|
<CircleAlert
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
t('metadata.linkTypeDeletionImpact', {
|
||||||
|
count: target.entry.ticket_links_count ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<fieldset
|
||||||
|
v-if="!deleting"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="link-type-source-label">{{
|
||||||
|
t('metadata.sourceLabel')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="link-type-source-label"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="link-type-source-label-help link-type-source-label-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="link-type-source-label-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.sourceLabelHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="link-type-source-label-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ArrowRight
|
||||||
|
class="text-primary mx-auto size-5 rotate-90 sm:rotate-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="link-type-counterpart-label">{{
|
||||||
|
t('metadata.counterpartLabel')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="link-type-counterpart-label"
|
||||||
|
v-model="form.counterpart_name"
|
||||||
|
name="counterpart_name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.counterpart_name"
|
||||||
|
aria-describedby="link-type-counterpart-label-help link-type-counterpart-label-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="link-type-counterpart-label-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('metadata.counterpartLabelHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="link-type-counterpart-label-error"
|
||||||
|
:message="form.errors.counterpart_name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="target?.action === 'edit'" class="grid gap-2">
|
||||||
|
<Label for="link-type-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="link-type-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="linkTypes.length"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="link-type-position-help link-type-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="link-type-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('metadata.positionHelp', {
|
||||||
|
count: linkTypes.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="link-type-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowRight, Link2, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import TicketLinkTypeEditorDialog from '@/components/projects/TicketLinkTypeEditorDialog.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type { TicketLinkTypeEditorTarget } from '@/types/project-metadata';
|
||||||
|
|
||||||
|
defineProps<{ projectId: number; linkTypes: TicketLinkTypeModel[] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const target = ref<TicketLinkTypeEditorTarget | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card grid min-w-0 gap-4 overflow-hidden rounded-xl border p-4 sm:p-6"
|
||||||
|
aria-labelledby="metadata-link-types"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<Link2
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 id="metadata-link-types" class="font-semibold">
|
||||||
|
{{ t('metadata.linkTypes') }}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{{ linkTypes.length }}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
@click="target = { action: 'create' }"
|
||||||
|
>
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('metadata.addLinkType')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('metadata.linkTypesDescription') }}
|
||||||
|
</p>
|
||||||
|
<FeedbackState
|
||||||
|
v-if="linkTypes.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('metadata.noLinkTypes')"
|
||||||
|
:description="t('metadata.noLinkTypesDescription')"
|
||||||
|
/>
|
||||||
|
<ol v-else class="divide-y" :aria-label="t('metadata.linkTypes')">
|
||||||
|
<li
|
||||||
|
v-for="linkType in linkTypes"
|
||||||
|
:key="linkType.id"
|
||||||
|
class="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 py-4"
|
||||||
|
:data-ticket-link-type="linkType.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.positionValue', {
|
||||||
|
position: linkType.position,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ linkType.position }}</span
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.linkDirection', {
|
||||||
|
source: linkType.label,
|
||||||
|
counterpart: linkType.counterpart_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="break-words font-medium">{{
|
||||||
|
linkType.label
|
||||||
|
}}</span>
|
||||||
|
<ArrowRight
|
||||||
|
class="text-primary size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="break-words font-medium">{{
|
||||||
|
linkType.counterpart_label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t('metadata.ticketRelationships', {
|
||||||
|
count: linkType.ticket_links_count ?? 0,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.editLinkType', {
|
||||||
|
name: linkType.label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="target = { action: 'edit', entry: linkType }"
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('metadata.deleteLinkType', {
|
||||||
|
name: linkType.label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="target = { action: 'delete', entry: linkType }"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<TicketLinkTypeEditorDialog
|
||||||
|
:link-types="linkTypes"
|
||||||
|
:project-id="projectId"
|
||||||
|
:target="target"
|
||||||
|
@close="target = null"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
destroyStatus,
|
||||||
|
store,
|
||||||
|
storeStatus,
|
||||||
|
update,
|
||||||
|
updateStatus,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
columnCount: number;
|
||||||
|
target: WorkflowEditorTarget | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const form = useForm({ name: '', position: 1 });
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.target !== null && form.isDirty,
|
||||||
|
});
|
||||||
|
const deleting = computed(() => props.target?.action === 'delete');
|
||||||
|
const title = computed(() => {
|
||||||
|
if (props.target?.kind === 'column') {
|
||||||
|
return t(
|
||||||
|
props.target.action === 'create'
|
||||||
|
? 'workflow.addColumn'
|
||||||
|
: props.target.action === 'edit'
|
||||||
|
? 'workflow.editColumn'
|
||||||
|
: 'workflow.deleteColumn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
props.target?.action === 'create'
|
||||||
|
? 'workflow.addStatus'
|
||||||
|
: props.target?.action === 'edit'
|
||||||
|
? 'workflow.editStatus'
|
||||||
|
: 'workflow.deleteStatus',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const description = computed(() => {
|
||||||
|
const target = props.target;
|
||||||
|
if (!target) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (target.kind === 'column') {
|
||||||
|
return t(
|
||||||
|
target.action === 'delete'
|
||||||
|
? 'workflow.deleteColumnDescription'
|
||||||
|
: target.action === 'create'
|
||||||
|
? 'workflow.addColumnDescription'
|
||||||
|
: 'workflow.editColumnDescription',
|
||||||
|
{ name: target.column?.name ?? '' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t(
|
||||||
|
target.action === 'delete'
|
||||||
|
? 'workflow.deleteStatusDescription'
|
||||||
|
: 'workflow.statusContext',
|
||||||
|
{
|
||||||
|
name: target.status?.name ?? '',
|
||||||
|
context: target.column?.name ?? t('workflow.backlog'),
|
||||||
|
replacement: target.replacement?.name ?? '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const otherErrors = computed(() =>
|
||||||
|
Object.entries(form.errors)
|
||||||
|
.filter(([key]) => !['name', 'position'].includes(key))
|
||||||
|
.map(([, message]) => message),
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.target,
|
||||||
|
(target) => {
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.defaults({
|
||||||
|
name:
|
||||||
|
target.kind === 'column'
|
||||||
|
? (target.column?.name ?? '')
|
||||||
|
: (target.status?.name ?? ''),
|
||||||
|
position: target.column?.position ?? 1,
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (open || form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
const target = props.target;
|
||||||
|
if (
|
||||||
|
!target ||
|
||||||
|
form.processing ||
|
||||||
|
(target.kind === 'status' &&
|
||||||
|
target.action === 'delete' &&
|
||||||
|
!target.replacement)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route =
|
||||||
|
target.kind === 'column'
|
||||||
|
? target.action === 'create'
|
||||||
|
? store(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? update([props.projectId, target.column.id])
|
||||||
|
: destroy([props.projectId, target.column.id])
|
||||||
|
: target.action === 'create'
|
||||||
|
? storeStatus(props.projectId)
|
||||||
|
: target.action === 'edit'
|
||||||
|
? updateStatus([props.projectId, target.status.id])
|
||||||
|
: destroyStatus([props.projectId, target.status.id]);
|
||||||
|
form.transform((data) =>
|
||||||
|
target.action === 'delete'
|
||||||
|
? {}
|
||||||
|
: target.kind === 'column' && target.action === 'edit'
|
||||||
|
? data
|
||||||
|
: target.kind === 'status' && target.action === 'create'
|
||||||
|
? {
|
||||||
|
name: data.name,
|
||||||
|
board_column_id: target.column?.id ?? null,
|
||||||
|
}
|
||||||
|
: { name: data.name },
|
||||||
|
);
|
||||||
|
protection.submit(route, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
emit('close');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="target !== null" @update:open="close">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!form.processing"
|
||||||
|
@escape-key-down="form.processing && $event.preventDefault()"
|
||||||
|
@interact-outside="form.processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription class="break-words">{{
|
||||||
|
description
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<FormFeedback :state="protection.state.value" @retry="submit" />
|
||||||
|
<InputError
|
||||||
|
v-for="message in otherErrors"
|
||||||
|
:key="message"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
v-if="!deleting"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="workflow-name">{{
|
||||||
|
t(
|
||||||
|
target?.kind === 'column'
|
||||||
|
? 'workflow.columnName'
|
||||||
|
: 'workflow.statusName',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="workflow-name"
|
||||||
|
v-model="form.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxlength="255"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
:aria-describedby="
|
||||||
|
form.errors.name
|
||||||
|
? 'workflow-name-error'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="workflow-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
target?.kind === 'column' &&
|
||||||
|
target.action === 'edit'
|
||||||
|
"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label for="workflow-position">{{
|
||||||
|
t('workflow.position')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="workflow-position"
|
||||||
|
v-model="form.position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="columnCount"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:aria-invalid="!!form.errors.position"
|
||||||
|
aria-describedby="workflow-position-help workflow-position-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="workflow-position-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('workflow.positionHelp', {
|
||||||
|
count: columnCount,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="workflow-position-error"
|
||||||
|
:message="form.errors.position"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:variant="deleting ? 'destructive' : 'default'"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{
|
||||||
|
deleting
|
||||||
|
? t('workflow.confirmDelete')
|
||||||
|
: target?.action === 'create'
|
||||||
|
? title
|
||||||
|
: t('common.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
statuses: TicketStatusModel[];
|
||||||
|
}>();
|
||||||
|
defineEmits<{ edit: [target: WorkflowEditorTarget] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid min-w-0 gap-3">
|
||||||
|
<ul
|
||||||
|
class="divide-y"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.statusesIn', {
|
||||||
|
context: column?.name ?? t('workflow.backlog'),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-center justify-between gap-2 py-3 first:pt-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 basis-28 break-words text-sm font-medium"
|
||||||
|
>{{ status.name }}</span
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.renameStatus', { name: status.name })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', {
|
||||||
|
kind: 'status',
|
||||||
|
action: 'edit',
|
||||||
|
column,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="text-destructive"
|
||||||
|
:disabled="statuses.length <= 1"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.removeStatus', { name: status.name })
|
||||||
|
"
|
||||||
|
:aria-describedby="
|
||||||
|
statuses.length <= 1
|
||||||
|
? `status-protection-${column?.id ?? 'backlog'}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
$emit('edit', {
|
||||||
|
kind: 'status',
|
||||||
|
action: 'delete',
|
||||||
|
column,
|
||||||
|
status,
|
||||||
|
replacement: statuses.find(
|
||||||
|
(item) => item.id !== status.id,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-if="statuses.length <= 1"
|
||||||
|
:id="`status-protection-${column?.id ?? 'backlog'}`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('workflow.lastStatus') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-fit"
|
||||||
|
:aria-label="
|
||||||
|
t('workflow.addStatusTo', {
|
||||||
|
context: column?.name ?? t('workflow.backlog'),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="$emit('edit', { kind: 'status', action: 'create', column })"
|
||||||
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('workflow.addStatus')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowRightLeft } from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { updateStatusTransitions } from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { ticketStatusLabel } from '@/lib/tickets';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
statuses: TicketStatusModel[];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const sourceId = ref(props.statuses[0]?.id ?? null);
|
||||||
|
const source = computed(() =>
|
||||||
|
props.statuses.find((status) => status.id === sourceId.value),
|
||||||
|
);
|
||||||
|
const targets = computed(() =>
|
||||||
|
props.statuses.filter((status) => status.id !== sourceId.value),
|
||||||
|
);
|
||||||
|
const savedTargets = computed(
|
||||||
|
() =>
|
||||||
|
source.value?.allowed_target_statuses
|
||||||
|
?.map((status) => status.id)
|
||||||
|
.sort((a, b) => a - b) ?? [],
|
||||||
|
);
|
||||||
|
const form = useForm({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const saved = ref(false);
|
||||||
|
const hasErrors = computed(() => Object.keys(form.errors).length > 0);
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
saved.value = false;
|
||||||
|
form.defaults({ allowed_ticket_status_ids: [...savedTargets.value] });
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.statuses,
|
||||||
|
() => {
|
||||||
|
if (!source.value) {
|
||||||
|
sourceId.value = props.statuses[0]?.id ?? null;
|
||||||
|
reset();
|
||||||
|
} else if (!form.isDirty && !form.processing) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function selectSource(event: Event): void {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
select.value = String(sourceId.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceId.value = Number(select.value);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTarget(id: number, event: Event): void {
|
||||||
|
saved.value = false;
|
||||||
|
const checked = (event.target as HTMLInputElement).checked;
|
||||||
|
form.allowed_ticket_status_ids = checked
|
||||||
|
? [...form.allowed_ticket_status_ids, id].sort((a, b) => a - b)
|
||||||
|
: form.allowed_ticket_status_ids.filter((target) => target !== id);
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (!source.value || !form.isDirty || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
updateStatusTransitions([props.projectId, source.value.id]),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
saved.value = true;
|
||||||
|
form.defaults({
|
||||||
|
allowed_ticket_status_ids: [...savedTargets.value],
|
||||||
|
});
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||||
|
aria-labelledby="workflow-transitions"
|
||||||
|
>
|
||||||
|
<header class="grid gap-3 border-b p-5 sm:p-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ArrowRightLeft
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 id="workflow-transitions" class="font-semibold">
|
||||||
|
{{ t('workflow.transitions') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.transitionsDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="grid min-w-0 gap-5 p-5 sm:p-6">
|
||||||
|
<p v-if="!source" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.noStatuses') }}
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="workflow-source">{{
|
||||||
|
t('workflow.sourceStatus')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="workflow-source"
|
||||||
|
:value="sourceId"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring/40 min-h-11 w-full min-w-0 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||||
|
@change="selectSource"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="status in statuses"
|
||||||
|
:key="status.id"
|
||||||
|
:value="status.id"
|
||||||
|
>
|
||||||
|
{{ ticketStatusLabel(status, '') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('workflow.directionHelp') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-3"
|
||||||
|
aria-describedby="workflow-target-help workflow-transition-errors"
|
||||||
|
:aria-invalid="hasErrors"
|
||||||
|
>
|
||||||
|
<legend class="mb-3 break-words font-medium">
|
||||||
|
{{
|
||||||
|
t('workflow.targetsFrom', { name: source.name })
|
||||||
|
}}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
id="workflow-target-help"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.targetHelp') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="targets.length === 0"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.noTargets') }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="target in targets"
|
||||||
|
:key="target.id"
|
||||||
|
class="has-[:focus-visible]:ring-ring/40 flex min-h-14 min-w-0 cursor-pointer items-center gap-3 rounded-lg border p-3 has-[:focus-visible]:ring-2"
|
||||||
|
:class="
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
? 'border-primary/40 bg-accent/40'
|
||||||
|
: 'border-input'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="allowed_ticket_status_ids[]"
|
||||||
|
:value="target.id"
|
||||||
|
:checked="
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="accent-primary size-4 shrink-0"
|
||||||
|
@change="toggleTarget(target.id, $event)"
|
||||||
|
/>
|
||||||
|
<span class="grid min-w-0 gap-1">
|
||||||
|
<span
|
||||||
|
class="break-words text-sm font-medium"
|
||||||
|
>{{
|
||||||
|
ticketStatusLabel(target, '')
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-muted-foreground text-xs"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
form.allowed_ticket_status_ids.includes(
|
||||||
|
target.id,
|
||||||
|
)
|
||||||
|
? 'workflow.allowed'
|
||||||
|
: 'workflow.notAllowed',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="
|
||||||
|
targets.length > 0 &&
|
||||||
|
form.allowed_ticket_status_ids.length === 0
|
||||||
|
"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{ t('workflow.noMovesHelp') }}
|
||||||
|
</p>
|
||||||
|
<div id="workflow-transition-errors" role="alert">
|
||||||
|
<InputError
|
||||||
|
v-for="(message, key) in form.errors"
|
||||||
|
:key="key"
|
||||||
|
:message="message"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
:state="
|
||||||
|
!saved && protection.state.value === 'success'
|
||||||
|
? 'idle'
|
||||||
|
: protection.state.value
|
||||||
|
"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-3 border-t pt-4"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
:variant="form.isDirty ? 'warning' : 'secondary'"
|
||||||
|
role="status"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
form.isDirty
|
||||||
|
? 'workflow.unsavedMoves'
|
||||||
|
: 'workflow.savedMoves',
|
||||||
|
{ count: savedTargets.length },
|
||||||
|
)
|
||||||
|
}}</Badge
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-wrap gap-2 sm:ml-auto sm:w-auto"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!form.isDirty || form.processing"
|
||||||
|
@click="reset"
|
||||||
|
>{{ t('workflow.resetMoves') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
:loading="form.processing"
|
||||||
|
>{{ t('workflow.saveMoves') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ClipboardCheck,
|
||||||
|
IndentDecrease,
|
||||||
|
IndentIncrease,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import {
|
||||||
|
canIndentAcceptanceCriterion,
|
||||||
|
indentAcceptanceCriterion,
|
||||||
|
removeAcceptanceCriterion,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
|
} from '@/lib/acceptance-criteria';
|
||||||
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: AcceptanceCriteriaModel;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
persistToggles?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
dirty?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
readonly: false,
|
||||||
|
disabled: false,
|
||||||
|
persistToggles: false,
|
||||||
|
allowEditing: true,
|
||||||
|
saving: undefined,
|
||||||
|
dirty: false,
|
||||||
|
error: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [items: AcceptanceCriteriaModel];
|
||||||
|
toggle: [index: number, checked: boolean];
|
||||||
|
}>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const id = useId();
|
||||||
|
const root = ref<HTMLElement>();
|
||||||
|
const editing = ref(!props.persistToggles);
|
||||||
|
const completed = computed(
|
||||||
|
() => props.modelValue.filter((item) => item.checked).length,
|
||||||
|
);
|
||||||
|
const locked = computed(() => props.readonly || props.disabled);
|
||||||
|
const update = (items: AcceptanceCriteriaModel): void => {
|
||||||
|
if (!locked.value) {
|
||||||
|
emit('update:modelValue', items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const focusRow = async (index: number): Promise<void> => {
|
||||||
|
await nextTick();
|
||||||
|
const inputs =
|
||||||
|
root.value?.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||||
|
if (inputs?.[index]) {
|
||||||
|
inputs[index].focus();
|
||||||
|
} else {
|
||||||
|
root.value
|
||||||
|
?.querySelector<HTMLButtonElement>('[data-add-criterion]')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const add = (): void => {
|
||||||
|
update([...props.modelValue, { text: '', checked: false, indent: 0 }]);
|
||||||
|
void focusRow(props.modelValue.length);
|
||||||
|
};
|
||||||
|
const remove = (index: number): void => {
|
||||||
|
update(removeAcceptanceCriterion(props.modelValue, index));
|
||||||
|
void focusRow(Math.max(0, index - 1));
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async () => {
|
||||||
|
await nextTick();
|
||||||
|
root.value
|
||||||
|
?.querySelectorAll<HTMLTextAreaElement>('textarea')
|
||||||
|
.forEach((input) => {
|
||||||
|
input.setCustomValidity(
|
||||||
|
input.value.trim() === '' ? t('criteria.required') : '',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const changeText = (index: number, event: Event): void => {
|
||||||
|
const text = (event.target as HTMLTextAreaElement).value;
|
||||||
|
update(
|
||||||
|
props.modelValue.map((item, position) =>
|
||||||
|
position === index ? { ...item, text } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const toggle = (index: number, event: Event): void => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const checked = target.checked;
|
||||||
|
target.checked = props.modelValue[index].checked;
|
||||||
|
if (locked.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.persistToggles && !editing.value && !props.dirty) {
|
||||||
|
emit('toggle', index, checked);
|
||||||
|
} else {
|
||||||
|
update(
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
checked,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeydown = (index: number, event: KeyboardEvent): void => {
|
||||||
|
if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
props.modelValue,
|
||||||
|
index,
|
||||||
|
event.key === 'ArrowRight' ? 1 : -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="root"
|
||||||
|
class="border-border bg-card min-w-0 space-y-4 rounded-xl border p-4 sm:p-5"
|
||||||
|
:aria-labelledby="`${id}-title`"
|
||||||
|
:aria-busy="disabled"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3
|
||||||
|
:id="`${id}-title`"
|
||||||
|
class="flex items-center gap-2 font-semibold"
|
||||||
|
>
|
||||||
|
<ClipboardCheck
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('criteria.title') }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-muted-foreground text-sm" aria-live="polite">{{
|
||||||
|
t('criteria.progress', { completed, total: modelValue.length })
|
||||||
|
}}</span>
|
||||||
|
<Button
|
||||||
|
v-if="persistToggles && allowEditing && !readonly"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
(editing &&
|
||||||
|
modelValue.some((item) => item.text.trim() === ''))
|
||||||
|
"
|
||||||
|
:aria-pressed="editing"
|
||||||
|
@click="editing = !editing"
|
||||||
|
>
|
||||||
|
{{ t(editing ? 'criteria.preview' : 'criteria.edit') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!modelValue.length" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('criteria.empty') }}
|
||||||
|
</p>
|
||||||
|
<ol v-else class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in modelValue"
|
||||||
|
:key="item.id ?? `new-${index}`"
|
||||||
|
:data-indent="item.indent"
|
||||||
|
class="border-border min-w-0"
|
||||||
|
:class="{ 'border-s-2 ps-2': item.indent > 0 }"
|
||||||
|
:style="{ marginInlineStart: `${item.indent * 0.5}rem` }"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<label
|
||||||
|
class="focus-within:ring-ring flex min-h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-md focus-within:ring-2"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-60': locked }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:checked="item.checked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="locked"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.complete', {
|
||||||
|
number: index + 1,
|
||||||
|
text: item.text,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="border-input accent-primary size-5 rounded"
|
||||||
|
@change="toggle(index, $event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<textarea
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
:value="item.text"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.item', { number: index + 1 })
|
||||||
|
"
|
||||||
|
:aria-describedby="`${id}-hint ${id}-error`"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="Boolean(error)"
|
||||||
|
required
|
||||||
|
rows="2"
|
||||||
|
class="border-input bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:opacity-60"
|
||||||
|
@input="changeText(index, $event)"
|
||||||
|
@keydown="onKeydown(index, $event)"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="wrap-anywhere min-h-11 whitespace-pre-wrap py-2.5 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-muted-foreground line-through':
|
||||||
|
item.checked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="editing && !readonly"
|
||||||
|
class="flex flex-wrap gap-1"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled || item.indent === 0"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.outdent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentDecrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="
|
||||||
|
disabled ||
|
||||||
|
!canIndentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.indent', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="
|
||||||
|
update(
|
||||||
|
indentAcceptanceCriterion(
|
||||||
|
modelValue,
|
||||||
|
index,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
><IndentIncrease aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="
|
||||||
|
t('criteria.remove', { number: index + 1 })
|
||||||
|
"
|
||||||
|
@click="remove(index)"
|
||||||
|
><Trash2 aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<template v-if="editing && !readonly">
|
||||||
|
<Button
|
||||||
|
data-add-criterion
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="add"
|
||||||
|
><Plus aria-hidden="true" />{{ t('criteria.add') }}</Button
|
||||||
|
>
|
||||||
|
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
|
||||||
|
{{ t('criteria.hint') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p
|
||||||
|
v-if="persistToggles && dirty"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.unsaved') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="saving ?? disabled"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ t('criteria.saving') }}
|
||||||
|
</p>
|
||||||
|
<InputError :id="`${id}-error`" :message="error" role="alert" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
CircleAlert,
|
||||||
|
GitBranchPlus,
|
||||||
|
LoaderCircle,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
store,
|
||||||
|
validate as validateBranch,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||||
|
import type {
|
||||||
|
TicketBranchCreateDialogProps,
|
||||||
|
TicketBranchFormData,
|
||||||
|
} from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketBranchCreateDialogProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const appName = String(usePage().props.name);
|
||||||
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:branch`;
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const isCheckingBranchName = ref(false);
|
||||||
|
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
||||||
|
|
||||||
|
const defaultRepositoryConnectionId = computed(() =>
|
||||||
|
String(
|
||||||
|
props.repositoryConnections.find(
|
||||||
|
(repositoryConnection) => repositoryConnection.has_personal_access,
|
||||||
|
)?.id ??
|
||||||
|
props.repositoryConnections[0]?.id ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<TicketBranchFormData>(draftKey, {
|
||||||
|
branch_name: props.suggestedBranchName,
|
||||||
|
repository_connection_id: defaultRepositoryConnectionId.value,
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => isOpen.value && form.isDirty,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedRepositoryConnection = computed(
|
||||||
|
() =>
|
||||||
|
props.repositoryConnections.find(
|
||||||
|
(repositoryConnection) =>
|
||||||
|
String(repositoryConnection.id) ===
|
||||||
|
form.repository_connection_id,
|
||||||
|
) ?? null,
|
||||||
|
);
|
||||||
|
const selectedRepositoryHasPersonalAccess = computed(
|
||||||
|
() => selectedRepositoryConnection.value?.has_personal_access ?? false,
|
||||||
|
);
|
||||||
|
const validationStateClasses = computed(() => {
|
||||||
|
if (isCheckingBranchName.value) {
|
||||||
|
return 'border-primary/30 bg-primary/5 text-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchNameValidation.value?.is_valid) {
|
||||||
|
return 'border-success/40 bg-success/10 text-success-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchNameValidation.value !== null) {
|
||||||
|
return 'border-warning/40 bg-warning/10 text-warning-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-border bg-muted/30 text-muted-foreground';
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateOpen(open: boolean): void {
|
||||||
|
if (!open && !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = open;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeForm(): void {
|
||||||
|
if (form.isDirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
form.branch_name = props.suggestedBranchName;
|
||||||
|
form.repository_connection_id = defaultRepositoryConnectionId.value;
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[isOpen, () => form.repository_connection_id, () => form.branch_name],
|
||||||
|
(
|
||||||
|
[open, repositoryConnectionId, branchName],
|
||||||
|
_previousValues,
|
||||||
|
onCleanup,
|
||||||
|
) => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryConnection = selectedRepositoryConnection.value;
|
||||||
|
|
||||||
|
if (repositoryConnection === null || repositoryConnectionId === '') {
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!repositoryConnection.has_personal_access) {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.connectGitFirst'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchName.trim() === '') {
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchRequired'),
|
||||||
|
};
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = null;
|
||||||
|
isCheckingBranchName.value = true;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
validateBranch.url(
|
||||||
|
{
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
branch_name: branchName,
|
||||||
|
repository_connection_id:
|
||||||
|
repositoryConnectionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Branch validation failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value =
|
||||||
|
(await response.json()) as BranchNameValidationModel;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'AbortError'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNameValidation.value = {
|
||||||
|
is_valid: false,
|
||||||
|
message: t('ticketDetail.branchValidationFailed', {
|
||||||
|
app: appName,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abortController.abort();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
isCheckingBranchName.value = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (
|
||||||
|
!selectedRepositoryHasPersonalAccess.value ||
|
||||||
|
branchNameValidation.value?.is_valid !== true
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
store({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
},
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="updateOpen">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full gap-2 sm:w-auto"
|
||||||
|
@click="isOpen = true"
|
||||||
|
>
|
||||||
|
<GitBranchPlus class="size-4" aria-hidden="true" />
|
||||||
|
{{ t('ticketDetail.createBranch') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader class="space-y-3">
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('ticketDetail.createBranchTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('ticketDetail.createBranchDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form class="grid gap-4" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:title="
|
||||||
|
protection.state.value === 'loading'
|
||||||
|
? t('ticketDetail.creatingBranch')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="repository_connection_id">{{
|
||||||
|
t('ticketDetail.repository')
|
||||||
|
}}</Label>
|
||||||
|
<select
|
||||||
|
id="repository_connection_id"
|
||||||
|
v-model="form.repository_connection_id"
|
||||||
|
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="repositoryConnection in repositoryConnections"
|
||||||
|
:key="repositoryConnection.id"
|
||||||
|
:value="String(repositoryConnection.id)"
|
||||||
|
>
|
||||||
|
{{ repositoryConnection.repository_full_name
|
||||||
|
}}{{
|
||||||
|
repositoryConnection.has_personal_access
|
||||||
|
? ''
|
||||||
|
: ` — ${t('ticketDetail.personalAccessRequired')}`
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="form.errors.repository_connection_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<Label for="branch_name">{{
|
||||||
|
t('ticketDetail.branchName')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="branch_name"
|
||||||
|
v-model="form.branch_name"
|
||||||
|
:disabled="!selectedRepositoryHasPersonalAccess"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<InputError :message="form.errors.branch_name" />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
:aria-busy="isCheckingBranchName || undefined"
|
||||||
|
class="rounded-lg border px-4 py-3 transition-colors"
|
||||||
|
:class="validationStateClasses"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<LoaderCircle
|
||||||
|
v-if="isCheckingBranchName"
|
||||||
|
class="mt-0.5 size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CheckCircle2
|
||||||
|
v-else-if="branchNameValidation?.is_valid"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CircleAlert
|
||||||
|
v-else-if="branchNameValidation !== null"
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{{
|
||||||
|
isCheckingBranchName
|
||||||
|
? t('ticketDetail.checkingBranch')
|
||||||
|
: (branchNameValidation?.message ??
|
||||||
|
t('ticketDetail.selectRepository'))
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!selectedRepositoryHasPersonalAccess &&
|
||||||
|
selectedRepositoryConnection !== null
|
||||||
|
"
|
||||||
|
class="border-border bg-muted/20 grid gap-3 rounded-lg border border-dashed px-4 py-4"
|
||||||
|
>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<p class="font-semibold">
|
||||||
|
{{ t('ticketDetail.gitAccessRequired') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.branchAccess') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="updateOpen(false)"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="
|
||||||
|
isCheckingBranchName ||
|
||||||
|
branchNameValidation?.is_valid !== true
|
||||||
|
"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('ticketDetail.creatingBranch')"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.createNow') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import type { TicketDetailBlockerProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketDetailBlockerProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const savedFormData = () => ({
|
||||||
|
acceptance_criteria: props.ticket.acceptance_criteria,
|
||||||
|
assignee_user_id: props.ticket.assignee_user_id,
|
||||||
|
blocked_comment: props.ticket.blocked_comment ?? '',
|
||||||
|
description: props.ticket.description,
|
||||||
|
field_values: (props.ticket.field_values ?? []).map((fieldValue) => ({
|
||||||
|
ticket_field_definition_id: fieldValue.ticket_field_definition_id,
|
||||||
|
value_number: fieldValue.value_number,
|
||||||
|
value_user_id: fieldValue.value_user_id,
|
||||||
|
})),
|
||||||
|
is_blocked: props.ticket.is_blocked,
|
||||||
|
parent_ticket_id: props.ticket.parent_ticket_id,
|
||||||
|
story_points: props.ticket.story_points,
|
||||||
|
ticket_priority_id: props.ticket.ticket_priority_id,
|
||||||
|
ticket_type_id: props.ticket.ticket_type_id,
|
||||||
|
title: props.ticket.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(
|
||||||
|
`projects:${props.project.id}:tickets:${props.ticket.id}:blocker`,
|
||||||
|
savedFormData(),
|
||||||
|
);
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const isBlocked = computed(() => Boolean(form.is_blocked));
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
if (!props.canEditTicket || form.processing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
protection.submit(
|
||||||
|
ProjectTicketController.update({
|
||||||
|
project: props.project.id,
|
||||||
|
ticket: props.ticket.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
Object.assign(form, savedFormData());
|
||||||
|
form.defaults();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
id="ticket-blocker"
|
||||||
|
aria-labelledby="ticket-blocker-title"
|
||||||
|
class="min-w-0 scroll-mt-6"
|
||||||
|
>
|
||||||
|
<Card :class="{ 'border-warning/40': isBlocked }">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 id="ticket-blocker-title" class="text-lg font-semibold">
|
||||||
|
{{ t('ticketDetail.blocker') }}
|
||||||
|
</h2>
|
||||||
|
<Badge :variant="isBlocked ? 'warning' : 'outline'">
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.blockerActive')
|
||||||
|
: t('ticketDetail.blockerInactive')
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{{ t('ticketDetail.blockerDescription') }}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent v-if="canEditTicket">
|
||||||
|
<form class="grid min-w-0 gap-5" @submit.prevent="submit">
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-muted/30 flex min-w-0 items-start gap-3 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="is_blocked"
|
||||||
|
v-model="form.is_blocked"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="Boolean(form.errors.is_blocked)"
|
||||||
|
aria-describedby="ticket-blocker-state-description is_blocked-error"
|
||||||
|
class="border-input text-primary focus-visible:ring-ring mt-0.5 size-4 shrink-0 rounded focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<Label for="is_blocked">
|
||||||
|
{{ t('ticketDetail.blocked') }}
|
||||||
|
</Label>
|
||||||
|
<p
|
||||||
|
id="ticket-blocker-state-description"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t(
|
||||||
|
'ticketDetail.blockerActiveDescription',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'ticketDetail.blockerInactiveDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputError
|
||||||
|
id="is_blocked-error"
|
||||||
|
:message="form.errors.is_blocked"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TicketFormField
|
||||||
|
v-if="isBlocked"
|
||||||
|
id="blocked_comment"
|
||||||
|
v-model="form.blocked_comment"
|
||||||
|
kind="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:label="t('ticketDetail.blockerComment')"
|
||||||
|
:hint="t('ticketDetail.blockerCommentHelp')"
|
||||||
|
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
||||||
|
:error="form.errors.blocked_comment"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!form.isDirty || form.processing"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.resetBlocker') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:loading="form.processing"
|
||||||
|
:loading-label="t('ticketDetail.savingBlocker')"
|
||||||
|
:disabled="!form.isDirty"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? t('ticketDetail.saveBlocker')
|
||||||
|
: t('ticketDetail.unblockTicket')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardContent v-else>
|
||||||
|
<p
|
||||||
|
class="whitespace-pre-wrap text-sm leading-6"
|
||||||
|
:class="{ 'text-muted-foreground': !isBlocked }"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isBlocked
|
||||||
|
? ticket.blocked_comment
|
||||||
|
: t('ticketDetail.blockerInactiveDescription')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -3,43 +3,54 @@ import { useLocalization } from '@/composables/useLocalization';
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
|
||||||
import { useForm, useRemember } from '@inertiajs/vue3';
|
import { useForm, useRemember } from '@inertiajs/vue3';
|
||||||
import { computed, ref, toRef, unref } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
toRef,
|
||||||
|
unref,
|
||||||
|
useTemplateRef,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
import {
|
import {
|
||||||
acceptanceCriteriaToEditorState,
|
|
||||||
buildAcceptanceCriteria,
|
|
||||||
formatChecklistLines,
|
|
||||||
normalizeAcceptanceCriteria,
|
normalizeAcceptanceCriteria,
|
||||||
syncAcceptanceCriteriaDescendantCheckedState,
|
syncAcceptanceCriteriaDescendantCheckedState,
|
||||||
} from '@/lib/acceptance-criteria';
|
} from '@/lib/acceptance-criteria';
|
||||||
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
import type { TicketDetailContentProps } from '@/types/ticket-detail';
|
||||||
const props = defineProps<TicketDetailContentProps>();
|
const props = defineProps<TicketDetailContentProps>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const editor = useTemplateRef<HTMLDetailsElement>('editor');
|
||||||
|
async function editParent(): Promise<void> {
|
||||||
|
if (!props.canEditTicket || !editor.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.value.open = true;
|
||||||
|
await nextTick();
|
||||||
|
editor.value
|
||||||
|
?.querySelector<HTMLSelectElement>('#parent_ticket_id')
|
||||||
|
?.focus();
|
||||||
|
}
|
||||||
|
defineExpose({ editParent });
|
||||||
const canEditAuthor = computed(
|
const canEditAuthor = computed(
|
||||||
() => props.canEditTicket && props.canManageProject,
|
() => props.canEditTicket && props.canManageProject,
|
||||||
);
|
);
|
||||||
const canViewAuthor = computed(
|
const canViewAuthor = computed(
|
||||||
() => props.project.show_author || props.canManageProject,
|
() => props.project.show_author || props.canManageProject,
|
||||||
);
|
);
|
||||||
const acceptanceCriteriaEditorState = acceptanceCriteriaToEditorState(
|
|
||||||
props.ticket.acceptance_criteria,
|
|
||||||
);
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const acceptanceCriteriaDraft = useRemember(
|
||||||
{ checklist: acceptanceCriteriaEditorState.checklist },
|
{ items: normalizeAcceptanceCriteria(props.ticket.acceptance_criteria) },
|
||||||
`${draftKey}:checklist`,
|
`${draftKey}:criteria`,
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
);
|
||||||
|
const acceptanceCriteriaItems = toRef(unref(acceptanceCriteriaDraft), 'items');
|
||||||
const savedAcceptanceCriteria = ref(
|
const savedAcceptanceCriteria = ref(
|
||||||
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
normalizeAcceptanceCriteria(props.ticket.acceptance_criteria),
|
||||||
);
|
);
|
||||||
@@ -47,8 +58,8 @@ const isPersistingAcceptanceCriteriaToggle = ref(false);
|
|||||||
const acceptanceCriteriaToggleError = ref('');
|
const acceptanceCriteriaToggleError = ref('');
|
||||||
|
|
||||||
const savedFormData = () => ({
|
const savedFormData = () => ({
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
acceptance_criteria: normalizeAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value,
|
savedAcceptanceCriteria.value,
|
||||||
),
|
),
|
||||||
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
assignee_user_id: props.ticket.assignee_user_id ?? '',
|
||||||
author_user_id: props.ticket.author_user_id ?? '',
|
author_user_id: props.ticket.author_user_id ?? '',
|
||||||
@@ -77,14 +88,20 @@ const savedFormData = () => ({
|
|||||||
|
|
||||||
const form = useForm(draftKey, savedFormData());
|
const form = useForm(draftKey, savedFormData());
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(
|
watch(
|
||||||
() => savedAcceptanceCriteria.value,
|
() => [props.ticket.is_blocked, props.ticket.blocked_comment] as const,
|
||||||
|
([isBlocked, blockedComment]) => {
|
||||||
|
form.is_blocked = isBlocked;
|
||||||
|
form.blocked_comment = blockedComment ?? '';
|
||||||
|
form.defaults('is_blocked', isBlocked);
|
||||||
|
form.defaults('blocked_comment', blockedComment ?? '');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
const hasUnsavedAcceptanceCriteriaDraft = computed(
|
||||||
() =>
|
() =>
|
||||||
acceptanceCriteriaChecklist.value !==
|
JSON.stringify(acceptanceCriteriaItems.value) !==
|
||||||
formatChecklistLines(savedAcceptanceCriteria.value),
|
JSON.stringify(savedAcceptanceCriteria.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDirty = computed(
|
const isDirty = computed(
|
||||||
@@ -103,9 +120,10 @@ const reset = (): void => {
|
|||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
savedAcceptanceCriteria.value,
|
savedAcceptanceCriteria.value,
|
||||||
);
|
);
|
||||||
|
acceptanceCriteriaToggleError.value = '';
|
||||||
protection.clearFeedback();
|
protection.clearFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,19 +137,6 @@ const acceptanceCriteriaError = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildAcceptanceCriteriaForSubmit = () => {
|
|
||||||
const draftItems = buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return draftItems.map((item, index) => ({
|
|
||||||
...item,
|
|
||||||
...(savedAcceptanceCriteria.value[index]?.id !== undefined
|
|
||||||
? { id: savedAcceptanceCriteria.value[index].id }
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSavedTicketPayload = (
|
const buildSavedTicketPayload = (
|
||||||
acceptanceCriteria = savedAcceptanceCriteria.value,
|
acceptanceCriteria = savedAcceptanceCriteria.value,
|
||||||
) => ({
|
) => ({
|
||||||
@@ -171,10 +176,6 @@ const persistAcceptanceCriteriaToggle = async (
|
|||||||
index,
|
index,
|
||||||
checked,
|
checked,
|
||||||
);
|
);
|
||||||
const previousChecklist = formatChecklistLines(
|
|
||||||
savedAcceptanceCriteria.value,
|
|
||||||
);
|
|
||||||
const nextChecklist = formatChecklistLines(nextAcceptanceCriteria);
|
|
||||||
const csrfToken = document
|
const csrfToken = document
|
||||||
.querySelector('meta[name="csrf-token"]')
|
.querySelector('meta[name="csrf-token"]')
|
||||||
?.getAttribute('content');
|
?.getAttribute('content');
|
||||||
@@ -209,34 +210,18 @@ const persistAcceptanceCriteriaToggle = async (
|
|||||||
|
|
||||||
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
savedAcceptanceCriteria.value = nextAcceptanceCriteria;
|
||||||
|
|
||||||
if (acceptanceCriteriaChecklist.value === previousChecklist) {
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
acceptanceCriteriaChecklist.value = nextChecklist;
|
nextAcceptanceCriteria,
|
||||||
|
);
|
||||||
form.acceptance_criteria = nextAcceptanceCriteria;
|
form.acceptance_criteria = nextAcceptanceCriteria;
|
||||||
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
form.defaults('acceptance_criteria', nextAcceptanceCriteria);
|
||||||
}
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
acceptanceCriteriaToggleError.value = t(
|
acceptanceCriteriaToggleError.value = t('criteria.saveError');
|
||||||
'ticketDetail.checklistSaveFailed',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
isPersistingAcceptanceCriteriaToggle.value = false;
|
isPersistingAcceptanceCriteriaToggle.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAcceptanceCriteriaCheckboxChange = async (
|
|
||||||
index: number,
|
|
||||||
event: Event,
|
|
||||||
): Promise<void> => {
|
|
||||||
const target = event.target;
|
|
||||||
|
|
||||||
if (!(target instanceof HTMLInputElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistAcceptanceCriteriaToggle(index, target.checked);
|
|
||||||
target.checked = savedAcceptanceCriteria.value[index]?.checked ?? false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
if (
|
if (
|
||||||
!props.canEditTicket ||
|
!props.canEditTicket ||
|
||||||
@@ -246,7 +231,7 @@ const submit = (): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.acceptance_criteria = buildAcceptanceCriteriaForSubmit();
|
form.acceptance_criteria = acceptanceCriteriaItems.value;
|
||||||
|
|
||||||
protection.submit(
|
protection.submit(
|
||||||
ProjectTicketController.update({
|
ProjectTicketController.update({
|
||||||
@@ -259,7 +244,7 @@ const submit = (): void => {
|
|||||||
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
savedAcceptanceCriteria.value = normalizeAcceptanceCriteria(
|
||||||
props.ticket.acceptance_criteria,
|
props.ticket.acceptance_criteria,
|
||||||
);
|
);
|
||||||
acceptanceCriteriaChecklist.value = formatChecklistLines(
|
acceptanceCriteriaItems.value = normalizeAcceptanceCriteria(
|
||||||
savedAcceptanceCriteria.value,
|
savedAcceptanceCriteria.value,
|
||||||
);
|
);
|
||||||
Object.assign(form, savedFormData());
|
Object.assign(form, savedFormData());
|
||||||
@@ -301,85 +286,29 @@ const submit = (): void => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<div id="ticket-checklist" class="scroll-mt-6">
|
||||||
id="ticket-checklist"
|
<AcceptanceCriteriaChecklist
|
||||||
aria-labelledby="ticket-checklist-title"
|
:model-value="savedAcceptanceCriteria"
|
||||||
class="scroll-mt-6"
|
:readonly="!canEditTicket"
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
><h2
|
|
||||||
id="ticket-checklist-title"
|
|
||||||
class="text-lg font-semibold"
|
|
||||||
>
|
|
||||||
{{ t('ticketDetail.checklist') }}
|
|
||||||
</h2></CardHeader
|
|
||||||
>
|
|
||||||
<CardContent class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-if="acceptanceCriteriaPreviewItems.length > 0"
|
|
||||||
class="border-border rounded-xl border p-4 text-sm"
|
|
||||||
>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="item.id ?? index"
|
|
||||||
:style="{
|
|
||||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
|
||||||
}"
|
|
||||||
class="flex min-w-0 items-start gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:id="`acceptance-criteria-${item.id ?? index}`"
|
|
||||||
:checked="item.checked"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
!canEditTicket ||
|
|
||||||
form.processing ||
|
form.processing ||
|
||||||
isPersistingAcceptanceCriteriaToggle ||
|
isPersistingAcceptanceCriteriaToggle ||
|
||||||
hasUnsavedAcceptanceCriteriaDraft
|
hasUnsavedAcceptanceCriteriaDraft
|
||||||
"
|
"
|
||||||
class="border-input text-primary focus:ring-ring mt-3.5 size-4 shrink-0 rounded"
|
:saving="
|
||||||
@change="
|
form.processing || isPersistingAcceptanceCriteriaToggle
|
||||||
onAcceptanceCriteriaCheckboxChange(
|
|
||||||
index,
|
|
||||||
$event,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:allow-editing="false"
|
||||||
|
persist-toggles
|
||||||
|
:dirty="hasUnsavedAcceptanceCriteriaDraft"
|
||||||
|
:error="acceptanceCriteriaToggleError"
|
||||||
|
@toggle="persistAcceptanceCriteriaToggle"
|
||||||
/>
|
/>
|
||||||
<label
|
|
||||||
:for="`acceptance-criteria-${item.id ?? index}`"
|
|
||||||
class="min-h-control wrap-anywhere flex min-w-0 flex-1 items-center py-2"
|
|
||||||
>
|
|
||||||
{{ item.text }}
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.noChecklist') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="hasUnsavedAcceptanceCriteriaDraft"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
{{ t('ticketDetail.saveChecklistFirst') }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="acceptanceCriteriaToggleError"
|
|
||||||
role="alert"
|
|
||||||
class="text-destructive text-sm"
|
|
||||||
>
|
|
||||||
{{ acceptanceCriteriaToggleError }}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
<details
|
<details
|
||||||
v-if="canEditTicket"
|
v-if="canEditTicket"
|
||||||
id="ticket-edit"
|
id="ticket-edit"
|
||||||
|
ref="editor"
|
||||||
class="bg-card group rounded-xl border shadow-sm"
|
class="bg-card group rounded-xl border shadow-sm"
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
@@ -416,20 +345,13 @@ const submit = (): void => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<TicketFormField
|
<AcceptanceCriteriaChecklist
|
||||||
id="acceptance_criteria_checklist"
|
v-model="acceptanceCriteriaItems"
|
||||||
v-model="acceptanceCriteriaChecklist"
|
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.checklistEditor')"
|
|
||||||
:hint="t('ticketForm.checklistHelp')"
|
|
||||||
:placeholder="
|
|
||||||
t('ticketDetail.checklistPlaceholder')
|
|
||||||
"
|
|
||||||
:error="acceptanceCriteriaError"
|
|
||||||
:disabled="
|
:disabled="
|
||||||
form.processing ||
|
form.processing ||
|
||||||
isPersistingAcceptanceCriteriaToggle
|
isPersistingAcceptanceCriteriaToggle
|
||||||
"
|
"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #metadata>
|
<template #metadata>
|
||||||
@@ -459,44 +381,6 @@ const submit = (): void => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</TicketFormFields>
|
</TicketFormFields>
|
||||||
<fieldset
|
|
||||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
|
||||||
:disabled="
|
|
||||||
form.processing ||
|
|
||||||
isPersistingAcceptanceCriteriaToggle
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<legend class="px-2 text-sm font-semibold">
|
|
||||||
{{ t('ticketForm.blocker') }}
|
|
||||||
</legend>
|
|
||||||
<div class="min-h-control flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
id="is_blocked"
|
|
||||||
v-model="form.is_blocked"
|
|
||||||
type="checkbox"
|
|
||||||
:aria-invalid="Boolean(form.errors.is_blocked)"
|
|
||||||
aria-describedby="is_blocked-error"
|
|
||||||
class="border-input text-primary focus-visible:ring-ring size-4 rounded focus-visible:ring-2"
|
|
||||||
/>
|
|
||||||
<Label for="is_blocked">{{
|
|
||||||
t('ticketDetail.blocked')
|
|
||||||
}}</Label>
|
|
||||||
</div>
|
|
||||||
<InputError
|
|
||||||
id="is_blocked-error"
|
|
||||||
:message="form.errors.is_blocked"
|
|
||||||
/>
|
|
||||||
<TicketFormField
|
|
||||||
id="blocked_comment"
|
|
||||||
v-model="form.blocked_comment"
|
|
||||||
kind="textarea"
|
|
||||||
:rows="3"
|
|
||||||
:label="t('ticketDetail.blockerComment')"
|
|
||||||
:placeholder="t('ticketDetail.blockerPlaceholder')"
|
|
||||||
:error="form.errors.blocked_comment"
|
|
||||||
:required="form.is_blocked"
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset
|
<fieldset
|
||||||
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
class="grid min-w-0 gap-4 rounded-xl border p-4 sm:p-5"
|
||||||
:disabled="
|
:disabled="
|
||||||
|
|||||||
@@ -1,273 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import TicketGitBranches from '@/components/tickets/detail/TicketGitBranches.vue';
|
||||||
|
import TicketGitPullRequests from '@/components/tickets/detail/TicketGitPullRequests.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import {
|
import type { TicketDetailGitProps } from '@/types/ticket-detail';
|
||||||
Card,
|
|
||||||
CardContent,
|
defineProps<TicketDetailGitProps>();
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
|
|
||||||
import { Link, useForm, usePage } from '@inertiajs/vue3';
|
|
||||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
|
||||||
import { computed, ref, watch } from 'vue';
|
|
||||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
|
||||||
import FeedbackState from '@/components/FeedbackState.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useFormProtection } from '@/composables/useFormProtection';
|
|
||||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
|
||||||
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
|
||||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
|
||||||
import type {
|
|
||||||
TicketDetailGitProps,
|
|
||||||
TicketBranchFormData,
|
|
||||||
} from '@/types/ticket-detail';
|
|
||||||
const props = defineProps<TicketDetailGitProps>();
|
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
const appName = String(usePage().props.name);
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:${props.ticket.id}:edit`;
|
|
||||||
const visibleRepositoryBranches = computed(() =>
|
|
||||||
props.repositoryBranches.filter(
|
|
||||||
(repositoryBranch) =>
|
|
||||||
repositoryBranch.branches.length > 0 ||
|
|
||||||
repositoryBranch.error !== null ||
|
|
||||||
!repositoryBranch.repository_connection.has_personal_access,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleRepositoryPullRequests = computed(() =>
|
|
||||||
props.repositoryPullRequests.filter(
|
|
||||||
(repositoryPullRequest) =>
|
|
||||||
repositoryPullRequest.pull_requests.length > 0 ||
|
|
||||||
repositoryPullRequest.error !== null ||
|
|
||||||
!repositoryPullRequest.repository_connection.has_personal_access,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
|
||||||
props.repositoryBranches.map(
|
|
||||||
(repositoryBranch) => repositoryBranch.repository_connection,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const canCreateBranches = computed(
|
|
||||||
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
|
|
||||||
);
|
|
||||||
const defaultBranchRepositoryConnectionId = computed(() =>
|
|
||||||
String(
|
|
||||||
linkedRepositoryConnections.value.find(
|
|
||||||
(repositoryConnection) => repositoryConnection.has_personal_access,
|
|
||||||
)?.id ??
|
|
||||||
linkedRepositoryConnections.value[0]?.id ??
|
|
||||||
'',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isCreateBranchDialogOpen = ref(false);
|
|
||||||
const isCheckingBranchName = ref(false);
|
|
||||||
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
|
|
||||||
|
|
||||||
const branchCreationForm = useForm<TicketBranchFormData>(`${draftKey}:branch`, {
|
|
||||||
branch_name: props.suggestedBranchName,
|
|
||||||
repository_connection_id: defaultBranchRepositoryConnectionId.value,
|
|
||||||
});
|
|
||||||
const branchProtection = useFormProtection(branchCreationForm, {
|
|
||||||
isDirty: () => isCreateBranchDialogOpen.value && branchCreationForm.isDirty,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateBranchDialogOpen = (open: boolean): void => {
|
|
||||||
if (!open && !branchProtection.confirmLeave()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isCreateBranchDialogOpen.value = open;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedBranchRepositoryConnection = computed(
|
|
||||||
(): RepositoryConnectionModel | null =>
|
|
||||||
linkedRepositoryConnections.value.find(
|
|
||||||
(repositoryConnection) =>
|
|
||||||
String(repositoryConnection.id) ===
|
|
||||||
branchCreationForm.repository_connection_id,
|
|
||||||
) ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedBranchRepositoryHasPersonalAccess = computed(
|
|
||||||
() =>
|
|
||||||
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initializeBranchCreationForm = (): void => {
|
|
||||||
if (branchCreationForm.isDirty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
branchCreationForm.clearErrors();
|
|
||||||
branchCreationForm.branch_name = props.suggestedBranchName;
|
|
||||||
branchCreationForm.repository_connection_id =
|
|
||||||
defaultBranchRepositoryConnectionId.value;
|
|
||||||
branchNameValidation.value = null;
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => isCreateBranchDialogOpen.value,
|
|
||||||
(isOpen) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
branchCreationForm.clearErrors();
|
|
||||||
branchNameValidation.value = null;
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeBranchCreationForm();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[
|
|
||||||
() => isCreateBranchDialogOpen.value,
|
|
||||||
() => branchCreationForm.repository_connection_id,
|
|
||||||
() => branchCreationForm.branch_name,
|
|
||||||
],
|
|
||||||
(
|
|
||||||
[isOpen, repositoryConnectionId, branchName],
|
|
||||||
_previousValues,
|
|
||||||
onCleanup,
|
|
||||||
) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repositoryConnection = selectedBranchRepositoryConnection.value;
|
|
||||||
|
|
||||||
if (repositoryConnection === null || repositoryConnectionId === '') {
|
|
||||||
branchNameValidation.value = null;
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!repositoryConnection.has_personal_access) {
|
|
||||||
branchNameValidation.value = {
|
|
||||||
is_valid: false,
|
|
||||||
message: t('ticketDetail.connectGitFirst'),
|
|
||||||
};
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (branchName.trim() === '') {
|
|
||||||
branchNameValidation.value = {
|
|
||||||
is_valid: false,
|
|
||||||
message: t('ticketDetail.branchRequired'),
|
|
||||||
};
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const timeoutId = window.setTimeout(async () => {
|
|
||||||
isCheckingBranchName.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
ProjectTicketBranchController.validate.url(
|
|
||||||
{
|
|
||||||
project: props.project.id,
|
|
||||||
ticket: props.ticket.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
branch_name: branchName,
|
|
||||||
repository_connection_id:
|
|
||||||
repositoryConnectionId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
},
|
|
||||||
method: 'GET',
|
|
||||||
signal: abortController.signal,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Branch validation failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
branchNameValidation.value =
|
|
||||||
(await response.json()) as BranchNameValidationModel;
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
error instanceof DOMException &&
|
|
||||||
error.name === 'AbortError'
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
branchNameValidation.value = {
|
|
||||||
is_valid: false,
|
|
||||||
message: t('ticketDetail.branchValidationFailed', {
|
|
||||||
app: appName,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (!abortController.signal.aborted) {
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
abortController.abort();
|
|
||||||
window.clearTimeout(timeoutId);
|
|
||||||
isCheckingBranchName.value = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitBranchCreation = (): void => {
|
|
||||||
if (
|
|
||||||
!canCreateBranches.value ||
|
|
||||||
!selectedBranchRepositoryHasPersonalAccess.value ||
|
|
||||||
branchNameValidation.value?.is_valid !== true
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
branchProtection.submit(
|
|
||||||
ProjectTicketBranchController.store({
|
|
||||||
project: props.project.id,
|
|
||||||
ticket: props.ticket.id,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
isCreateBranchDialogOpen.value = false;
|
|
||||||
},
|
|
||||||
preserveScroll: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -279,380 +18,18 @@ const submitBranchCreation = (): void => {
|
|||||||
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
<h2 id="ticket-git-title" class="text-lg font-semibold">
|
||||||
{{ t('ticketDetail.git') }}
|
{{ t('ticketDetail.git') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-6">
|
<div class="grid min-w-0 gap-6">
|
||||||
<Card class="h-fit">
|
<TicketGitBranches
|
||||||
<CardHeader
|
:ticket="ticket"
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
:project="project"
|
||||||
>
|
:can-edit-ticket="canEditTicket"
|
||||||
<div class="space-y-1.5">
|
:repository-branches="repositoryBranches"
|
||||||
<CardTitle>{{ t('ticketDetail.branches') }}</CardTitle>
|
:suggested-branch-name="suggestedBranchName"
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.branchesDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
:open="isCreateBranchDialogOpen"
|
|
||||||
@update:open="updateBranchDialogOpen"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
v-if="canCreateBranches"
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
class="gap-2"
|
|
||||||
@click="isCreateBranchDialogOpen = true"
|
|
||||||
>
|
|
||||||
<GitBranchPlus class="size-4" />{{
|
|
||||||
t('ticketDetail.createBranch')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<DialogContent class="sm:max-w-lg">
|
|
||||||
<DialogHeader class="space-y-3">
|
|
||||||
<DialogTitle>{{
|
|
||||||
t('ticketDetail.createBranchTitle')
|
|
||||||
}}</DialogTitle>
|
|
||||||
<DialogDescription>{{
|
|
||||||
t('ticketDetail.createBranchDescription')
|
|
||||||
}}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="grid gap-4"
|
|
||||||
@submit.prevent="submitBranchCreation"
|
|
||||||
>
|
|
||||||
<FormFeedback
|
|
||||||
:state="branchProtection.state.value"
|
|
||||||
@retry="submitBranchCreation"
|
|
||||||
/>
|
/>
|
||||||
<div class="grid min-w-0 gap-2">
|
<TicketGitPullRequests
|
||||||
<Label for="repository_connection_id">{{
|
:repository-pull-requests="repositoryPullRequests"
|
||||||
t('ticketDetail.repository')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="repository_connection_id"
|
|
||||||
v-model="
|
|
||||||
branchCreationForm.repository_connection_id
|
|
||||||
"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing
|
|
||||||
"
|
|
||||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="repositoryConnection in linkedRepositoryConnections"
|
|
||||||
:key="repositoryConnection.id"
|
|
||||||
:value="
|
|
||||||
String(repositoryConnection.id)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
branchCreationForm.errors
|
|
||||||
.repository_connection_id
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="branch_name">{{
|
|
||||||
t('ticketDetail.branchName')
|
|
||||||
}}</Label>
|
|
||||||
<Input
|
|
||||||
id="branch_name"
|
|
||||||
v-model="branchCreationForm.branch_name"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing ||
|
|
||||||
!selectedBranchRepositoryHasPersonalAccess
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
branchCreationForm.errors
|
|
||||||
.branch_name
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-xl border px-4 py-3"
|
|
||||||
:class="
|
|
||||||
branchNameValidation?.is_valid
|
|
||||||
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
|
|
||||||
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<CheckCircle2
|
|
||||||
v-if="
|
|
||||||
branchNameValidation?.is_valid
|
|
||||||
"
|
|
||||||
class="mt-0.5 size-4 shrink-0"
|
|
||||||
/>
|
|
||||||
<XCircle
|
|
||||||
v-else-if="
|
|
||||||
branchNameValidation !== null
|
|
||||||
"
|
|
||||||
class="mt-0.5 size-4 shrink-0"
|
|
||||||
/>
|
|
||||||
<p class="text-sm">
|
|
||||||
{{
|
|
||||||
isCheckingBranchName
|
|
||||||
? t(
|
|
||||||
'ticketDetail.checkingBranch',
|
|
||||||
)
|
|
||||||
: (branchNameValidation?.message ??
|
|
||||||
t(
|
|
||||||
'ticketDetail.selectRepository',
|
|
||||||
))
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!selectedBranchRepositoryHasPersonalAccess &&
|
|
||||||
selectedBranchRepositoryConnection !==
|
|
||||||
null
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border border-dashed px-4 py-4"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.branchAccess') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="mt-3"
|
|
||||||
>
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
@click="updateBranchDialogOpen(false)"
|
|
||||||
>{{ t('common.cancel') }}</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="
|
|
||||||
branchCreationForm.processing ||
|
|
||||||
isCheckingBranchName ||
|
|
||||||
branchNameValidation?.is_valid !==
|
|
||||||
true
|
|
||||||
"
|
|
||||||
>{{
|
|
||||||
t('ticketDetail.createNow')
|
|
||||||
}}</Button
|
|
||||||
>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<FeedbackState
|
|
||||||
v-if="visibleRepositoryBranches.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('tickets.noBranches')"
|
|
||||||
:description="t('tickets.noBranchesDescription')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="repositoryBranch in visibleRepositoryBranches"
|
|
||||||
:key="repositoryBranch.repository_connection.id"
|
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.provider_label
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="font-medium">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.repository_full_name
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
repositoryBranch.repository_connection
|
|
||||||
.base_url
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!repositoryBranch.repository_connection
|
|
||||||
.has_personal_access
|
|
||||||
"
|
|
||||||
class="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.inspectBranches') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button as-child variant="outline" size="sm">
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="repositoryBranch.error"
|
|
||||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
||||||
>
|
|
||||||
{{ repositoryBranch.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
v-for="branch in repositoryBranch.branches"
|
|
||||||
:key="branch.name"
|
|
||||||
variant="outline"
|
|
||||||
class="px-3 py-1"
|
|
||||||
>
|
|
||||||
{{ branch.name }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.pullRequests') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.pullRequestsDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<FeedbackState
|
|
||||||
v-if="visibleRepositoryPullRequests.length === 0"
|
|
||||||
state="empty"
|
|
||||||
:title="t('tickets.noPullRequests')"
|
|
||||||
:description="t('tickets.noPullRequestsDescription')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
|
|
||||||
:key="
|
|
||||||
repositoryPullRequest.repository_connection.id
|
|
||||||
"
|
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest
|
|
||||||
.repository_connection
|
|
||||||
.provider_label
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
<span class="font-medium">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest
|
|
||||||
.repository_connection
|
|
||||||
.repository_full_name
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
|
||||||
{{
|
|
||||||
repositoryPullRequest.repository_connection
|
|
||||||
.base_url
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
!repositoryPullRequest.repository_connection
|
|
||||||
.has_personal_access
|
|
||||||
"
|
|
||||||
class="mt-3 space-y-3"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
{{ t('ticketDetail.inspectPullRequests') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button as-child variant="outline" size="sm">
|
|
||||||
<Link :href="editGitConnections()">{{
|
|
||||||
t('ticketDetail.openGitConnections')
|
|
||||||
}}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="repositoryPullRequest.error"
|
|
||||||
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
|
||||||
>
|
|
||||||
{{ repositoryPullRequest.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="mt-3 space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
|
||||||
:key="pullRequest.html_url"
|
|
||||||
class="border-border/70 rounded-xl border px-4 py-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge variant="outline">
|
|
||||||
#{{ pullRequest.number }}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{{ pullRequest.status }}
|
|
||||||
</Badge>
|
|
||||||
<a
|
|
||||||
:href="pullRequest.html_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ pullRequest.title }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground mt-2 text-sm"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
t('ticketDetail.sourceBranch', {
|
|
||||||
name: pullRequest.source_branch,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const sections = [
|
|||||||
'description',
|
'description',
|
||||||
'checklist',
|
'checklist',
|
||||||
'metadata',
|
'metadata',
|
||||||
|
'blocker',
|
||||||
'relationships',
|
'relationships',
|
||||||
'history',
|
'history',
|
||||||
'git',
|
'git',
|
||||||
|
|||||||
@@ -8,11 +8,21 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { UserAvatar } from '@/components/ui/avatar';
|
||||||
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
import type { TicketDetailHistoryProps } from '@/types/ticket-detail';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
import type { TicketWorklogEntryType } from '@/types/models/ticket-worklog-entry-model';
|
||||||
defineProps<TicketDetailHistoryProps>();
|
defineProps<TicketDetailHistoryProps>();
|
||||||
const { t, date } = useLocalization();
|
const { t, date } = useLocalization();
|
||||||
const formatDateTime = (value: string): string =>
|
const formatDateTime = (value: string): string =>
|
||||||
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
date(new Date(value), { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const entryTypeLabels: Record<TicketWorklogEntryType, TranslationKey> = {
|
||||||
|
created: 'ticketDetail.historyType.created',
|
||||||
|
link_removed: 'ticketDetail.historyType.linkRemoved',
|
||||||
|
link_updated: 'ticketDetail.historyType.linkUpdated',
|
||||||
|
status_changed: 'ticketDetail.historyType.statusChanged',
|
||||||
|
updated: 'ticketDetail.historyType.updated',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,31 +49,70 @@ const formatDateTime = (value: string): string =>
|
|||||||
{{ t('ticketDetail.noHistory') }}
|
{{ t('ticketDetail.noHistory') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-4">
|
<ol v-else class="relative grid gap-0" role="list">
|
||||||
<div
|
<li
|
||||||
v-for="worklogEntry in ticket.worklog_entries"
|
v-for="worklogEntry in ticket.worklog_entries"
|
||||||
:key="worklogEntry.id"
|
:key="worklogEntry.id"
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
class="border-border relative grid min-w-0 grid-cols-[2.5rem_minmax(0,1fr)] gap-3 border-l pb-6 last:border-transparent last:pb-0 sm:grid-cols-[3rem_minmax(0,1fr)] sm:gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-card relative -left-px flex size-10 -translate-x-1/2 items-center justify-center rounded-full sm:size-12"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="worklogEntry.actor"
|
||||||
|
:user="worklogEntry.actor"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-full border text-sm font-semibold"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<article
|
||||||
|
class="bg-muted/20 min-w-0 rounded-xl border p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-foreground text-sm font-semibold"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{{ worklogEntry.entry_type }}
|
|
||||||
</Badge>
|
|
||||||
<span class="text-foreground text-sm font-medium">
|
|
||||||
{{
|
{{
|
||||||
worklogEntry.actor?.name ??
|
worklogEntry.actor?.name ??
|
||||||
t('ticketDetail.system')
|
t('ticketDetail.system')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
entryTypeLabels[
|
||||||
|
worklogEntry.entry_type
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-foreground mt-3 text-sm">
|
<time
|
||||||
|
:datetime="worklogEntry.created_at"
|
||||||
|
class="text-muted-foreground shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
formatDateTime(worklogEntry.created_at)
|
||||||
|
}}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p class="text-foreground mt-3 text-sm leading-6">
|
||||||
{{ worklogEntry.summary }}
|
{{ worklogEntry.summary }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground mt-2 text-xs">
|
</article>
|
||||||
{{ formatDateTime(worklogEntry.created_at) }}
|
</li>
|
||||||
</p>
|
</ol>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,33 +1,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import { ArrowRight, GitFork, Link2, Plus } from 'lucide-vue-next';
|
||||||
Card,
|
import { computed, nextTick, useTemplateRef } from 'vue';
|
||||||
CardContent,
|
import { store } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
CardDescription,
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
|
|
||||||
import { Form, Link } from '@inertiajs/vue3';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import TicketTreeList from '@/components/TicketTreeList.vue';
|
import TicketTreeList from '@/components/TicketTreeList.vue';
|
||||||
|
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
||||||
|
import TicketRelationshipLink from '@/components/tickets/detail/TicketRelationshipLink.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
import type { TicketDetailRelationshipsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
const props = defineProps<TicketDetailRelationshipsProps>();
|
const props = defineProps<TicketDetailRelationshipsProps>();
|
||||||
|
defineEmits<{ editParent: [] }>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const linksHeading = useTemplateRef<HTMLHeadingElement>('linksHeading');
|
||||||
const currentParentTicket = computed(
|
const currentParentTicket = computed(
|
||||||
() =>
|
() =>
|
||||||
props.ticket.parent_ticket ??
|
props.ticket.parent_ticket ??
|
||||||
props.parentTicketOptions.find(
|
props.parentTicketOptions.find(
|
||||||
(parentTicket) => parentTicket.id === props.ticket.parent_ticket_id,
|
(parent) => parent.id === props.ticket.parent_ticket_id,
|
||||||
) ??
|
) ??
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const canAddLink = computed(
|
||||||
|
() =>
|
||||||
|
props.canEditTicket &&
|
||||||
|
props.ticketLinkTypes.length > 0 &&
|
||||||
|
props.ticketLinkOptions.length > 0,
|
||||||
|
);
|
||||||
|
const form = useForm({
|
||||||
|
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||||
|
linked_ticket_id: '',
|
||||||
|
});
|
||||||
|
const protection = useFormProtection(form, {
|
||||||
|
isDirty: () => props.canEditTicket && form.isDirty,
|
||||||
|
});
|
||||||
|
const selectedType = computed(() =>
|
||||||
|
props.ticketLinkTypes.find(
|
||||||
|
(type) => String(type.id) === String(form.ticket_link_type_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedTicket = computed(() =>
|
||||||
|
props.ticketLinkOptions.find(
|
||||||
|
(ticket) => String(ticket.id) === String(form.linked_ticket_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function focusLinks(): Promise<void> {
|
||||||
|
await nextTick();
|
||||||
|
linksHeading.value?.focus();
|
||||||
|
}
|
||||||
|
function submit(): void {
|
||||||
|
if (!canAddLink.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
store({ project: props.project.id, ticket: props.ticket.id }),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.linked_ticket_id = '';
|
||||||
|
form.clearErrors();
|
||||||
|
void focusLinks();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -39,184 +82,223 @@ const currentParentTicket = computed(
|
|||||||
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
<h2 id="ticket-relationships-title" class="text-lg font-semibold">
|
||||||
{{ t('ticketDetail.relationships') }}
|
{{ t('ticketDetail.relationships') }}
|
||||||
</h2>
|
</h2>
|
||||||
<Card v-if="ticket.ticket_type?.is_parent" class="h-fit">
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<CardTitle>{{ t('ticketDetail.children') }}</CardTitle>
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
<CardDescription>{{
|
>
|
||||||
t('ticketDetail.childrenDescription')
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
}}</CardDescription>
|
<GitFork class="text-primary size-5" aria-hidden="true" />{{
|
||||||
</CardHeader>
|
t('ticketRelationships.hierarchy')
|
||||||
|
}}
|
||||||
<CardContent>
|
</h3>
|
||||||
<div
|
<Button
|
||||||
v-if="childTicketTree.length === 0"
|
v-if="canEditTicket"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('editParent')"
|
||||||
|
>{{ t('ticketRelationships.editParent') }}</Button
|
||||||
>
|
>
|
||||||
{{ t('ticketDetail.noChildren') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketTreeList
|
|
||||||
v-else
|
|
||||||
:nodes="childTicketTree"
|
|
||||||
:project-id="project.id"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-else-if="currentParentTicket !== null" class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.parent') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.parentDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
<CardContent>
|
<div class="grid min-w-0 gap-2">
|
||||||
|
<h4
|
||||||
|
class="text-muted-foreground text-xs font-semibold uppercase tracking-wide"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.parent') }}
|
||||||
|
</h4>
|
||||||
<Link
|
<Link
|
||||||
|
v-if="currentParentTicket"
|
||||||
:href="
|
:href="
|
||||||
showTicket({
|
showTicket({
|
||||||
project: project.id,
|
project: project.id,
|
||||||
ticket: currentParentTicket.id,
|
ticket: currentParentTicket.id,
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
class="border-border hover:border-primary/60 block rounded-xl border px-4 py-4 transition"
|
class="border-primary/20 bg-primary/5 hover:border-primary focus-visible:ring-ring/35 group grid min-w-0 gap-2 rounded-xl border p-4 outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-primary font-mono text-xs font-semibold"
|
||||||
|
>{{ currentParentTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="group-hover:text-primary text-sm font-medium"
|
||||||
|
>{{ currentParentTicket.title }}</span
|
||||||
>
|
>
|
||||||
<div class="font-medium">
|
|
||||||
{{ currentParentTicket.ticket_key }}
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground mt-1 text-sm">
|
|
||||||
{{ currentParentTicket.title }}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
<p
|
||||||
</Card>
|
v-else
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
<Card class="h-fit">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{{ t('ticketDetail.linkedTickets') }}</CardTitle>
|
|
||||||
<CardDescription>{{
|
|
||||||
t('ticketDetail.linkedTicketsDescription')
|
|
||||||
}}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<Form
|
|
||||||
v-if="
|
|
||||||
canEditTicket &&
|
|
||||||
ticketLinkTypes.length > 0 &&
|
|
||||||
ticketLinkOptions.length > 0
|
|
||||||
"
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketLinkController.store.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<div class="grid min-w-0 gap-2">
|
{{ t('ticketDetail.noParent') }}
|
||||||
<Label for="ticket_link_type_id">{{
|
</p>
|
||||||
t('ticketDetail.linkType')
|
<p
|
||||||
}}</Label>
|
v-if="canEditTicket"
|
||||||
<select
|
class="text-muted-foreground text-xs"
|
||||||
id="ticket_link_type_id"
|
|
||||||
name="ticket_link_type_id"
|
|
||||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
|
||||||
>
|
>
|
||||||
<option
|
{{ t('ticketRelationships.parentHelp') }}
|
||||||
v-for="ticketLinkType in ticketLinkTypes"
|
</p>
|
||||||
:key="ticketLinkType.id"
|
|
||||||
:value="ticketLinkType.id"
|
|
||||||
>
|
|
||||||
{{ ticketLinkType.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.ticket_link_type_id" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-2">
|
|
||||||
<Label for="linked_ticket_id">{{
|
|
||||||
t('ticketDetail.linkedTicket')
|
|
||||||
}}</Label>
|
|
||||||
<select
|
|
||||||
id="linked_ticket_id"
|
|
||||||
name="linked_ticket_id"
|
|
||||||
class="min-h-control border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketLinkOption in ticketLinkOptions"
|
|
||||||
:key="ticketLinkOption.id"
|
|
||||||
:value="ticketLinkOption.id"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.linked_ticket_id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">{{
|
|
||||||
t('ticketDetail.saveLink')
|
|
||||||
}}</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="linkedTickets.length === 0"
|
v-if="ticket.ticket_type?.is_parent"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
class="grid min-w-0 gap-3 border-t pt-5"
|
||||||
>
|
|
||||||
{{ t('ticketDetail.noLinks') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="linkedTicket in linkedTickets"
|
|
||||||
:key="linkedTicket.id"
|
|
||||||
class="border-border rounded-xl border px-4 py-4"
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary">
|
<h4 class="text-sm font-semibold">
|
||||||
{{ linkedTicket.ticket_link_type.label }}
|
{{ t('ticketDetail.children') }}
|
||||||
</Badge>
|
</h4>
|
||||||
<Link
|
<Badge variant="secondary">{{
|
||||||
:href="
|
childTicketTree.length
|
||||||
showTicket({
|
}}</Badge>
|
||||||
project: project.id,
|
|
||||||
ticket: linkedTicket.linked_ticket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="text-foreground hover:text-primary font-medium transition"
|
|
||||||
>
|
|
||||||
{{ linkedTicket.linked_ticket.ticket_key }}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
<p class="text-muted-foreground text-xs">
|
||||||
{{ linkedTicket.linked_ticket.title }}
|
{{ t('ticketRelationships.childrenHelp') }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
<Form
|
v-if="childTicketTree.length === 0"
|
||||||
v-if="canEditTicket"
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
v-bind="
|
|
||||||
ProjectTicketLinkController.destroy.form({
|
|
||||||
project: project.id,
|
|
||||||
ticket: ticket.id,
|
|
||||||
ticketLink: linkedTicket.id,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing: deleting }"
|
|
||||||
>
|
>
|
||||||
|
{{ t('ticketDetail.noChildren') }}
|
||||||
|
</p>
|
||||||
|
<TicketTreeList
|
||||||
|
v-else
|
||||||
|
:nodes="childTicketTree"
|
||||||
|
:project-id="project.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="min-w-0 gap-5 overflow-hidden">
|
||||||
|
<CardHeader>
|
||||||
|
<h3
|
||||||
|
ref="linksHeading"
|
||||||
|
tabindex="-1"
|
||||||
|
class="focus-visible:ring-ring/35 flex flex-wrap items-center gap-2 rounded-md font-semibold outline-none focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<Link2 class="text-primary size-5" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.linkedTickets')
|
||||||
|
}}<Badge variant="secondary">{{
|
||||||
|
linkedTickets.length
|
||||||
|
}}</Badge>
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
{{ t('ticketDetail.linkedTicketsDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid min-w-0 gap-5">
|
||||||
|
<p
|
||||||
|
v-if="linkedTickets.length === 0"
|
||||||
|
class="text-muted-foreground rounded-xl border border-dashed p-4 text-sm"
|
||||||
|
>
|
||||||
|
{{ t('ticketDetail.noLinks') }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<TicketRelationshipLink
|
||||||
|
v-for="link in linkedTickets"
|
||||||
|
:key="link.id"
|
||||||
|
:link="link"
|
||||||
|
:project-id="project.id"
|
||||||
|
:ticket-id="ticket.id"
|
||||||
|
:ticket-key="ticket.ticket_key"
|
||||||
|
:can-edit="canEditTicket"
|
||||||
|
@removed="focusLinks"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
v-if="canAddLink"
|
||||||
|
class="bg-muted/30 grid min-w-0 gap-4 rounded-xl border p-4"
|
||||||
|
@submit.prevent="submit"
|
||||||
|
>
|
||||||
|
<h4 class="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketRelationships.addLink')
|
||||||
|
}}
|
||||||
|
</h4>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="submit"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||||
|
>
|
||||||
|
<TicketFormField
|
||||||
|
id="ticket_link_type_id"
|
||||||
|
v-model="form.ticket_link_type_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkType')"
|
||||||
|
:options="
|
||||||
|
ticketLinkTypes.map((type) => ({
|
||||||
|
value: type.id,
|
||||||
|
label: type.label,
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:error="form.errors.ticket_link_type_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TicketFormField
|
||||||
|
id="linked_ticket_id"
|
||||||
|
v-model="form.linked_ticket_id"
|
||||||
|
kind="select"
|
||||||
|
:label="t('ticketDetail.linkedTicket')"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
label: t(
|
||||||
|
'ticketRelationships.selectTicket',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...ticketLinkOptions.map((ticket) => ({
|
||||||
|
value: ticket.id,
|
||||||
|
label: `${ticket.ticket_key} — ${ticket.title}`,
|
||||||
|
})),
|
||||||
|
]"
|
||||||
|
:error="form.errors.linked_ticket_id"
|
||||||
|
:disabled="form.processing"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<p
|
||||||
|
v-if="selectedType && selectedTicket"
|
||||||
|
aria-live="polite"
|
||||||
|
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs">{{
|
||||||
|
ticket.ticket_key
|
||||||
|
}}</span
|
||||||
|
><span>{{ selectedType.label }}</span
|
||||||
|
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||||
|
class="font-mono text-xs"
|
||||||
|
>{{ selectedTicket.ticket_key }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('ticketRelationships.directionHelp') }}
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
:disabled="form.processing || !selectedTicket"
|
||||||
size="sm"
|
class="sm:justify-self-start"
|
||||||
:disabled="deleting"
|
>{{
|
||||||
>{{ t('ticketDetail.removeLink') }}</Button
|
form.processing
|
||||||
|
? t('forms.saving')
|
||||||
|
: t('ticketDetail.saveLink')
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
</Form>
|
</form>
|
||||||
</div>
|
<p
|
||||||
</div>
|
v-else-if="canEditTicket"
|
||||||
|
class="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
ticketLinkTypes.length === 0
|
||||||
|
? 'ticketRelationships.noTypes'
|
||||||
|
: 'ticketRelationships.noTargets',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ExternalLink, GitBranch } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import TicketBranchCreateDialog from '@/components/tickets/detail/TicketBranchCreateDialog.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
|
import type { TicketGitBranchesProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketGitBranchesProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const visibleRepositories = computed(() =>
|
||||||
|
props.repositoryBranches.filter(
|
||||||
|
(repositoryBranch) =>
|
||||||
|
repositoryBranch.branches.length > 0 ||
|
||||||
|
repositoryBranch.error !== null ||
|
||||||
|
!repositoryBranch.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const repositoryConnections = computed<RepositoryConnectionModel[]>(() =>
|
||||||
|
props.repositoryBranches.map(
|
||||||
|
(repositoryBranch) => repositoryBranch.repository_connection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const canCreateBranch = computed(
|
||||||
|
() => props.canEditTicket && repositoryConnections.value.length > 0,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||||
|
<CardHeader
|
||||||
|
class="flex flex-col gap-4 border-b sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1.5">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<GitBranch
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('ticketDetail.branches') }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>{ t('ticketDetail.branchesDescription') }}</CardDescription
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TicketBranchCreateDialog
|
||||||
|
v-if="canCreateBranch"
|
||||||
|
:ticket="ticket"
|
||||||
|
:project="project"
|
||||||
|
:repository-connections="repositoryConnections"
|
||||||
|
:suggested-branch-name="suggestedBranchName"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="grid gap-4 pt-6">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="repositoryBranches.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.noLinkedRepositories')"
|
||||||
|
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="visibleRepositories.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noBranches')"
|
||||||
|
:description="t('tickets.noBranchesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="repositoryBranch in visibleRepositories"
|
||||||
|
v-else
|
||||||
|
:key="repositoryBranch.repository_connection.id"
|
||||||
|
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<header class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_url
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openRepositoryExternal', {
|
||||||
|
repository:
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name,
|
||||||
|
provider:
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
repositoryBranch.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ repositoryBranch.repository_connection.base_url }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-if="
|
||||||
|
!repositoryBranch.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.gitAccessRequired')"
|
||||||
|
:description="t('ticketDetail.inspectBranches')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="repositoryBranch.error"
|
||||||
|
state="error"
|
||||||
|
:title="t('ticketDetail.branchesUnavailable')"
|
||||||
|
:description="repositoryBranch.error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-else class="grid min-w-0 gap-2 sm:grid-cols-2">
|
||||||
|
<li
|
||||||
|
v-for="branch in repositoryBranch.branches"
|
||||||
|
:key="branch.name"
|
||||||
|
class="bg-muted/30 flex min-w-0 items-center gap-2 rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<GitBranch
|
||||||
|
class="text-muted-foreground size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="wrap-anywhere font-mono text-xs">{{
|
||||||
|
branch.name
|
||||||
|
}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { ExternalLink, GitPullRequest } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||||
|
import type { TicketGitPullRequestsProps } from '@/types/ticket-detail';
|
||||||
|
|
||||||
|
const props = defineProps<TicketGitPullRequestsProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const visibleRepositories = computed(() =>
|
||||||
|
props.repositoryPullRequests.filter(
|
||||||
|
(repositoryPullRequest) =>
|
||||||
|
repositoryPullRequest.pull_requests.length > 0 ||
|
||||||
|
repositoryPullRequest.error !== null ||
|
||||||
|
!repositoryPullRequest.repository_connection.has_personal_access,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="min-w-0 gap-0 overflow-hidden">
|
||||||
|
<CardHeader class="border-b">
|
||||||
|
<CardTitle class="flex items-center gap-2">
|
||||||
|
<GitPullRequest
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('ticketDetail.pullRequests') }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>{ t('ticketDetail.pullRequestsDescription') }}</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="grid gap-4 pt-6">
|
||||||
|
<FeedbackState
|
||||||
|
v-if="repositoryPullRequests.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.noLinkedRepositories')"
|
||||||
|
:description="t('ticketDetail.noLinkedRepositoriesDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="visibleRepositories.length === 0"
|
||||||
|
state="empty"
|
||||||
|
:title="t('tickets.noPullRequests')"
|
||||||
|
:description="t('tickets.noPullRequestsDescription')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-for="repositoryPullRequest in visibleRepositories"
|
||||||
|
v-else
|
||||||
|
:key="repositoryPullRequest.repository_connection.id"
|
||||||
|
class="border-border grid min-w-0 gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<header class="grid min-w-0 gap-2">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.provider_label
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.repository_url
|
||||||
|
"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openRepositoryExternal', {
|
||||||
|
repository:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.repository_full_name,
|
||||||
|
provider:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
repositoryPullRequest.repository_connection
|
||||||
|
.repository_full_name
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
repositoryPullRequest.repository_connection.base_url
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-if="
|
||||||
|
!repositoryPullRequest.repository_connection
|
||||||
|
.has_personal_access
|
||||||
|
"
|
||||||
|
state="empty"
|
||||||
|
:title="t('ticketDetail.gitAccessRequired')"
|
||||||
|
:description="t('ticketDetail.inspectPullRequests')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as-child
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="justify-self-start"
|
||||||
|
>
|
||||||
|
<Link :href="editGitConnections()">{{
|
||||||
|
t('ticketDetail.openGitConnections')
|
||||||
|
}}</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
|
<FeedbackState
|
||||||
|
v-else-if="repositoryPullRequest.error"
|
||||||
|
state="error"
|
||||||
|
:title="t('ticketDetail.pullRequestsUnavailable')"
|
||||||
|
:description="repositoryPullRequest.error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-else class="grid min-w-0 gap-3">
|
||||||
|
<li
|
||||||
|
v-for="pullRequest in repositoryPullRequest.pull_requests"
|
||||||
|
:key="pullRequest.html_url"
|
||||||
|
class="border-border/70 bg-muted/20 grid min-w-0 gap-2 rounded-md border px-4 py-3"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
#{{ pullRequest.number }}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{{ pullRequest.status }}
|
||||||
|
</Badge>
|
||||||
|
<a
|
||||||
|
:href="pullRequest.html_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketDetail.openPullRequestExternal', {
|
||||||
|
number: pullRequest.number,
|
||||||
|
provider:
|
||||||
|
repositoryPullRequest
|
||||||
|
.repository_connection
|
||||||
|
.provider_label,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 group flex min-w-0 items-center gap-1.5 rounded-sm font-medium outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="wrap-anywhere">{{
|
||||||
|
pullRequest.title
|
||||||
|
}}</span>
|
||||||
|
<ExternalLink
|
||||||
|
class="size-3.5 shrink-0 opacity-60 transition-opacity group-hover:opacity-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{
|
||||||
|
t('ticketDetail.sourceBranch', {
|
||||||
|
name: pullRequest.source_branch,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3';
|
||||||
|
import { ArrowDownLeft, ArrowUpRight, Unlink } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { destroy } from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { show } from '@/routes/projects/tickets';
|
||||||
|
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
link: TicketLinkModel;
|
||||||
|
projectId: number;
|
||||||
|
ticketId: number;
|
||||||
|
ticketKey: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ removed: [] }>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const outgoing = computed(() => props.link.source_ticket_id === props.ticketId);
|
||||||
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
function remove(): void {
|
||||||
|
if (!props.canEdit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(
|
||||||
|
destroy({
|
||||||
|
project: props.projectId,
|
||||||
|
ticket: props.ticketId,
|
||||||
|
ticketLink: props.link.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => emit('removed'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="grid min-w-0 gap-3 rounded-xl border p-4"
|
||||||
|
:data-ticket-link="link.id"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-muted-foreground max-w-full whitespace-normal"
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
v-if="outgoing"
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/><ArrowDownLeft
|
||||||
|
v-else
|
||||||
|
class="size-3.5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
outgoing
|
||||||
|
? 'ticketRelationships.outgoing'
|
||||||
|
: 'ticketRelationships.incoming',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground font-mono text-xs">{{
|
||||||
|
ticketKey
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm font-medium">{{
|
||||||
|
link.ticket_link_type.label
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-start justify-between gap-3">
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
show({ project: projectId, ticket: link.linked_ticket.id })
|
||||||
|
"
|
||||||
|
class="hover:text-primary focus-visible:ring-ring/35 grid min-w-0 flex-1 basis-40 gap-1 rounded-md outline-none transition-colors focus-visible:ring-2"
|
||||||
|
>
|
||||||
|
<span class="text-primary font-mono text-xs font-semibold">{{
|
||||||
|
link.linked_ticket.ticket_key
|
||||||
|
}}</span>
|
||||||
|
<span class="wrap-anywhere text-sm font-medium">{{
|
||||||
|
link.linked_ticket.title
|
||||||
|
}}</span>
|
||||||
|
</Link>
|
||||||
|
<form v-if="canEdit" @submit.prevent="remove">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('ticketRelationships.removeLink', {
|
||||||
|
key: link.linked_ticket.ticket_key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:disabled="form.processing"
|
||||||
|
><Unlink class="size-4" aria-hidden="true" />{{
|
||||||
|
t('ticketDetail.removeLink')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
v-if="canEdit"
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="remove"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
|
const areaKeys = {
|
||||||
|
project_settings: 'projectCreation.areas.projectSettings',
|
||||||
|
board_columns: 'projectCreation.areas.boardColumns',
|
||||||
|
ticket_statuses: 'projectCreation.areas.ticketStatuses',
|
||||||
|
status_transitions: 'projectCreation.areas.statusTransitions',
|
||||||
|
ticket_types: 'projectCreation.areas.ticketTypes',
|
||||||
|
ticket_priorities: 'projectCreation.areas.ticketPriorities',
|
||||||
|
ticket_fields: 'projectCreation.areas.ticketFields',
|
||||||
|
ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
|
||||||
|
repository_connections: 'projectCreation.areas.repositories',
|
||||||
|
notification_actions: 'projectCreation.areas.notifications',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function useProjectTemplateAreas() {
|
||||||
|
const { t } = useLocalization();
|
||||||
|
function areaText(
|
||||||
|
value: string,
|
||||||
|
part: 'label' | 'description',
|
||||||
|
fallback = value,
|
||||||
|
): string {
|
||||||
|
const key = areaKeys[value as keyof typeof areaKeys];
|
||||||
|
return key ? t(`${key}.${part}`) : fallback;
|
||||||
|
}
|
||||||
|
return { areaText };
|
||||||
|
}
|
||||||
@@ -1,120 +1,107 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue';
|
import { computed, onScopeDispose, ref } from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
||||||
|
|
||||||
export type UseTwoFactorAuthReturn = {
|
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
||||||
qrCodeSvg: Ref<string | null>;
|
export function useTwoFactorAuth() {
|
||||||
manualSetupKey: Ref<string | null>;
|
const { t } = useLocalization();
|
||||||
recoveryCodesList: Ref<string[]>;
|
const errors = ref<string[]>([]);
|
||||||
errors: Ref<string[]>;
|
const manualSetupKey = ref<string | null>(null);
|
||||||
hasSetupData: ComputedRef<boolean>;
|
const qrCodeSvg = ref<string | null>(null);
|
||||||
clearSetupData: () => void;
|
const recoveryCodesList = ref<string[]>([]);
|
||||||
clearErrors: () => void;
|
const loading = ref(false);
|
||||||
clearTwoFactorAuthData: () => void;
|
let pending: AbortController | null = null;
|
||||||
fetchQrCode: () => Promise<void>;
|
|
||||||
fetchSetupKey: () => Promise<void>;
|
|
||||||
fetchSetupData: () => Promise<void>;
|
|
||||||
fetchRecoveryCodes: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchJson = async <T>(url: string): Promise<T> => {
|
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
if (!response.ok || response.redirected) {
|
||||||
if (!response.ok) {
|
throw new Error(String(response.status));
|
||||||
throw new Error(`Failed to fetch: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
};
|
|
||||||
|
|
||||||
const errors = ref<string[]>([]);
|
|
||||||
const manualSetupKey = ref<string | null>(null);
|
|
||||||
const qrCodeSvg = ref<string | null>(null);
|
|
||||||
const recoveryCodesList = ref<string[]>([]);
|
|
||||||
|
|
||||||
const hasSetupData = computed<boolean>(
|
|
||||||
() => qrCodeSvg.value !== null && manualSetupKey.value !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useTwoFactorAuth = (): UseTwoFactorAuthReturn => {
|
|
||||||
const fetchQrCode = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { svg } = await fetchJson<{ svg: string; url: string }>(
|
|
||||||
qrCode.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
qrCodeSvg.value = svg;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch QR code');
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSetupKey = async (): Promise<void> => {
|
function clearErrors(): void {
|
||||||
try {
|
|
||||||
const { secretKey: key } = await fetchJson<{ secretKey: string }>(
|
|
||||||
secretKey.url(),
|
|
||||||
);
|
|
||||||
|
|
||||||
manualSetupKey.value = key;
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch a setup key');
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSetupData = (): void => {
|
|
||||||
manualSetupKey.value = null;
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
clearErrors();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearErrors = (): void => {
|
|
||||||
errors.value = [];
|
errors.value = [];
|
||||||
};
|
|
||||||
|
|
||||||
const clearTwoFactorAuthData = (): void => {
|
|
||||||
clearSetupData();
|
|
||||||
clearErrors();
|
|
||||||
recoveryCodesList.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRecoveryCodes = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
clearErrors();
|
|
||||||
recoveryCodesList.value = await fetchJson<string[]>(
|
|
||||||
recoveryCodes.url(),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
errors.value.push('Failed to fetch recovery codes');
|
|
||||||
recoveryCodesList.value = [];
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const fetchSetupData = async (): Promise<void> => {
|
function clearTwoFactorAuthData(): void {
|
||||||
try {
|
pending?.abort();
|
||||||
clearErrors();
|
pending = null;
|
||||||
await Promise.all([fetchQrCode(), fetchSetupKey()]);
|
loading.value = false;
|
||||||
} catch {
|
|
||||||
qrCodeSvg.value = null;
|
|
||||||
manualSetupKey.value = null;
|
manualSetupKey.value = null;
|
||||||
|
qrCodeSvg.value = null;
|
||||||
|
recoveryCodesList.value = [];
|
||||||
|
clearErrors();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
||||||
|
clearTwoFactorAuthData();
|
||||||
|
const request = new AbortController();
|
||||||
|
pending = request;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
if (kind === 'setup') {
|
||||||
|
const [qr, key] = await Promise.all([
|
||||||
|
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
||||||
|
fetchJson<{ secretKey: string }>(
|
||||||
|
secretKey.url(),
|
||||||
|
request.signal,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (pending !== request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qrCodeSvg.value = qr.svg;
|
||||||
|
manualSetupKey.value = key.secretKey;
|
||||||
|
} else {
|
||||||
|
const codes = await fetchJson<string[]>(
|
||||||
|
recoveryCodes.url(),
|
||||||
|
request.signal,
|
||||||
|
);
|
||||||
|
if (pending === request) {
|
||||||
|
recoveryCodesList.value = codes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (pending === request && !request.signal.aborted) {
|
||||||
|
const status = error instanceof Error ? error.message : '';
|
||||||
|
errors.value = [
|
||||||
|
t(
|
||||||
|
status === '423' || status === '401' || status === '419'
|
||||||
|
? 'security.confirmAgain'
|
||||||
|
: kind === 'setup'
|
||||||
|
? 'security.setupError'
|
||||||
|
: 'security.recoveryError',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pending === request) {
|
||||||
|
pending = null;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(clearTwoFactorAuthData);
|
||||||
return {
|
return {
|
||||||
qrCodeSvg,
|
qrCodeSvg,
|
||||||
manualSetupKey,
|
manualSetupKey,
|
||||||
recoveryCodesList,
|
recoveryCodesList,
|
||||||
errors,
|
errors,
|
||||||
hasSetupData,
|
loading,
|
||||||
clearSetupData,
|
hasSetupData: computed(() =>
|
||||||
clearErrors,
|
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
||||||
|
),
|
||||||
|
clearSetupData: clearTwoFactorAuthData,
|
||||||
|
clearRecoveryCodes: clearTwoFactorAuthData,
|
||||||
clearTwoFactorAuthData,
|
clearTwoFactorAuthData,
|
||||||
fetchQrCode,
|
clearErrors,
|
||||||
fetchSetupKey,
|
fetchSetupData: () => load('setup'),
|
||||||
fetchSetupData,
|
fetchRecoveryCodes: () => load('recovery'),
|
||||||
fetchRecoveryCodes,
|
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import { Settings2 } from 'lucide-vue-next';
|
||||||
|
import { useAppNavigation } from '@/composables/useAppNavigation';
|
||||||
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { toUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
defineProps<{ projectName: string; projectKey: string }>();
|
||||||
|
const { projectSettingsItems } = useAppNavigation();
|
||||||
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-7xl flex-1 flex-col p-4"
|
||||||
|
>
|
||||||
|
<header class="gap-stack grid min-w-0">
|
||||||
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground break-all uppercase">
|
||||||
|
{{ projectKey }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.projectSettings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground break-words">
|
||||||
|
{{ t('projectSettings.description', { project: projectName }) }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 min-w-0 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
|
:aria-label="t('navigation.projectSettings')"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
v-for="item in projectSettingsItems"
|
||||||
|
:key="toUrl(item.href)"
|
||||||
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
|
:class="[
|
||||||
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
|
isCurrentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="gap-page mx-auto grid w-full min-w-0 max-w-3xl">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
Palette,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
|||||||
{
|
{
|
||||||
title: t('navigation.profile'),
|
title: t('navigation.profile'),
|
||||||
href: editProfile(),
|
href: editProfile(),
|
||||||
|
icon: UserRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.security'),
|
title: t('navigation.security'),
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.gitConnections'),
|
title: t('navigation.gitConnections'),
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.applications'),
|
title: t('navigation.applications'),
|
||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.appearance'),
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
|
icon: Palette,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-6">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||||
:title="t('navigation.settings')"
|
>
|
||||||
:description="t('navigation.settingsDescription')"
|
<header class="gap-stack grid min-w-0">
|
||||||
/>
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('settings.eyebrow') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.settings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('navigation.settingsDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
<div
|
||||||
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
class="bg-card grid min-w-0 grid-cols-1 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
<nav
|
<nav
|
||||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
:aria-label="t('navigation.settings')"
|
:aria-label="t('navigation.settings')"
|
||||||
>
|
>
|
||||||
<Button
|
<Link
|
||||||
v-for="item in sidebarNavItems"
|
v-for="item in sidebarNavItems"
|
||||||
:key="toUrl(item.href)"
|
:key="toUrl(item.href)"
|
||||||
variant="ghost"
|
|
||||||
:class="[
|
|
||||||
'shrink-0 justify-start lg:w-full',
|
|
||||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
|
||||||
]"
|
|
||||||
as-child
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
:href="item.href"
|
:href="item.href"
|
||||||
:aria-current="
|
:aria-current="
|
||||||
isCurrentOrParentUrl(item.href)
|
isCurrentOrParentUrl(item.href) ? 'page' : undefined
|
||||||
? 'page'
|
|
||||||
: undefined
|
|
||||||
"
|
"
|
||||||
|
:class="[
|
||||||
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
|
isCurrentOrParentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
<component
|
||||||
{{ item.title }}
|
:is="item.icon"
|
||||||
|
class="size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Separator class="lg:hidden" />
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="mx-auto grid w-full min-w-0 max-w-3xl gap-8">
|
||||||
<div class="min-w-0 flex-1 md:max-w-2xl">
|
|
||||||
<section class="max-w-xl space-y-12">
|
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ import type {
|
|||||||
|
|
||||||
const MAX_INDENT_LEVEL = 8;
|
const MAX_INDENT_LEVEL = 8;
|
||||||
|
|
||||||
|
export const canIndentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): boolean => {
|
||||||
|
const item = items[index];
|
||||||
|
const previous = items[index - 1];
|
||||||
|
if (!item || !previous || item.indent > previous.indent) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.slice(index, acceptanceCriteriaSubtreeEnd(items, index))
|
||||||
|
.every((descendant) => descendant.indent < MAX_INDENT_LEVEL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptanceCriteriaSubtreeEnd = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): number => {
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < items.length && items[end].indent > items[index].indent) {
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const indentAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (
|
||||||
|
!items[index] ||
|
||||||
|
(direction === 1
|
||||||
|
? !canIndentAcceptanceCriterion(items, index)
|
||||||
|
: items[index].indent === 0)
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.map((item, position) =>
|
||||||
|
position >= index && position < end
|
||||||
|
? { ...item, indent: item.indent + direction }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove only the selected criterion, promoting its children without losing them. */
|
||||||
|
export const removeAcceptanceCriterion = (
|
||||||
|
items: AcceptanceCriteriaModel,
|
||||||
|
index: number,
|
||||||
|
): AcceptanceCriteriaModel => {
|
||||||
|
if (!items[index]) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const end = acceptanceCriteriaSubtreeEnd(items, index);
|
||||||
|
return items.flatMap((item, position) =>
|
||||||
|
position === index
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
indent:
|
||||||
|
position > index && position < end
|
||||||
|
? item.indent - 1
|
||||||
|
: item.indent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AcceptanceCriteriaEditorState = {
|
export type AcceptanceCriteriaEditorState = {
|
||||||
checklist: string;
|
checklist: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NotificationActionFilterModel } from '@/types/models/notification-action-filter-model';
|
||||||
|
import type { NotificationFilterFormState } from '@/types/project-notifications';
|
||||||
|
|
||||||
|
export const supportsFilterValue = (operator: string): boolean =>
|
||||||
|
!['exists', 'not_exists'].includes(operator);
|
||||||
|
|
||||||
|
export function serializeNotificationFilters(
|
||||||
|
filters: NotificationFilterFormState[],
|
||||||
|
): NotificationActionFilterModel[] {
|
||||||
|
return filters.map(({ attribute, operator, value }) => ({
|
||||||
|
attribute,
|
||||||
|
operator,
|
||||||
|
value:
|
||||||
|
operator === 'in'
|
||||||
|
? value
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: supportsFilterValue(operator)
|
||||||
|
? value.trim()
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Localization } from '@/lib/localization';
|
||||||
|
import { en } from '@/locales/en';
|
||||||
|
import type { TranslationKey } from '@/types/localization';
|
||||||
|
|
||||||
|
export function notificationLabel(
|
||||||
|
t: Localization['t'],
|
||||||
|
group: string,
|
||||||
|
value: string,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
const key = `notifications.${group}.${value}`;
|
||||||
|
return Object.hasOwn(en, key) ? t(key as TranslationKey) : fallback;
|
||||||
|
}
|
||||||
@@ -1,4 +1,426 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
|
'notifications.title': 'Notifications',
|
||||||
|
'notifications.pageTitle': '{project} · Notifications',
|
||||||
|
'notifications.description':
|
||||||
|
'Send ticket updates to your team with webhook and Discord actions.',
|
||||||
|
'notifications.saved': 'Notification settings saved.',
|
||||||
|
'notifications.actions': 'Notification actions',
|
||||||
|
'notifications.create': 'Add notification',
|
||||||
|
'notifications.edit': 'Edit notification',
|
||||||
|
'notifications.delete': 'Delete notification?',
|
||||||
|
'notifications.editNamed': 'Edit {name}',
|
||||||
|
'notifications.deleteNamed': 'Delete {name}',
|
||||||
|
'notifications.empty': 'No notifications yet',
|
||||||
|
'notifications.emptyHelp':
|
||||||
|
'Add an action to send updates when a ticket event matches your rules.',
|
||||||
|
'notifications.enabled': 'Enabled',
|
||||||
|
'notifications.disabled': 'Disabled',
|
||||||
|
'notifications.delivery': 'Delivery',
|
||||||
|
'notifications.urlSaved': 'Secret URL saved',
|
||||||
|
'notifications.urlMissing': 'No delivery URL saved',
|
||||||
|
'notifications.filterCount': {
|
||||||
|
one: '{count} filter · all must match',
|
||||||
|
other: '{count} filters · all must match',
|
||||||
|
},
|
||||||
|
'notifications.noFiltersShort': 'All matching events',
|
||||||
|
'notifications.noChanges': 'No saved changes yet',
|
||||||
|
'notifications.createdAt': 'Created {date}',
|
||||||
|
'notifications.updatedAt': 'Updated {date}',
|
||||||
|
'notifications.deleteHelp':
|
||||||
|
'Delete “{name}”? This action will stop sending notifications. This cannot be undone.',
|
||||||
|
'notifications.editorHelp':
|
||||||
|
'Choose a ticket event, a delivery channel and optional filters.',
|
||||||
|
'notifications.name': 'Name',
|
||||||
|
'notifications.event': 'Trigger',
|
||||||
|
'notifications.channel': 'Channel',
|
||||||
|
'notifications.keepUrl':
|
||||||
|
'A secret URL is saved. Leave the field blank to keep it.',
|
||||||
|
'notifications.replaceUrl': 'Replace delivery URL (optional)',
|
||||||
|
'notifications.url': 'Delivery URL',
|
||||||
|
'notifications.secretHelp':
|
||||||
|
'URLs are stored encrypted and never shown again after saving. Enter the complete webhook URL.',
|
||||||
|
'notifications.message': 'Discord message template',
|
||||||
|
'notifications.messageHelp':
|
||||||
|
'Use the variables below to include ticket details. Maximum 2,000 characters.',
|
||||||
|
'notifications.variables': 'Available template variables',
|
||||||
|
'notifications.mentions': 'Allowed mentions',
|
||||||
|
'notifications.mentionsHelp':
|
||||||
|
'Leave all unchecked to disable mentions. Only the selected mention types may notify people.',
|
||||||
|
'notifications.filters': 'Filters',
|
||||||
|
'notifications.filtersHelp':
|
||||||
|
'All filters must match before this action sends a notification.',
|
||||||
|
'notifications.noFilters':
|
||||||
|
'No filters. This action runs for every matching event.',
|
||||||
|
'notifications.filterNumber': 'Filter {number}',
|
||||||
|
'notifications.attribute': 'Attribute',
|
||||||
|
'notifications.operator': 'Operator',
|
||||||
|
'notifications.value': 'Value',
|
||||||
|
'notifications.values': 'Values (comma or newline separated)',
|
||||||
|
'notifications.removeFilter': 'Remove filter',
|
||||||
|
'notifications.removeFilterNumber': 'Remove filter {number}',
|
||||||
|
'notifications.addFilter': 'Add filter',
|
||||||
|
'notifications.enabledHelp':
|
||||||
|
'Turn off this action to pause delivery while keeping its configuration.',
|
||||||
|
'notifications.confirmDelete': 'Delete notification',
|
||||||
|
'notifications.channel.discord': 'Discord',
|
||||||
|
'notifications.channel.webhook': 'Webhook',
|
||||||
|
'notifications.event.ticket_status_changed': 'Ticket status changed',
|
||||||
|
'notifications.event.ticket_create': 'Ticket create',
|
||||||
|
'notifications.event.ticket_assignee_changed': 'Assignee changed',
|
||||||
|
'notifications.attribute.project.id': 'Project ID',
|
||||||
|
'notifications.attribute.project.key': 'Project key',
|
||||||
|
'notifications.attribute.project.name': 'Project name',
|
||||||
|
'notifications.attribute.ticket.id': 'Ticket ID',
|
||||||
|
'notifications.attribute.ticket.key': 'Ticket key',
|
||||||
|
'notifications.attribute.ticket.title': 'Ticket title',
|
||||||
|
'notifications.attribute.ticket.url': 'Ticket URL',
|
||||||
|
'notifications.attribute.actor.id': 'Actor ID',
|
||||||
|
'notifications.attribute.actor.name': 'Actor name',
|
||||||
|
'notifications.attribute.assignee.from.id': 'From assignee ID',
|
||||||
|
'notifications.attribute.assignee.from.name': 'From assignee name',
|
||||||
|
'notifications.attribute.assignee.to.id': 'To assignee ID',
|
||||||
|
'notifications.attribute.assignee.to.name': 'To assignee name',
|
||||||
|
'notifications.attribute.status.from.id': 'From status ID',
|
||||||
|
'notifications.attribute.status.from.label': 'From status label',
|
||||||
|
'notifications.attribute.status.to.id': 'To status ID',
|
||||||
|
'notifications.attribute.status.to.label': 'To status label',
|
||||||
|
'notifications.operator.equals': 'Equals',
|
||||||
|
'notifications.operator.not_equals': 'Not equals',
|
||||||
|
'notifications.operator.exists': 'Exists',
|
||||||
|
'notifications.operator.not_exists': 'Not exists',
|
||||||
|
'notifications.operator.in': 'In list',
|
||||||
|
'notifications.mention.everyone': '@everyone and @here',
|
||||||
|
'notifications.mention.roles': 'Role mentions (@role)',
|
||||||
|
'notifications.mention.users': 'User mentions (@username)',
|
||||||
|
'notifications.variable.url':
|
||||||
|
'Absolute ticket URL for the notification event.',
|
||||||
|
'notifications.variable.ticket_key': 'Project ticket key such as CORE-42.',
|
||||||
|
'notifications.variable.ticket_title':
|
||||||
|
'Ticket title for the notification event.',
|
||||||
|
'notifications.variable.project_name': 'Project name that owns the ticket.',
|
||||||
|
'notifications.variable.status_from':
|
||||||
|
'Origin status label for status moves; blank for ticket create and assignee changes.',
|
||||||
|
'notifications.variable.status_to':
|
||||||
|
'Destination or initial status label for the ticket event.',
|
||||||
|
'notifications.variable.actor_name':
|
||||||
|
'Actor name when the event has a known user.',
|
||||||
|
'notifications.variable.assignee_from':
|
||||||
|
'Previous assignee name when the event updates assignment; blank otherwise.',
|
||||||
|
'notifications.variable.assignee_to':
|
||||||
|
'New assignee name when the event updates assignment; blank when unassigned.',
|
||||||
|
'templates.description':
|
||||||
|
'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
|
||||||
|
'templates.updated': 'Project templates updated.',
|
||||||
|
'templates.fromProject': 'Start with an existing project',
|
||||||
|
'templates.fromProjectHelp':
|
||||||
|
'Open a project and choose “Save as new template” to capture its configuration.',
|
||||||
|
'templates.browseProjects': 'Browse projects',
|
||||||
|
'templates.empty': 'No published templates yet',
|
||||||
|
'templates.emptyHelp':
|
||||||
|
'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.',
|
||||||
|
'templates.published': 'Published project templates',
|
||||||
|
'templates.linkedCount': {
|
||||||
|
one: '{count} linked project',
|
||||||
|
other: '{count} linked projects',
|
||||||
|
},
|
||||||
|
'templates.savedFrom': 'Saved from {project} ({key}).',
|
||||||
|
'templates.sourceUnavailable': 'Source project unavailable',
|
||||||
|
'templates.edit': 'Edit',
|
||||||
|
'templates.use': 'Use template',
|
||||||
|
'templates.createTitle': 'Save {project} as a template',
|
||||||
|
'templates.createDescription':
|
||||||
|
'Choose the configuration to reuse in future projects.',
|
||||||
|
'templates.editTitle': 'Edit {name}',
|
||||||
|
'templates.editDescription':
|
||||||
|
'Refresh the template from its source project and review the impact on linked projects.',
|
||||||
|
'templates.defaultName': '{project} template',
|
||||||
|
'templates.details': 'Template details',
|
||||||
|
'templates.name': 'Template name',
|
||||||
|
'templates.namePlaceholder': 'Foundation template',
|
||||||
|
'templates.slug': 'Template slug',
|
||||||
|
'templates.slugPlaceholder': 'foundation-template',
|
||||||
|
'templates.slugHelp':
|
||||||
|
'Slugs are normalized automatically. After creation, the slug cannot be changed.',
|
||||||
|
'templates.slugLocked':
|
||||||
|
'The slug is locked so existing template references remain stable.',
|
||||||
|
'templates.groups': 'Configuration groups',
|
||||||
|
'templates.createGroupsHelp':
|
||||||
|
'Only selected groups are saved. Related groups required by the workflow are included automatically.',
|
||||||
|
'templates.editGroupsHelp':
|
||||||
|
'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
|
||||||
|
'templates.impact': 'Template impact',
|
||||||
|
'templates.inheritance': 'Inheritance',
|
||||||
|
'templates.cleanStart':
|
||||||
|
'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
|
||||||
|
'templates.refreshHelp':
|
||||||
|
'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
|
||||||
|
'templates.linkedWarning': 'Linked projects will be affected',
|
||||||
|
'templates.linkedImpact':
|
||||||
|
'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.',
|
||||||
|
'templates.acknowledge':
|
||||||
|
'I understand that projects inheriting from this template will be affected.',
|
||||||
|
'templates.saveNew': 'Save new template',
|
||||||
|
'templates.saveChanges': 'Save template changes',
|
||||||
|
'templates.missingSource': 'This template cannot be refreshed',
|
||||||
|
'templates.missingSourceHelp':
|
||||||
|
'The source project is no longer available. You can still use the saved template configuration to create a project.',
|
||||||
|
'templates.deleteTitle': 'Delete {name}',
|
||||||
|
'templates.delete': 'Delete template',
|
||||||
|
'templates.deleting': 'Deleting template…',
|
||||||
|
'templates.deleteHelp':
|
||||||
|
'This permanently removes the reusable template. It will no longer be available for new projects.',
|
||||||
|
'templates.deleteImpact':
|
||||||
|
'{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
|
||||||
|
|
||||||
|
'workflow.title': 'Board workflow',
|
||||||
|
'workflow.pageTitle': '{project} · Board workflow',
|
||||||
|
'workflow.description':
|
||||||
|
'Organize your board into columns and give each stage the statuses your team needs.',
|
||||||
|
'workflow.updated': 'Workflow updated.',
|
||||||
|
'workflow.backlog': 'Backlog',
|
||||||
|
'workflow.offBoard': 'Off the board',
|
||||||
|
'workflow.backlogDescription':
|
||||||
|
'Your intake queue. Tickets with these statuses stay outside the board columns.',
|
||||||
|
'workflow.columns': 'Board columns',
|
||||||
|
'workflow.columnsDescription':
|
||||||
|
'Columns appear from left to right in this order. Edit a column to change its name or position.',
|
||||||
|
'workflow.emptyColumns': 'No board columns yet',
|
||||||
|
'workflow.emptyColumnsDescription':
|
||||||
|
'Add your first column to start organizing tickets on the board.',
|
||||||
|
'workflow.addColumn': 'Add column',
|
||||||
|
'workflow.editColumn': 'Edit column',
|
||||||
|
'workflow.deleteColumn': 'Delete column?',
|
||||||
|
'workflow.addColumnDescription':
|
||||||
|
'The column is added at the end of the board with a first status of the same name.',
|
||||||
|
'workflow.editColumnDescription':
|
||||||
|
'Rename this column or move it to a different position. Its statuses keep their names.',
|
||||||
|
'workflow.deleteColumnDescription':
|
||||||
|
'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.',
|
||||||
|
'workflow.addStatus': 'Add status',
|
||||||
|
'workflow.editStatus': 'Rename status',
|
||||||
|
'workflow.deleteStatus': 'Delete status?',
|
||||||
|
'workflow.statusContext': 'This status belongs to {context}.',
|
||||||
|
'workflow.deleteStatusDescription':
|
||||||
|
'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.',
|
||||||
|
'workflow.columnName': 'Column name',
|
||||||
|
'workflow.statusName': 'Status name',
|
||||||
|
'workflow.position': 'Position',
|
||||||
|
'workflow.positionValue': 'Position {position}',
|
||||||
|
'workflow.positionHelp':
|
||||||
|
'Choose a position from 1 to {count}. Other columns shift automatically.',
|
||||||
|
'workflow.statusesIn': 'Statuses in {context}',
|
||||||
|
'workflow.renameStatus': 'Rename status {name}',
|
||||||
|
'workflow.removeStatus': 'Delete status {name}',
|
||||||
|
'workflow.changeColumn': 'Edit column {name}',
|
||||||
|
'workflow.removeColumn': 'Delete column {name}',
|
||||||
|
'workflow.addStatusTo': 'Add status to {context}',
|
||||||
|
'workflow.lastStatus':
|
||||||
|
'Keep at least one status in this group. The last status cannot be deleted.',
|
||||||
|
'workflow.transitions': 'Status moves',
|
||||||
|
'workflow.transitionsDescription':
|
||||||
|
'Choose exactly where each status is allowed to move next.',
|
||||||
|
'workflow.sourceStatus': 'From status',
|
||||||
|
'workflow.directionHelp':
|
||||||
|
'Rules apply in one direction. Select another source to configure the return move.',
|
||||||
|
'workflow.targetsFrom': 'Allowed destinations from {name}',
|
||||||
|
'workflow.targetHelp':
|
||||||
|
'Checked destinations are allowed. Unchecked destinations are forbidden. Save to apply these rules to ticket moves and board drop targets.',
|
||||||
|
'workflow.allowed': 'Allowed',
|
||||||
|
'workflow.notAllowed': 'Not allowed',
|
||||||
|
'workflow.noMovesHelp':
|
||||||
|
'With no destinations selected, tickets cannot leave this status after you save.',
|
||||||
|
'workflow.noStatuses':
|
||||||
|
'Add a status to configure its allowed destinations.',
|
||||||
|
'workflow.unsavedMoves': 'Unsaved changes',
|
||||||
|
'workflow.savedMoves': 'Saved destinations: {count}',
|
||||||
|
'workflow.resetMoves': 'Reset changes',
|
||||||
|
'workflow.saveMoves': 'Save moves',
|
||||||
|
'workflow.noTargets': 'No other statuses exist yet.',
|
||||||
|
'workflow.confirmDelete': 'Delete',
|
||||||
|
'security.title': 'Security settings',
|
||||||
|
'security.passwordTitle': 'Update password',
|
||||||
|
'security.passwordDescription':
|
||||||
|
'Use a long, unique password to keep your account secure.',
|
||||||
|
'security.currentPassword': 'Current password',
|
||||||
|
'security.newPassword': 'New password',
|
||||||
|
'security.confirmPassword': 'Confirm password',
|
||||||
|
'security.savePassword': 'Save password',
|
||||||
|
'security.savingPassword': 'Saving password…',
|
||||||
|
'security.passwordSaved': 'Password saved.',
|
||||||
|
'security.twoFactorTitle': 'Two-factor authentication',
|
||||||
|
'security.twoFactorDescription':
|
||||||
|
'Add a second layer of protection with a code from your authenticator app.',
|
||||||
|
'security.enabledDescription':
|
||||||
|
'Your authenticator app provides a six-digit code when you sign in. Keep your recovery codes somewhere safe.',
|
||||||
|
'security.enabled': 'Enabled',
|
||||||
|
'security.disabled': 'Disabled',
|
||||||
|
'security.pending': 'Awaiting confirmation',
|
||||||
|
'security.enable': 'Enable 2FA',
|
||||||
|
'security.disable': 'Disable 2FA',
|
||||||
|
'security.continueSetup': 'Continue setup',
|
||||||
|
'security.disableTitle': 'Disable two-factor authentication?',
|
||||||
|
'security.disableDescription':
|
||||||
|
'You will no longer need an authenticator code to sign in. Your existing recovery codes will be removed.',
|
||||||
|
'security.setupTitle': 'Set up your authenticator',
|
||||||
|
'security.setupDescription':
|
||||||
|
'Scan this QR code with a TOTP authenticator app, or enter the setup key manually.',
|
||||||
|
'security.setupEnabled': 'Two-factor authentication enabled',
|
||||||
|
'security.setupEnabledDescription':
|
||||||
|
'Finish adding this account to your authenticator app and save your recovery codes.',
|
||||||
|
'security.verifyTitle': 'Verify authentication code',
|
||||||
|
'security.verifyDescription':
|
||||||
|
'Enter the six-digit code from your authenticator app to finish enabling two-factor authentication.',
|
||||||
|
'security.authenticationCode': 'Authentication code',
|
||||||
|
'security.manualKey': 'Manual setup key',
|
||||||
|
'security.copyKey': 'Copy key',
|
||||||
|
'security.copied': 'Copied',
|
||||||
|
'security.copyError':
|
||||||
|
'Copying failed. Select the key and copy it manually.',
|
||||||
|
'security.confirm': 'Confirm',
|
||||||
|
'security.continue': 'Continue',
|
||||||
|
'security.loadingSetup': 'Loading authenticator setup…',
|
||||||
|
'security.setupPendingDescription':
|
||||||
|
'Protection starts after you confirm a code. Closing this dialog keeps setup unfinished; you can continue from this page.',
|
||||||
|
'security.confirmAgain':
|
||||||
|
'Your session or password confirmation has expired. Reload this page and confirm your password to continue.',
|
||||||
|
'security.setupError':
|
||||||
|
'Authenticator setup could not be loaded. Please try again.',
|
||||||
|
'security.recoveryError':
|
||||||
|
'Recovery codes could not be loaded. Please try again.',
|
||||||
|
'security.recoveryTitle': '2FA recovery codes',
|
||||||
|
'security.recoveryDescription':
|
||||||
|
'Recovery codes let you regain access if you lose your authenticator. Store them in a secure password manager.',
|
||||||
|
'security.hideRecovery': 'Hide recovery codes',
|
||||||
|
'security.viewRecovery': 'View recovery codes',
|
||||||
|
'security.loadingRecovery': 'Loading recovery codes…',
|
||||||
|
'security.regenerate': 'Regenerate codes',
|
||||||
|
'security.recoveryOnce':
|
||||||
|
'Each recovery code can be used once. Hiding this section removes the codes from this page.',
|
||||||
|
'security.regenerateTitle': 'Regenerate recovery codes?',
|
||||||
|
'security.regenerateDescription':
|
||||||
|
'All previous recovery codes will stop working. Save the new codes in a secure place.',
|
||||||
|
'applications.title': 'Applications',
|
||||||
|
'applications.description':
|
||||||
|
'Create API keys for tools that use KanbanGit with your account permissions. Delete an application to revoke its access.',
|
||||||
|
'applications.saveKey': 'Save this API key now.',
|
||||||
|
'applications.keyOnce':
|
||||||
|
'This key is shown only after creation. It will not be shown again after you leave this page.',
|
||||||
|
'applications.apiKey': 'API key',
|
||||||
|
'applications.create': 'Create application',
|
||||||
|
'applications.nameDescription':
|
||||||
|
'Name the tool that will receive an API key.',
|
||||||
|
'applications.name': 'Application name',
|
||||||
|
'applications.namePlaceholder': 'For example, Claude Desktop',
|
||||||
|
'applications.creating': 'Creating application…',
|
||||||
|
'applications.saved': 'Saved applications',
|
||||||
|
'applications.hiddenKeys':
|
||||||
|
'Existing keys stay hidden. Create a new application when another key is needed.',
|
||||||
|
'applications.empty': 'No applications have been created yet.',
|
||||||
|
'applications.created': 'Created:',
|
||||||
|
'applications.lastUsed': 'Last used:',
|
||||||
|
'applications.never': 'Never',
|
||||||
|
'applications.delete': 'Delete',
|
||||||
|
'applications.deleteNamed': 'Delete {name}',
|
||||||
|
'applications.deleteTitle': 'Delete application?',
|
||||||
|
'applications.deleteDescription':
|
||||||
|
'{name} will lose access immediately. This cannot be undone.',
|
||||||
|
'common.back': 'Back',
|
||||||
|
'common.done': 'Done',
|
||||||
|
|
||||||
|
'adminUsers.eyebrow': 'Workspace administration',
|
||||||
|
'adminUsers.title': 'User management',
|
||||||
|
'adminUsers.description':
|
||||||
|
'Manage workspace accounts, password requirements, and the global permissions that apply across KanbanGit.',
|
||||||
|
'adminUsers.managedAccount': 'Managed account',
|
||||||
|
'adminUsers.createAction': 'Create user',
|
||||||
|
'adminUsers.updated': 'User management updated',
|
||||||
|
'adminUsers.summary.label': 'User management summary',
|
||||||
|
'adminUsers.summary.total': 'Workspace users',
|
||||||
|
'adminUsers.summary.admins': 'Global administrators',
|
||||||
|
'adminUsers.summary.passwordChanges': 'Password changes pending',
|
||||||
|
'adminUsers.listTitle': 'Users ({count})',
|
||||||
|
'adminUsers.listDescription':
|
||||||
|
'Review account status, edit profile details, or confirm global access changes.',
|
||||||
|
'adminUsers.empty.title': 'No users yet',
|
||||||
|
'adminUsers.empty.description':
|
||||||
|
'Create the first managed account for this workspace.',
|
||||||
|
'adminUsers.passwordChangePending': 'Password change required',
|
||||||
|
'adminUsers.permissions.none': 'No direct global permissions',
|
||||||
|
'adminUsers.permissions.action': 'Manage access',
|
||||||
|
'adminUsers.permissions.available': 'Available global permissions',
|
||||||
|
'adminUsers.permissions.globalAdmin': 'Global administrator',
|
||||||
|
'adminUsers.permissions.globalAdminHelp':
|
||||||
|
'Allows access to workspace-wide administration, including this user management area.',
|
||||||
|
'adminUsers.permissions.projectCreate': 'Create projects',
|
||||||
|
'adminUsers.permissions.projectCreateHelp':
|
||||||
|
'Allows the user to create new projects without granting global administration.',
|
||||||
|
'adminUsers.permissions.title': 'Global access for {name}',
|
||||||
|
'adminUsers.permissions.description':
|
||||||
|
'Select the direct permissions this account should receive.',
|
||||||
|
'adminUsers.permissions.warning':
|
||||||
|
'These changes take effect across the workspace after confirmation.',
|
||||||
|
'adminUsers.permissions.confirm': 'Confirm access changes',
|
||||||
|
'adminUsers.create.title': 'Create user',
|
||||||
|
'adminUsers.create.description':
|
||||||
|
'Add a workspace account with an initial password and a clear first-login requirement.',
|
||||||
|
'adminUsers.edit.breadcrumb': 'Edit {name}',
|
||||||
|
'adminUsers.edit.pageTitle': 'Edit {name}',
|
||||||
|
'adminUsers.edit.title': 'Edit {name}',
|
||||||
|
'adminUsers.edit.action': 'Edit account details for {name}',
|
||||||
|
'adminUsers.edit.shortAction': 'Edit details',
|
||||||
|
'adminUsers.form.detailsTitle': 'Account details',
|
||||||
|
'adminUsers.form.createDescription':
|
||||||
|
'Set the identity and initial security state for the new account.',
|
||||||
|
'adminUsers.form.editDescription':
|
||||||
|
'Update account details and review security-sensitive changes before saving.',
|
||||||
|
'adminUsers.form.name': 'Name',
|
||||||
|
'adminUsers.form.namePlaceholder': 'Jane Admin',
|
||||||
|
'adminUsers.form.email': 'Email address',
|
||||||
|
'adminUsers.form.emailPlaceholder': 'jane@example.com',
|
||||||
|
'adminUsers.form.passwordTitle': 'Password and first sign-in',
|
||||||
|
'adminUsers.form.passwordCreateHelp':
|
||||||
|
'Set a temporary password that the user can replace after signing in.',
|
||||||
|
'adminUsers.form.passwordEditHelp':
|
||||||
|
'Leave both password fields empty to keep the current password.',
|
||||||
|
'adminUsers.form.password': 'Temporary password',
|
||||||
|
'adminUsers.form.newPassword': 'New password',
|
||||||
|
'adminUsers.form.passwordPlaceholder': 'Enter a temporary password',
|
||||||
|
'adminUsers.form.passwordEditPlaceholder':
|
||||||
|
'Leave blank to keep the current password',
|
||||||
|
'adminUsers.form.confirmPassword': 'Confirm password',
|
||||||
|
'adminUsers.form.confirmPasswordPlaceholder': 'Repeat the new password',
|
||||||
|
'adminUsers.form.forcePasswordChange':
|
||||||
|
'Require a password change on next sign-in',
|
||||||
|
'adminUsers.form.forcePasswordChangeHelp':
|
||||||
|
'The user stays in security settings until a new password has been saved.',
|
||||||
|
'adminUsers.form.createAction': 'Create user',
|
||||||
|
'adminUsers.form.reviewChanges': 'Review changes',
|
||||||
|
'adminUsers.form.confirmTitle': 'Update {name}?',
|
||||||
|
'adminUsers.form.confirmDescription':
|
||||||
|
'Confirm the account and security changes before they are applied.',
|
||||||
|
'adminUsers.form.confirmWarning':
|
||||||
|
'A new password takes effect immediately. Requiring a password change restricts the user to security settings on their next sign-in.',
|
||||||
|
'adminUsers.form.confirmAction': 'Confirm and save',
|
||||||
|
|
||||||
|
'criteria.title': 'Acceptance criteria',
|
||||||
|
'criteria.progress': '{completed} of {total} complete',
|
||||||
|
'criteria.empty': 'No acceptance criteria yet.',
|
||||||
|
'criteria.required': 'Enter a criterion or remove this row.',
|
||||||
|
'criteria.edit': 'Edit checklist',
|
||||||
|
'criteria.preview': 'Preview checklist',
|
||||||
|
'criteria.item': 'Criterion {number}',
|
||||||
|
'criteria.complete': 'Complete criterion {number}: {text}',
|
||||||
|
'criteria.indent': 'Indent criterion {number}',
|
||||||
|
'criteria.outdent': 'Outdent criterion {number}',
|
||||||
|
'criteria.remove': 'Remove criterion {number}',
|
||||||
|
'criteria.add': 'Add criterion',
|
||||||
|
'criteria.hint':
|
||||||
|
'Use the indent buttons or Alt + Left/Right in a text field to nest criteria. Checking a parent also checks its children. Removing a parent keeps its children.',
|
||||||
|
'criteria.unsaved': 'Checklist changes will be saved with the ticket.',
|
||||||
|
'criteria.saving': 'Saving checklist…',
|
||||||
|
'criteria.saveError':
|
||||||
|
'Checklist status could not be saved. Please try again.',
|
||||||
|
|
||||||
'board.title': '{name} board',
|
'board.title': '{name} board',
|
||||||
'board.description':
|
'board.description':
|
||||||
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
|
||||||
@@ -184,6 +606,40 @@ export const en = {
|
|||||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||||
'projects.inheritedArea': '{name} · inherited',
|
'projects.inheritedArea': '{name} · inherited',
|
||||||
'projects.overriddenArea': '{name} · overridden',
|
'projects.overriddenArea': '{name} · overridden',
|
||||||
|
'welcome.metaTitle': 'Project work with Git context',
|
||||||
|
'welcome.homeLabel': 'KanbanGit home',
|
||||||
|
'welcome.navigation': 'Account navigation',
|
||||||
|
'welcome.eyebrow': 'Self-hosted project management',
|
||||||
|
'welcome.title': 'Plan the work. Keep Git context close.',
|
||||||
|
'welcome.description':
|
||||||
|
'Plan projects on configurable boards, keep ticket decisions visible, and connect delivery work to the repositories your team already uses.',
|
||||||
|
'welcome.action.signIn': 'Sign in to KanbanGit',
|
||||||
|
'welcome.action.signInShort': 'Sign in',
|
||||||
|
'welcome.action.dashboard': 'Open dashboard',
|
||||||
|
'welcome.action.dashboardShort': 'Dashboard',
|
||||||
|
'welcome.actionHint': 'One workspace for boards, tickets, and Git context.',
|
||||||
|
'welcome.preview.label': 'KanbanGit board preview',
|
||||||
|
'welcome.preview.project': 'Product delivery board',
|
||||||
|
'welcome.preview.connected': 'Git connected',
|
||||||
|
'welcome.preview.backlog': 'Backlog',
|
||||||
|
'welcome.preview.inProgress': 'In progress',
|
||||||
|
'welcome.preview.review': 'Review',
|
||||||
|
'welcome.preview.ticketOnboarding': 'Refine onboarding flow',
|
||||||
|
'welcome.preview.ticketRelease': 'Plan release workflow',
|
||||||
|
'welcome.preview.ticketRepository': 'Connect project repository',
|
||||||
|
'welcome.preview.ticketPullRequest': 'Review pull request context',
|
||||||
|
'welcome.features.eyebrow': 'A focused workflow',
|
||||||
|
'welcome.features.title': 'The project context your team needs',
|
||||||
|
'welcome.features.workflows.title': 'Configurable workflows',
|
||||||
|
'welcome.features.workflows.description':
|
||||||
|
'Shape project boards, backlog, statuses, and transitions around the way your team works.',
|
||||||
|
'welcome.features.tickets.title': 'Structured ticket context',
|
||||||
|
'welcome.features.tickets.description':
|
||||||
|
'Keep metadata, acceptance criteria, relationships, blockers, and history with the work.',
|
||||||
|
'welcome.features.git.title': 'Repository-aware delivery',
|
||||||
|
'welcome.features.git.description':
|
||||||
|
'Link project repositories and see matching branches and pull requests on the ticket.',
|
||||||
|
'welcome.footer': 'Self-hosted project and workflow management.',
|
||||||
'dashboard.eyebrow': 'Personal workspace',
|
'dashboard.eyebrow': 'Personal workspace',
|
||||||
'dashboard.greeting': 'Welcome back, {name}',
|
'dashboard.greeting': 'Welcome back, {name}',
|
||||||
'dashboard.description':
|
'dashboard.description':
|
||||||
@@ -340,6 +796,7 @@ export const en = {
|
|||||||
'navigation.appearance': 'Appearance',
|
'navigation.appearance': 'Appearance',
|
||||||
'navigation.settingsDescription':
|
'navigation.settingsDescription':
|
||||||
'Manage your profile, security, and Git integration settings.',
|
'Manage your profile, security, and Git integration settings.',
|
||||||
|
'settings.eyebrow': 'Account settings',
|
||||||
'navigation.createTicket': 'New ticket',
|
'navigation.createTicket': 'New ticket',
|
||||||
'navigation.createProject': 'New project',
|
'navigation.createProject': 'New project',
|
||||||
'navigation.open': 'Open navigation',
|
'navigation.open': 'Open navigation',
|
||||||
@@ -354,10 +811,62 @@ export const en = {
|
|||||||
'navigation.breadcrumbs': 'Breadcrumbs',
|
'navigation.breadcrumbs': 'Breadcrumbs',
|
||||||
'appearance.label': 'Color theme',
|
'appearance.label': 'Color theme',
|
||||||
'appearance.light': 'Light',
|
'appearance.light': 'Light',
|
||||||
|
'appearance.lightDescription':
|
||||||
|
'Use a bright workspace with dark text and clear borders.',
|
||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
|
'appearance.darkDescription':
|
||||||
|
'Reduce glare with a darker workspace and balanced contrast.',
|
||||||
'appearance.system': 'System',
|
'appearance.system': 'System',
|
||||||
|
'appearance.systemDescription':
|
||||||
|
'Follow the light or dark preference set on this device.',
|
||||||
|
'appearance.title': 'Appearance',
|
||||||
|
'appearance.description': 'Choose how KanbanGit looks on this device.',
|
||||||
|
'appearance.persistenceHint':
|
||||||
|
'Your choice is saved on this device and restored when you open KanbanGit again.',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'profile.description':
|
||||||
|
'Keep your personal details and profile picture up to date.',
|
||||||
|
'profile.information': 'Profile information',
|
||||||
|
'profile.picture': 'Profile picture',
|
||||||
|
'profile.pictureDescription':
|
||||||
|
'Help teammates recognize you throughout the workspace.',
|
||||||
|
'profile.picturePreview': 'Profile picture preview',
|
||||||
|
'profile.choosePicture': 'Choose picture',
|
||||||
|
'profile.changePicture': 'Choose another',
|
||||||
|
'profile.discardPicture': 'Discard selection',
|
||||||
|
'profile.removePicture': 'Remove picture',
|
||||||
|
'profile.removingPicture': 'Removing picture…',
|
||||||
|
'profile.selectedPicture': 'Selected: {name}',
|
||||||
|
'profile.pictureHelp':
|
||||||
|
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
|
||||||
|
'profile.name': 'Name',
|
||||||
|
'profile.namePlaceholder': 'Full name',
|
||||||
|
'profile.email': 'Email address',
|
||||||
|
'profile.emailPlaceholder': 'Email address',
|
||||||
|
'profile.emailUnverified': 'Your email address is unverified.',
|
||||||
|
'profile.resendVerification': 'Resend the verification email',
|
||||||
|
'profile.verificationSent':
|
||||||
|
'A new verification link has been sent to your email address.',
|
||||||
|
'profile.save': 'Save profile',
|
||||||
|
'profile.saving': 'Saving profile…',
|
||||||
|
'profile.unsaved': 'You have unsaved profile changes.',
|
||||||
|
'profile.delete.title': 'Delete account',
|
||||||
|
'profile.delete.description':
|
||||||
|
'Permanently delete your account and all of its resources.',
|
||||||
|
'profile.delete.warning': 'This action cannot be undone',
|
||||||
|
'profile.delete.warningDescription':
|
||||||
|
'Your account and associated data will be permanently removed.',
|
||||||
|
'profile.delete.action': 'Delete account',
|
||||||
|
'profile.delete.confirmTitle':
|
||||||
|
'Are you sure you want to delete your account?',
|
||||||
|
'profile.delete.confirmDescription':
|
||||||
|
'Enter your password to confirm that you want to permanently delete your account and its data.',
|
||||||
|
'profile.delete.password': 'Password',
|
||||||
|
'profile.delete.confirmAction': 'Permanently delete account',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
|
'common.edit': 'Edit',
|
||||||
|
'common.remove': 'Remove',
|
||||||
'common.retry': 'Try again',
|
'common.retry': 'Try again',
|
||||||
'feedback.empty': 'Nothing here yet',
|
'feedback.empty': 'Nothing here yet',
|
||||||
'feedback.saved': 'Changes saved',
|
'feedback.saved': 'Changes saved',
|
||||||
@@ -437,9 +946,24 @@ export const en = {
|
|||||||
'ticketDetail.checklistPlaceholder':
|
'ticketDetail.checklistPlaceholder':
|
||||||
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
'[ ] User can sign in\n [x] Validation is shown for invalid credentials',
|
||||||
'ticketDetail.blocked': 'Mark ticket as blocked',
|
'ticketDetail.blocked': 'Mark ticket as blocked',
|
||||||
|
'ticketDetail.blocker': 'Blocker',
|
||||||
|
'ticketDetail.blockerDescription':
|
||||||
|
'Record whether progress is blocked and why. Changes are added to the ticket worklog.',
|
||||||
|
'ticketDetail.blockerActive': 'Blocked',
|
||||||
|
'ticketDetail.blockerInactive': 'Not blocked',
|
||||||
|
'ticketDetail.blockerActiveDescription':
|
||||||
|
'A blocker reason is required while this ticket is blocked.',
|
||||||
|
'ticketDetail.blockerInactiveDescription':
|
||||||
|
'This ticket currently has no active blocker.',
|
||||||
'ticketDetail.blockerComment': 'Blocker comment',
|
'ticketDetail.blockerComment': 'Blocker comment',
|
||||||
|
'ticketDetail.blockerCommentHelp':
|
||||||
|
'Explain the concrete dependency or decision preventing progress.',
|
||||||
'ticketDetail.blockerPlaceholder':
|
'ticketDetail.blockerPlaceholder':
|
||||||
'Describe what currently blocks progress on this ticket.',
|
'Describe what currently blocks progress on this ticket.',
|
||||||
|
'ticketDetail.saveBlocker': 'Save blocker',
|
||||||
|
'ticketDetail.unblockTicket': 'Unblock ticket',
|
||||||
|
'ticketDetail.resetBlocker': 'Discard blocker changes',
|
||||||
|
'ticketDetail.savingBlocker': 'Saving blocker…',
|
||||||
'ticketDetail.customFields': 'Custom fields',
|
'ticketDetail.customFields': 'Custom fields',
|
||||||
'ticketDetail.customFieldsDescription':
|
'ticketDetail.customFieldsDescription':
|
||||||
'Project-configured fields can be tracked directly on the ticket.',
|
'Project-configured fields can be tracked directly on the ticket.',
|
||||||
@@ -457,8 +981,20 @@ export const en = {
|
|||||||
'ticketDetail.branchName': 'Branch name',
|
'ticketDetail.branchName': 'Branch name',
|
||||||
'ticketDetail.branchAccess':
|
'ticketDetail.branchAccess':
|
||||||
'You can only create branches in repositories that match one of your personal Git connections.',
|
'You can only create branches in repositories that match one of your personal Git connections.',
|
||||||
|
'ticketDetail.gitAccessRequired': 'Personal Git access required',
|
||||||
|
'ticketDetail.personalAccessRequired': 'personal Git access required',
|
||||||
'ticketDetail.openGitConnections': 'Open Git connections',
|
'ticketDetail.openGitConnections': 'Open Git connections',
|
||||||
'ticketDetail.createNow': 'Create now',
|
'ticketDetail.createNow': 'Create now',
|
||||||
|
'ticketDetail.creatingBranch': 'Creating branch…',
|
||||||
|
'ticketDetail.noLinkedRepositories': 'No linked repositories',
|
||||||
|
'ticketDetail.noLinkedRepositoriesDescription':
|
||||||
|
'A project administrator needs to link a repository before Git activity can appear here.',
|
||||||
|
'ticketDetail.branchesUnavailable': 'Branches unavailable',
|
||||||
|
'ticketDetail.pullRequestsUnavailable': 'Pull requests unavailable',
|
||||||
|
'ticketDetail.openRepositoryExternal':
|
||||||
|
'Open {repository} on {provider} (opens in a new tab)',
|
||||||
|
'ticketDetail.openPullRequestExternal':
|
||||||
|
'Open pull request #{number} on {provider} (opens in a new tab)',
|
||||||
'ticketDetail.inspectBranches':
|
'ticketDetail.inspectBranches':
|
||||||
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
'Connect a matching Git account in your user settings to inspect branches for this repository.',
|
||||||
'ticketDetail.pullRequests': 'Pull requests',
|
'ticketDetail.pullRequests': 'Pull requests',
|
||||||
@@ -493,10 +1029,32 @@ export const en = {
|
|||||||
'ticketDetail.noLinks':
|
'ticketDetail.noLinks':
|
||||||
'No ticket links are configured for this ticket yet.',
|
'No ticket links are configured for this ticket yet.',
|
||||||
'ticketDetail.removeLink': 'Remove',
|
'ticketDetail.removeLink': 'Remove',
|
||||||
|
'ticketRelationships.hierarchy': 'Hierarchy',
|
||||||
|
'ticketRelationships.editParent': 'Change parent',
|
||||||
|
'ticketRelationships.parentHelp':
|
||||||
|
'Change parent to assign this ticket or choose “No parent ticket” to detach it.',
|
||||||
|
'ticketRelationships.childrenHelp':
|
||||||
|
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||||
|
'ticketRelationships.addLink': 'Add or update a link',
|
||||||
|
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||||
|
'ticketRelationships.directionHelp':
|
||||||
|
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
||||||
|
'ticketRelationships.outgoing': 'Outgoing',
|
||||||
|
'ticketRelationships.incoming': 'Incoming',
|
||||||
|
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||||
|
'ticketRelationships.noTypes':
|
||||||
|
'No link types are configured for this project.',
|
||||||
|
'ticketRelationships.noTargets':
|
||||||
|
'No other tickets are available to link in this project.',
|
||||||
'ticketDetail.historyDescription':
|
'ticketDetail.historyDescription':
|
||||||
'Every tracked ticket change is recorded with actor and timestamp.',
|
'Every tracked ticket change is recorded with actor and timestamp.',
|
||||||
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
'ticketDetail.noHistory': 'No worklog entries exist for this ticket yet.',
|
||||||
'ticketDetail.system': 'System',
|
'ticketDetail.system': 'System',
|
||||||
|
'ticketDetail.historyType.created': 'Ticket created',
|
||||||
|
'ticketDetail.historyType.updated': 'Ticket updated',
|
||||||
|
'ticketDetail.historyType.statusChanged': 'Status changed',
|
||||||
|
'ticketDetail.historyType.linkUpdated': 'Link added',
|
||||||
|
'ticketDetail.historyType.linkRemoved': 'Link removed',
|
||||||
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
'ticketDetail.sourceBranch': 'Source branch: {name}',
|
||||||
'ticketForm.create': 'Create ticket',
|
'ticketForm.create': 'Create ticket',
|
||||||
'ticketForm.pageTitle': 'Create {project} ticket',
|
'ticketForm.pageTitle': 'Create {project} ticket',
|
||||||
@@ -522,4 +1080,286 @@ export const en = {
|
|||||||
'ticketForm.saved': 'All changes saved',
|
'ticketForm.saved': 'All changes saved',
|
||||||
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
'ticketForm.authorRestricted': 'Only project admins can change the author.',
|
||||||
'ticketForm.blocker': 'Blocker',
|
'ticketForm.blocker': 'Blocker',
|
||||||
|
'projectSettings.pageTitle': '{project} general settings',
|
||||||
|
'projectSettings.description':
|
||||||
|
'Manage settings and template inheritance for {project}.',
|
||||||
|
'projectSettings.generalDescription':
|
||||||
|
'Review project identity, setting sources, and administrative actions.',
|
||||||
|
'projectSettings.identity': 'Project identity',
|
||||||
|
'projectSettings.identityDescription':
|
||||||
|
'The name and key belong to this project and are never inherited from a template.',
|
||||||
|
'projectSettings.name': 'Project name',
|
||||||
|
'projectSettings.key': 'Project key',
|
||||||
|
'projectSettings.local': 'Local',
|
||||||
|
'projectSettings.inherited': 'Inherited',
|
||||||
|
'projectSettings.override': 'Local override',
|
||||||
|
'projectSettings.display': 'Ticket display',
|
||||||
|
'projectSettings.showAuthor': 'Show author',
|
||||||
|
'projectSettings.showStoryPoints': 'Show story points',
|
||||||
|
'projectSettings.enabled': 'Enabled',
|
||||||
|
'projectSettings.disabled': 'Disabled',
|
||||||
|
'projectSettings.displayDescription':
|
||||||
|
'Manage these display options in Ticket settings.',
|
||||||
|
'projectSettings.template': 'Template baseline',
|
||||||
|
'projectSettings.templateDescription':
|
||||||
|
'Manage the linked template or reset individual areas using the inheritance panel.',
|
||||||
|
'projectSettings.currentTemplate': 'Current linked template',
|
||||||
|
'projectSettings.noTemplate': 'No linked template',
|
||||||
|
'projectSettings.linked': 'Template inheritance active',
|
||||||
|
'projectSettings.standalone': 'Standalone',
|
||||||
|
'projectSettings.standaloneDescription':
|
||||||
|
'This project manages its settings locally. Published template updates are not applied.',
|
||||||
|
'projectSettings.noAlternatives': 'No other published templates',
|
||||||
|
'projectSettings.noAlternativesDescription':
|
||||||
|
'A different published template is required to switch this project.',
|
||||||
|
'projectSettings.switch': 'Switch template',
|
||||||
|
'projectSettings.switching': 'Switching template…',
|
||||||
|
'projectSettings.switchWarning':
|
||||||
|
'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.',
|
||||||
|
'projectSettings.publishedTemplate': 'Published template',
|
||||||
|
'projectSettings.selectTemplate': 'Select a template',
|
||||||
|
'projectSettings.confirmation': 'Confirmation key',
|
||||||
|
'projectSettings.confirmPlaceholder': 'Type {key} exactly',
|
||||||
|
'projectSettings.confirmHelp':
|
||||||
|
'Enter the exact project key {key} to confirm this action. The key is case sensitive.',
|
||||||
|
'projectSettings.danger': 'Danger zone',
|
||||||
|
'projectSettings.deleteDescription':
|
||||||
|
'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.',
|
||||||
|
'projectSettings.irreversible': 'This action cannot be undone.',
|
||||||
|
'projectSettings.delete': 'Delete project',
|
||||||
|
'projectSettings.deleting': 'Deleting project…',
|
||||||
|
'projectSettings.updated': 'Project settings updated.',
|
||||||
|
'inheritance.title': 'Template inheritance',
|
||||||
|
'inheritance.description': 'Settings linked to {template}.',
|
||||||
|
'inheritance.help':
|
||||||
|
'Each area inherits independently. Settings outside the template are managed locally.',
|
||||||
|
'inheritance.areas': 'Setting sources',
|
||||||
|
'inheritance.overrideHelp':
|
||||||
|
'Local changes stay in place when the template is updated.',
|
||||||
|
'inheritance.reset': 'Reset to template',
|
||||||
|
'inheritance.resetArea': 'Reset {area} to template',
|
||||||
|
'inheritance.resetting': 'Resetting…',
|
||||||
|
'inheritance.confirmTitle': 'Reset this setting area?',
|
||||||
|
'inheritance.confirmDescription':
|
||||||
|
'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.',
|
||||||
|
'inheritance.resetWarning':
|
||||||
|
'Local configuration in this area may be replaced or removed.',
|
||||||
|
'inheritance.statusHelp':
|
||||||
|
'Choose where tickets in local statuses should move before those statuses are removed.',
|
||||||
|
'inheritance.statusLabel': '{status} ({count} tickets)',
|
||||||
|
'inheritance.selectStatus': 'Select replacement status',
|
||||||
|
'inheritance.area.projectSettings': 'Project settings',
|
||||||
|
'inheritance.area.boardColumns': 'Board columns',
|
||||||
|
'inheritance.area.ticketStatuses': 'Ticket statuses',
|
||||||
|
'inheritance.area.statusTransitions': 'Status transitions',
|
||||||
|
'inheritance.area.ticketTypes': 'Ticket types',
|
||||||
|
'inheritance.area.ticketPriorities': 'Ticket priorities',
|
||||||
|
'inheritance.area.ticketFields': 'Custom ticket fields',
|
||||||
|
'inheritance.area.ticketLinkTypes': 'Ticket link types',
|
||||||
|
'inheritance.area.repositoryConnections': 'Repository connections',
|
||||||
|
'inheritance.area.notificationActions': 'Notification actions',
|
||||||
|
'repositorySettings.pageTitle': '{project} repository settings',
|
||||||
|
'repositorySettings.title': 'Project repositories',
|
||||||
|
'repositorySettings.description':
|
||||||
|
'Choose the repositories this project uses for ticket branches and pull requests.',
|
||||||
|
'repositorySettings.projectScope':
|
||||||
|
'Repository links belong to the project. Every team member keeps using the same linked repositories, while access is granted through their own personal Git connections.',
|
||||||
|
'repositorySettings.linkedRepositories': 'Linked repositories',
|
||||||
|
'repositorySettings.linkAction': 'Link repository',
|
||||||
|
'repositorySettings.empty': 'No project repositories yet',
|
||||||
|
'repositorySettings.emptyDescription':
|
||||||
|
'Link a repository from one of your personal Git connections to make it available to project tickets.',
|
||||||
|
'repositorySettings.personalAccessReady': 'Personal access ready',
|
||||||
|
'repositorySettings.personalAccessMissing': 'Personal access needed',
|
||||||
|
'repositorySettings.ticketUsage':
|
||||||
|
'Tickets use this project link to discover matching branches and pull requests.',
|
||||||
|
'repositorySettings.openRepository':
|
||||||
|
'Open {repository} in the Git provider (opens in a new tab)',
|
||||||
|
'repositorySettings.open': 'Open repository',
|
||||||
|
'repositorySettings.connectAccess': 'Connect Git access',
|
||||||
|
'repositorySettings.editRepository':
|
||||||
|
'Change project repository {repository}',
|
||||||
|
'repositorySettings.removeRepository':
|
||||||
|
'Remove project repository {repository}',
|
||||||
|
'repositorySettings.personalSources': 'Personal Git access',
|
||||||
|
'repositorySettings.personalSourcesDescription':
|
||||||
|
'These account connections are private to you and are only used to discover repositories you can link to this project.',
|
||||||
|
'repositorySettings.manageConnections': 'Manage Git connections',
|
||||||
|
'repositorySettings.refresh': 'Refresh repositories',
|
||||||
|
'repositorySettings.refreshing': 'Loading repositories…',
|
||||||
|
'repositorySettings.refreshingDescription':
|
||||||
|
'The latest repositories are being requested from your Git providers.',
|
||||||
|
'repositorySettings.noConnections': 'No personal Git connection',
|
||||||
|
'repositorySettings.noConnectionsDescription':
|
||||||
|
'Add a personal Git connection before linking or changing a project repository.',
|
||||||
|
'repositorySettings.detectedSources': 'Detected personal sources',
|
||||||
|
'repositorySettings.unavailable': 'Provider unavailable',
|
||||||
|
'repositorySettings.repositoryCount': '{count} repositories',
|
||||||
|
'repositorySettings.linkTitle': 'Link a project repository',
|
||||||
|
'repositorySettings.linkDescription':
|
||||||
|
'Choose a personal connection, find a repository, and add it to this project.',
|
||||||
|
'repositorySettings.editTitle': 'Change project repository',
|
||||||
|
'repositorySettings.editDescription':
|
||||||
|
'Replace this project link with another repository. Tickets will use the updated link after saving.',
|
||||||
|
'repositorySettings.removeTitle': 'Remove repository',
|
||||||
|
'repositorySettings.removeDescription':
|
||||||
|
'Remove this repository from the project? Tickets will no longer discover its branches or pull requests.',
|
||||||
|
'repositorySettings.linked': 'Repository linked to the project.',
|
||||||
|
'repositorySettings.updated': 'Project repository updated.',
|
||||||
|
'repositorySettings.removed': 'Repository removed from the project.',
|
||||||
|
'repositorySettings.matchingConnectionMissing':
|
||||||
|
'Matching personal access is missing',
|
||||||
|
'repositorySettings.matchingConnectionMissingDescription':
|
||||||
|
'The project link remains available to the team, but this account cannot query it until you add a matching provider connection. You can still choose a different accessible repository below.',
|
||||||
|
'repositorySettings.personalConnection': 'Personal Git connection',
|
||||||
|
'repositorySettings.personalConnectionHelp':
|
||||||
|
'Used for discovery only; it is not shared with the project.',
|
||||||
|
'repositorySettings.providerError':
|
||||||
|
'Repositories could not be loaded from this provider',
|
||||||
|
'repositorySettings.searchLabel': 'Find a repository',
|
||||||
|
'repositorySettings.searchPlaceholder':
|
||||||
|
'Search by repository, owner, or namespace',
|
||||||
|
'repositorySettings.noRepositories': 'No repositories available',
|
||||||
|
'repositorySettings.noRepositoriesDescription':
|
||||||
|
'This connection is working, but the provider returned no repositories you can access.',
|
||||||
|
'repositorySettings.noSearchResults': 'No matching repositories',
|
||||||
|
'repositorySettings.noSearchResultsDescription':
|
||||||
|
'No repository matches “{search}”. Try a different name or clear the search.',
|
||||||
|
'repositorySettings.repositoryLabel': 'Available repositories',
|
||||||
|
'repositorySettings.forbidden': 'Project access denied',
|
||||||
|
'repositorySettings.forbiddenDescription':
|
||||||
|
'You no longer have permission to manage repositories for this project. Close this dialog and refresh the page.',
|
||||||
|
'repositorySettings.linking': 'Linking repository…',
|
||||||
|
'repositorySettings.saving': 'Saving repository…',
|
||||||
|
'repositorySettings.save': 'Save repository',
|
||||||
|
'repositorySettings.removing': 'Removing repository…',
|
||||||
|
'gitConnections.description':
|
||||||
|
'Connect your personal Git account to browse repositories, branches, and pull requests you can access.',
|
||||||
|
'gitConnections.privacy':
|
||||||
|
'Connections belong to your account. Saved tokens stay hidden. Without a matching connection, only repository names are available.',
|
||||||
|
'gitConnections.add': 'Add connection',
|
||||||
|
'gitConnections.editAction': 'Edit',
|
||||||
|
'gitConnections.edit': 'Edit connection',
|
||||||
|
'gitConnections.remove': 'Remove',
|
||||||
|
'gitConnections.removeTitle': 'Remove connection',
|
||||||
|
'gitConnections.saved': 'Saved connections',
|
||||||
|
'gitConnections.empty': 'No Git connections yet',
|
||||||
|
'gitConnections.emptyDescription':
|
||||||
|
'Add a Gitea connection to access your repositories and their Git activity.',
|
||||||
|
'gitConnections.unnamedAccount': 'Unnamed remote account',
|
||||||
|
'gitConnections.remoteId': 'Remote user ID: {id}',
|
||||||
|
'gitConnections.editAccount': 'Edit {account} at {server}',
|
||||||
|
'gitConnections.removeAccount': 'Remove {account} at {server}',
|
||||||
|
'gitConnections.addDescription':
|
||||||
|
'Connect a Gitea account using its server URL and a personal application token.',
|
||||||
|
'gitConnections.editDescription':
|
||||||
|
'Update the server URL and enter a fresh token to verify this connection.',
|
||||||
|
'gitConnections.removeDescription':
|
||||||
|
'Remove this personal connection? Repository links remain, but Git activity will require another matching connection.',
|
||||||
|
'gitConnections.gitea': 'Gitea',
|
||||||
|
'gitConnections.baseUrl': 'Gitea base URL',
|
||||||
|
'gitConnections.token': 'Application token',
|
||||||
|
'gitConnections.newToken': 'New application token',
|
||||||
|
'gitConnections.tokenPlaceholder': 'Paste your Gitea application token',
|
||||||
|
'gitConnections.tokenHelp':
|
||||||
|
'Your token is verified with Gitea before saving. It is never shown in the saved connection.',
|
||||||
|
'gitConnections.verifying': 'Verifying connection…',
|
||||||
|
'gitConnections.save': 'Save changes',
|
||||||
|
'gitConnections.removing': 'Removing connection…',
|
||||||
|
'gitConnections.forbidden': 'Connection access denied',
|
||||||
|
'gitConnections.forbiddenDescription':
|
||||||
|
'You can only manage connections belonging to your account. Close this dialog and refresh the page.',
|
||||||
|
'gitConnections.added': 'Git connection added.',
|
||||||
|
'gitConnections.updated': 'Git connection updated.',
|
||||||
|
'gitConnections.removed': 'Git connection removed.',
|
||||||
|
'metadata.title': 'Ticket settings',
|
||||||
|
'metadata.pageTitle': '{project} ticket settings',
|
||||||
|
'metadata.description':
|
||||||
|
'Manage ticket types, priorities, and the information shown on your tickets.',
|
||||||
|
'metadata.updated': 'Ticket settings updated.',
|
||||||
|
'metadata.types': 'Ticket types',
|
||||||
|
'metadata.priorities': 'Priorities',
|
||||||
|
'metadata.addType': 'Add type',
|
||||||
|
'metadata.addPriority': 'Add priority',
|
||||||
|
'metadata.typesDescription':
|
||||||
|
'Define your project’s ticket types and their order. Parent types can contain child tickets.',
|
||||||
|
'metadata.prioritiesDescription':
|
||||||
|
'Define and order project-specific priorities. The last priority must be kept.',
|
||||||
|
'metadata.parentType': 'Parent type',
|
||||||
|
'metadata.standardType': 'Standard type',
|
||||||
|
'metadata.assignedTickets': 'Assigned tickets: {count}',
|
||||||
|
'metadata.editEntry': 'Edit {name}',
|
||||||
|
'metadata.deleteEntry': 'Delete {name}',
|
||||||
|
'metadata.lastType':
|
||||||
|
'The last ticket type cannot be deleted. Add another type first.',
|
||||||
|
'metadata.lastPriority':
|
||||||
|
'The last priority cannot be deleted. Add another priority first.',
|
||||||
|
'metadata.display': 'Display options',
|
||||||
|
'metadata.displayDescription':
|
||||||
|
'Choose which optional information is available on this project’s tickets.',
|
||||||
|
'metadata.author': 'Show ticket author',
|
||||||
|
'metadata.authorHelp': 'Display the author on ticket details.',
|
||||||
|
'metadata.storyPoints': 'Enable story points',
|
||||||
|
'metadata.storyPointsHelp': 'Allow estimates in story points on tickets.',
|
||||||
|
'metadata.name': 'Name',
|
||||||
|
'metadata.positionHelp':
|
||||||
|
'Choose a position from 1 to {count}. Other entries shift automatically.',
|
||||||
|
'metadata.allowChildren': 'Allow child tickets',
|
||||||
|
'metadata.parentFixed':
|
||||||
|
'Whether a type allows child tickets is fixed after creation. Its name and position remain editable.',
|
||||||
|
'metadata.editHelp':
|
||||||
|
'Choose a project-specific name. Changes apply to tickets using this entry.',
|
||||||
|
'metadata.deleteTypeHelp':
|
||||||
|
'Delete this type? Assigned tickets must be moved to a replacement type first. This action cannot be undone.',
|
||||||
|
'metadata.deletePriorityHelp':
|
||||||
|
'Delete this priority? All tickets assigned to this priority will also be permanently deleted. There is no replacement mapping for priorities. This action cannot be undone.',
|
||||||
|
'metadata.replacementHelp':
|
||||||
|
'Move {count} assigned tickets to another type before deleting this one.',
|
||||||
|
'metadata.replacement': 'Replacement ticket type',
|
||||||
|
'metadata.chooseType': 'Choose a type',
|
||||||
|
'metadata.parentReplacement':
|
||||||
|
'If any affected ticket has child tickets, choose a parent type. The server checks this before deleting.',
|
||||||
|
'metadata.customFields': 'Custom fields',
|
||||||
|
'metadata.customFieldsDescription':
|
||||||
|
'Define the project-specific user and number fields shown on tickets.',
|
||||||
|
'metadata.addField': 'Add field',
|
||||||
|
'metadata.noCustomFields': 'No custom fields',
|
||||||
|
'metadata.noCustomFieldsDescription':
|
||||||
|
'Add a field when this project needs structured information beyond the standard ticket details.',
|
||||||
|
'metadata.storedValues': 'Stored values: {count}',
|
||||||
|
'metadata.editField': 'Edit {name}',
|
||||||
|
'metadata.deleteField': 'Delete {name}',
|
||||||
|
'metadata.fieldEditorHelp':
|
||||||
|
'Choose a label and one of the supported field types. Existing ticket values remain attached when this definition is edited.',
|
||||||
|
'metadata.deleteFieldHelp':
|
||||||
|
'Delete this custom field? This action cannot be undone.',
|
||||||
|
'metadata.fieldDeletionImpact':
|
||||||
|
'Deleting this field also permanently removes {count} stored ticket values.',
|
||||||
|
'metadata.fieldLabel': 'Field label',
|
||||||
|
'metadata.fieldType': 'Field type',
|
||||||
|
'metadata.fieldTypeHelp':
|
||||||
|
'Only the field types already supported by this project are available.',
|
||||||
|
'metadata.linkTypes': 'Link types',
|
||||||
|
'metadata.linkTypesDescription':
|
||||||
|
'Define the labels shown from each side of a ticket relationship.',
|
||||||
|
'metadata.addLinkType': 'Add link type',
|
||||||
|
'metadata.noLinkTypes': 'No link types',
|
||||||
|
'metadata.noLinkTypesDescription':
|
||||||
|
'Add a link type before tickets can be connected with a project-specific relationship.',
|
||||||
|
'metadata.ticketRelationships': 'Ticket relationships: {count}',
|
||||||
|
'metadata.linkDirection': '{source} points to {counterpart}',
|
||||||
|
'metadata.editLinkType': 'Edit {name}',
|
||||||
|
'metadata.deleteLinkType': 'Delete {name}',
|
||||||
|
'metadata.linkTypeEditorHelp':
|
||||||
|
'The source label is shown on the originating ticket; the counterpart label is shown on the linked ticket.',
|
||||||
|
'metadata.deleteLinkTypeHelp':
|
||||||
|
'Delete this link type? This action cannot be undone.',
|
||||||
|
'metadata.linkTypeDeletionImpact':
|
||||||
|
'Deleting this link type also permanently removes {count} ticket relationships.',
|
||||||
|
'metadata.sourceLabel': 'Source label',
|
||||||
|
'metadata.sourceLabelHelp':
|
||||||
|
'Shown when viewing the ticket where the relationship starts.',
|
||||||
|
'metadata.counterpartLabel': 'Counterpart label',
|
||||||
|
'metadata.counterpartLabelHelp':
|
||||||
|
'Shown when viewing the ticket at the other end of the relationship.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+213
-404
@@ -1,436 +1,245 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import { dashboard, login } from '@/routes/index';
|
import { ArrowRight, Columns3, GitBranch, ListChecks } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { dashboard, home, login } from '@/routes/index';
|
||||||
|
|
||||||
|
const { t } = useLocalization();
|
||||||
|
|
||||||
|
const features = computed(() => [
|
||||||
|
{
|
||||||
|
icon: Columns3,
|
||||||
|
title: t('welcome.features.workflows.title'),
|
||||||
|
description: t('welcome.features.workflows.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ListChecks,
|
||||||
|
title: t('welcome.features.tickets.title'),
|
||||||
|
description: t('welcome.features.tickets.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GitBranch,
|
||||||
|
title: t('welcome.features.git.title'),
|
||||||
|
description: t('welcome.features.git.description'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const previewColumns = computed(() => [
|
||||||
|
{
|
||||||
|
title: t('welcome.preview.backlog'),
|
||||||
|
tickets: [
|
||||||
|
t('welcome.preview.ticketOnboarding'),
|
||||||
|
t('welcome.preview.ticketRelease'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('welcome.preview.inProgress'),
|
||||||
|
tickets: [t('welcome.preview.ticketRepository')],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('welcome.preview.review'),
|
||||||
|
tickets: [t('welcome.preview.ticketPullRequest')],
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Welcome">
|
|
||||||
<link rel="preconnect" href="https://rsms.me/" />
|
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
|
||||||
</Head>
|
|
||||||
<div
|
<div
|
||||||
class="flex min-h-screen flex-col items-center bg-[#FDFDFC] p-6 text-[#1b1b18] lg:justify-center lg:p-8 dark:bg-[#0a0a0a]"
|
class="bg-background text-foreground relative isolate min-h-dvh overflow-x-hidden"
|
||||||
>
|
>
|
||||||
|
<Head :title="t('welcome.metaTitle')" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-brand/10 mask-[linear-gradient(to_bottom,black,transparent)] absolute inset-x-0 top-0 -z-10 h-[34rem]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="bg-primary/15 absolute left-1/2 top-12 -z-10 size-80 -translate-x-1/2 rounded-full blur-3xl sm:size-[32rem]"
|
||||||
|
/>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="not-has-[nav]:hidden mb-6 w-full max-w-[335px] text-sm lg:max-w-4xl"
|
class="mx-auto flex w-full min-w-0 max-w-6xl items-center justify-between gap-3 px-4 py-4 sm:gap-4 sm:px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<nav class="flex items-center justify-end gap-4">
|
|
||||||
<Link
|
<Link
|
||||||
v-if="$page.props.auth.user"
|
:href="home()"
|
||||||
:href="dashboard()"
|
:aria-label="t('welcome.homeLabel')"
|
||||||
class="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
|
class="focus-visible:ring-ring/35 min-w-0 rounded-md outline-none focus-visible:ring-2"
|
||||||
>
|
>
|
||||||
Dashboard
|
<AppLogo />
|
||||||
</Link>
|
</Link>
|
||||||
<template v-else>
|
|
||||||
<Link
|
<nav :aria-label="t('welcome.navigation')" class="shrink-0">
|
||||||
:href="login()"
|
<Button as-child variant="outline" size="sm">
|
||||||
class="inline-block rounded-sm border border-transparent px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#19140035] dark:text-[#EDEDEC] dark:hover:border-[#3E3E3A]"
|
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
||||||
>
|
{{ t('welcome.action.dashboardShort') }}
|
||||||
Log in
|
<ArrowRight aria-hidden="true" />
|
||||||
</Link>
|
</Link>
|
||||||
</template>
|
<Link v-else :href="login()">
|
||||||
|
{{ t('welcome.action.signInShort') }}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<div
|
|
||||||
class="duration-750 starting:opacity-0 flex w-full items-center justify-center opacity-100 transition-opacity lg:grow"
|
<main class="min-w-0">
|
||||||
>
|
<section
|
||||||
<main
|
aria-labelledby="welcome-title"
|
||||||
class="flex w-full max-w-[335px] flex-col-reverse overflow-hidden rounded-lg lg:max-w-4xl lg:flex-row"
|
class="mx-auto grid w-full min-w-0 max-w-6xl gap-10 px-4 pb-16 pt-12 sm:px-6 sm:pt-20 lg:px-8 lg:pt-24"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex-1 rounded-bl-lg rounded-br-lg bg-white p-6 pb-12 text-[13px] leading-[20px] shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-br-none lg:rounded-tl-lg lg:p-20 dark:bg-[#161615] dark:text-[#EDEDEC] dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
class="mx-auto grid w-full min-w-0 max-w-3xl justify-items-center gap-6 text-center"
|
||||||
>
|
>
|
||||||
<h1 class="mb-1 font-medium">Let's get started</h1>
|
<p
|
||||||
<p class="mb-2 text-[#706f6c] dark:text-[#A1A09A]">
|
class="border-primary/20 bg-card/80 text-primary text-label shadow-xs rounded-full border px-3 py-1.5 uppercase backdrop-blur"
|
||||||
Laravel has an incredibly rich ecosystem. <br />We
|
>
|
||||||
suggest starting with the following.
|
{{ t('welcome.eyebrow') }}
|
||||||
</p>
|
</p>
|
||||||
<ul class="mb-4 flex flex-col lg:mb-6">
|
<div class="grid gap-4">
|
||||||
|
<h1
|
||||||
|
id="welcome-title"
|
||||||
|
class="text-balance text-4xl font-semibold leading-tight tracking-[-0.04em] sm:text-5xl lg:text-6xl"
|
||||||
|
>
|
||||||
|
{{ t('welcome.title') }}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground mx-auto max-w-2xl text-pretty text-base leading-7 sm:text-lg"
|
||||||
|
>
|
||||||
|
{{ t('welcome.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button as-child size="lg">
|
||||||
|
<Link v-if="$page.props.auth.user" :href="dashboard()">
|
||||||
|
{{ t('welcome.action.dashboard') }}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
<Link v-else :href="login()">
|
||||||
|
{{ t('welcome.action.signIn') }}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('welcome.actionHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
:aria-label="t('welcome.preview.label')"
|
||||||
|
class="bg-card/90 min-w-0 rounded-xl border p-3 shadow-[0_24px_80px_-32px_rgba(0,110,47,0.35)] backdrop-blur sm:p-5 dark:shadow-[0_24px_80px_-32px_rgba(74,225,118,0.22)]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="border-border/80 mb-3 flex min-w-0 items-center justify-between gap-3 border-b px-1 pb-3 sm:mb-5 sm:gap-4 sm:px-2 sm:pb-4"
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 gap-1.5" aria-hidden="true">
|
||||||
|
<span class="bg-border size-2 rounded-full" />
|
||||||
|
<span class="bg-border size-2 rounded-full" />
|
||||||
|
<span class="bg-brand size-2 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground min-w-0 truncate"
|
||||||
|
>
|
||||||
|
{{ t('welcome.preview.project') }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
class="bg-success text-success-foreground text-label shrink-0 rounded-full px-2 py-1 uppercase"
|
||||||
|
>
|
||||||
|
{{ t('welcome.preview.connected') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-3 md:grid-cols-3">
|
||||||
|
<section
|
||||||
|
v-for="column in previewColumns"
|
||||||
|
:key="column.title"
|
||||||
|
class="bg-muted/70 grid min-w-0 content-start gap-3 rounded-lg border p-3 sm:p-4"
|
||||||
|
>
|
||||||
|
<h2 class="text-caption font-semibold">
|
||||||
|
{{ column.title }}
|
||||||
|
</h2>
|
||||||
|
<article
|
||||||
|
v-for="ticket in column.tickets"
|
||||||
|
:key="ticket"
|
||||||
|
class="bg-card text-card-foreground shadow-xs grid min-w-0 gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<p class="text-body break-words font-medium">
|
||||||
|
{{ ticket }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between gap-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="bg-accent h-1.5 w-16 rounded-full"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="bg-brand size-5 rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="welcome-features-title"
|
||||||
|
class="border-border/80 bg-card/60 border-y"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto grid w-full max-w-6xl gap-8 px-4 py-14 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<div class="mx-auto grid max-w-2xl gap-3 text-center">
|
||||||
|
<p class="text-label text-primary uppercase">
|
||||||
|
{{ t('welcome.features.eyebrow') }}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="welcome-features-title"
|
||||||
|
class="text-headline-mobile sm:text-headline"
|
||||||
|
>
|
||||||
|
{{ t('welcome.features.title') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="grid gap-4 md:grid-cols-3">
|
||||||
<li
|
<li
|
||||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-0 before:left-[0.4rem] before:top-1/2 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
v-for="feature in features"
|
||||||
|
:key="feature.title"
|
||||||
|
class="bg-card shadow-xs grid gap-4 rounded-lg border p-5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
class="bg-accent text-primary flex size-10 items-center justify-center rounded-md"
|
||||||
>
|
>
|
||||||
<span
|
<component
|
||||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
:is="feature.icon"
|
||||||
>
|
class="size-5"
|
||||||
<span
|
aria-hidden="true"
|
||||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<div class="grid gap-2">
|
||||||
<span>
|
<h3 class="text-title">
|
||||||
Read the
|
{{ feature.title }}
|
||||||
<a
|
</h3>
|
||||||
href="https://laravel.com/docs"
|
<p
|
||||||
target="_blank"
|
class="text-muted-foreground text-pretty leading-6"
|
||||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
|
||||||
>
|
>
|
||||||
<span>Documentation</span>
|
{{ feature.description }}
|
||||||
<svg
|
</p>
|
||||||
width="10"
|
</div>
|
||||||
height="11"
|
|
||||||
viewBox="0 0 10 11"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-2.5 w-2.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="square"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="relative flex items-center gap-4 py-2 before:absolute before:bottom-1/2 before:left-[0.4rem] before:top-0 before:border-l before:border-[#e3e3e0] dark:before:border-[#3E3E3A]"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="relative bg-white py-1 dark:bg-[#161615]"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border border-[#e3e3e0] bg-[#FDFDFC] shadow-[0px_0px_1px_0px_rgba(0,0,0,0.03),0px_1px_2px_0px_rgba(0,0,0,0.06)] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="h-1.5 w-1.5 rounded-full bg-[#dbdbd7] dark:bg-[#3E3E3A]"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Watch video tutorials at
|
|
||||||
<a
|
|
||||||
href="https://laracasts.com"
|
|
||||||
target="_blank"
|
|
||||||
class="ml-1 inline-flex items-center space-x-1 font-medium text-[#f53003] underline underline-offset-4 dark:text-[#FF4433]"
|
|
||||||
>
|
|
||||||
<span>Laracasts</span>
|
|
||||||
<svg
|
|
||||||
width="10"
|
|
||||||
height="11"
|
|
||||||
viewBox="0 0 10 11"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-2.5 w-2.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M7.70833 6.95834V2.79167H3.54167M2.5 8L7.5 3.00001"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="square"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<ul class="flex gap-3 text-sm leading-normal">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://cloud.laravel.com"
|
|
||||||
target="_blank"
|
|
||||||
class="inline-block rounded-sm border border-black bg-[#1b1b18] px-5 py-1.5 text-sm leading-normal text-white hover:border-black hover:bg-black dark:border-[#eeeeec] dark:bg-[#eeeeec] dark:text-[#1C1C1A] dark:hover:border-white dark:hover:bg-white"
|
|
||||||
>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</section>
|
||||||
class="relative -mb-px aspect-[335/364] w-full shrink-0 overflow-hidden rounded-t-lg bg-[#fff2f2] lg:-ml-px lg:mb-0 lg:aspect-auto lg:w-[438px] lg:rounded-r-lg lg:rounded-t-none dark:bg-[#1D0002]"
|
|
||||||
>
|
|
||||||
<!-- Laravel Logo -->
|
|
||||||
<svg
|
|
||||||
class="duration-750 starting:opacity-0 motion-safe:starting:translate-y-6 w-full max-w-none translate-y-0 text-[#F53003] opacity-100 transition-all dark:text-[#F61500]"
|
|
||||||
viewBox="0 0 438 104"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M17.2036 -3H0V102.197H49.5189V86.7187H17.2036V-3Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M110.256 41.6337C108.061 38.1275 104.945 35.3731 100.905 33.3681C96.8667 31.3647 92.8016 30.3618 88.7131 30.3618C83.4247 30.3618 78.5885 31.3389 74.201 33.2923C69.8111 35.2456 66.0474 37.928 62.9059 41.3333C59.7643 44.7401 57.3198 48.6726 55.5754 53.1293C53.8287 57.589 52.9572 62.274 52.9572 67.1813C52.9572 72.1925 53.8287 76.8995 55.5754 81.3069C57.3191 85.7173 59.7636 89.6241 62.9059 93.0293C66.0474 96.4361 69.8119 99.1155 74.201 101.069C78.5885 103.022 83.4247 103.999 88.7131 103.999C92.8016 103.999 96.8667 102.997 100.905 100.994C104.945 98.9911 108.061 96.2359 110.256 92.7282V102.195H126.563V32.1642H110.256V41.6337ZM108.76 75.7472C107.762 78.4531 106.366 80.8078 104.572 82.8112C102.776 84.8161 100.606 86.4183 98.0637 87.6206C95.5202 88.823 92.7004 89.4238 89.6103 89.4238C86.5178 89.4238 83.7252 88.823 81.2324 87.6206C78.7388 86.4183 76.5949 84.8161 74.7998 82.8112C73.004 80.8078 71.6319 78.4531 70.6856 75.7472C69.7356 73.0421 69.2644 70.1868 69.2644 67.1821C69.2644 64.1758 69.7356 61.3205 70.6856 58.6154C71.6319 55.9102 73.004 53.5571 74.7998 51.5522C76.5949 49.5495 78.738 47.9451 81.2324 46.7427C83.7252 45.5404 86.5178 44.9396 89.6103 44.9396C92.7012 44.9396 95.5202 45.5404 98.0637 46.7427C100.606 47.9451 102.776 49.5487 104.572 51.5522C106.367 53.5571 107.762 55.9102 108.76 58.6154C109.756 61.3205 110.256 64.1758 110.256 67.1821C110.256 70.1868 109.756 73.0421 108.76 75.7472Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M242.805 41.6337C240.611 38.1275 237.494 35.3731 233.455 33.3681C229.416 31.3647 225.351 30.3618 221.262 30.3618C215.974 30.3618 211.138 31.3389 206.75 33.2923C202.36 35.2456 198.597 37.928 195.455 41.3333C192.314 44.7401 189.869 48.6726 188.125 53.1293C186.378 57.589 185.507 62.274 185.507 67.1813C185.507 72.1925 186.378 76.8995 188.125 81.3069C189.868 85.7173 192.313 89.6241 195.455 93.0293C198.597 96.4361 202.361 99.1155 206.75 101.069C211.138 103.022 215.974 103.999 221.262 103.999C225.351 103.999 229.416 102.997 233.455 100.994C237.494 98.9911 240.611 96.2359 242.805 92.7282V102.195H259.112V32.1642H242.805V41.6337ZM241.31 75.7472C240.312 78.4531 238.916 80.8078 237.122 82.8112C235.326 84.8161 233.156 86.4183 230.614 87.6206C228.07 88.823 225.251 89.4238 222.16 89.4238C219.068 89.4238 216.275 88.823 213.782 87.6206C211.289 86.4183 209.145 84.8161 207.35 82.8112C205.554 80.8078 204.182 78.4531 203.236 75.7472C202.286 73.0421 201.814 70.1868 201.814 67.1821C201.814 64.1758 202.286 61.3205 203.236 58.6154C204.182 55.9102 205.554 53.5571 207.35 51.5522C209.145 49.5495 211.288 47.9451 213.782 46.7427C216.275 45.5404 219.068 44.9396 222.16 44.9396C225.251 44.9396 228.07 45.5404 230.614 46.7427C233.156 47.9451 235.326 49.5487 237.122 51.5522C238.917 53.5571 240.312 55.9102 241.31 58.6154C242.306 61.3205 242.806 64.1758 242.806 67.1821C242.805 70.1868 242.305 73.0421 241.31 75.7472Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M438 -3H421.694V102.197H438V-3Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M139.43 102.197H155.735V48.2834H183.712V32.1665H139.43V102.197Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M324.49 32.1665L303.995 85.794L283.498 32.1665H266.983L293.748 102.197H314.242L341.006 32.1665H324.49Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M376.571 30.3656C356.603 30.3656 340.797 46.8497 340.797 67.1828C340.797 89.6597 356.094 104 378.661 104C391.29 104 399.354 99.1488 409.206 88.5848L398.189 80.0226C398.183 80.031 389.874 90.9895 377.468 90.9895C363.048 90.9895 356.977 79.3111 356.977 73.269H411.075C413.917 50.1328 398.775 30.3656 376.571 30.3656ZM357.02 61.0967C357.145 59.7487 359.023 43.3761 376.442 43.3761C393.861 43.3761 395.978 59.7464 396.099 61.0967H357.02Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- 13 -->
|
|
||||||
<svg
|
|
||||||
class="relative -ml-8 -mt-[6.6rem] w-[438px] max-w-none [--stroke-color:#1B1B18] lg:ml-0 dark:[--stroke-color:#FF750F]"
|
|
||||||
viewBox="0 0 440 392"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g
|
|
||||||
class="duration-750 starting:opacity-0 text-[#1B1B18] opacity-100 mix-blend-darken transition-all delay-300 dark:text-black dark:mix-blend-normal"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="path-1-mask"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="-0.328613"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
fill="black"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
x="-0.328613"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M234.936 400.8C204.136 400.8 178.936 392.4 159.336 375.6C140.136 358.8 130.536 337 130.536 310.2H200.736C200.736 318.2 203.736 324.8 209.736 330C215.736 335.2 223.736 337.8 233.736 337.8C243.336 337.8 251.136 335 257.136 329.4C263.536 323.8 266.736 316.6 266.736 307.8C266.736 299.8 263.936 293.2 258.336 288C252.736 282.8 245.536 280.2 236.736 280.2H199.536V218.4H236.736C243.536 218.4 249.336 216 254.136 211.2C258.936 206.4 261.336 200.4 261.336 193.2C261.336 184.8 258.736 178.2 253.536 173.4C248.336 168.6 241.736 166.2 233.736 166.2C226.536 166.2 220.336 168.4 215.136 172.8C210.336 177.2 207.936 182.8 207.936 189.6H141.336C141.336 164.8 150.136 144.6 167.736 129C185.336 113 207.936 105 235.536 105C263.136 105 285.536 112.2 302.736 126.6C320.336 141 329.136 160 329.136 183.6C329.136 200.8 324.536 214.8 315.336 225.6C306.136 236 294.336 243.2 279.936 247.2C297.136 252 310.736 260.2 320.736 271.8C331.136 283.4 336.336 298 336.336 315.6C336.336 340.4 326.936 360.8 308.136 376.8C289.336 392.8 264.936 400.8 234.936 400.8Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-1-mask)"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M26.8714 167.6H1.67139V105.2H94.6714V400.2H26.8714V167.6Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-1-mask)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[26px] text-[#F3BEC7] opacity-100 transition-all dark:text-[#4B0600]"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="path-2-mask"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="25.3357"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
fill="black"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
x="25.3357"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M260.6 400.8C229.8 400.8 204.6 392.4 185 375.6C165.8 358.8 156.2 337 156.2 310.2H226.4C226.4 318.2 229.4 324.8 235.4 330C241.4 335.2 249.4 337.8 259.4 337.8C269 337.8 276.8 335 282.8 329.4C289.2 323.8 292.4 316.6 292.4 307.8C292.4 299.8 289.6 293.2 284 288C278.4 282.8 271.2 280.2 262.4 280.2H225.2V218.4H262.4C269.2 218.4 275 216 279.8 211.2C284.6 206.4 287 200.4 287 193.2C287 184.8 284.4 178.2 279.2 173.4C274 168.6 267.4 166.2 259.4 166.2C252.2 166.2 246 168.4 240.8 172.8C236 177.2 233.6 182.8 233.6 189.6H167C167 164.8 175.8 144.6 193.4 129C211 113 233.6 105 261.2 105C288.8 105 311.2 112.2 328.4 126.6C346 141 354.8 160 354.8 183.6C354.8 200.8 350.2 214.8 341 225.6C331.8 236 320 243.2 305.6 247.2C322.8 252 336.4 260.2 346.4 271.8C356.8 283.4 362 298 362 315.6C362 340.4 352.6 360.8 333.8 376.8C315 392.8 290.6 400.8 260.6 400.8Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-2-mask)"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M52.5357 167.6H27.3357V105.2H120.336V400.2H52.5357V167.6Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-2-mask)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[51px] text-[#F8B803] opacity-100 mix-blend-color transition-all dark:text-[#391800] dark:mix-blend-hard-light"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="path-3-mask"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="51"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
fill="black"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
x="51"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M286.264 400.8C255.464 400.8 230.264 392.4 210.664 375.6C191.464 358.8 181.864 337 181.864 310.2H252.064C252.064 318.2 255.064 324.8 261.064 330C267.064 335.2 275.064 337.8 285.064 337.8C294.664 337.8 302.464 335 308.464 329.4C314.864 323.8 318.064 316.6 318.064 307.8C318.064 299.8 315.264 293.2 309.664 288C304.064 282.8 296.864 280.2 288.064 280.2H250.864V218.4H288.064C294.864 218.4 300.664 216 305.464 211.2C310.264 206.4 312.664 200.4 312.664 193.2C312.664 184.8 310.064 178.2 304.864 173.4C299.664 168.6 293.064 166.2 285.064 166.2C277.864 166.2 271.664 168.4 266.464 172.8C261.664 177.2 259.264 182.8 259.264 189.6H192.664C192.664 164.8 201.464 144.6 219.064 129C236.664 113 259.264 105 286.864 105C314.464 105 336.864 112.2 354.064 126.6C371.664 141 380.464 160 380.464 183.6C380.464 200.8 375.864 214.8 366.664 225.6C357.464 236 345.664 243.2 331.264 247.2C348.464 252 362.064 260.2 372.064 271.8C382.464 283.4 387.664 298 387.664 315.6C387.664 340.4 378.264 360.8 359.464 376.8C340.664 392.8 316.264 400.8 286.264 400.8Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-3-mask)"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M78.2 167.6H53V105.2H146V400.2H78.2V167.6Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-3-mask)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[78px] text-[#F3BEC7] opacity-100 mix-blend-multiply transition-all dark:text-[#733000] dark:mix-blend-normal"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="path-4-mask"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="76.6643"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
fill="black"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
x="76.6643"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M311.929 400.8C281.129 400.8 255.929 392.4 236.329 375.6C217.129 358.8 207.529 337 207.529 310.2H277.729C277.729 318.2 280.729 324.8 286.729 330C292.729 335.2 300.729 337.8 310.729 337.8C320.329 337.8 328.129 335 334.129 329.4C340.529 323.8 343.729 316.6 343.729 307.8C343.729 299.8 340.929 293.2 335.329 288C329.729 282.8 322.529 280.2 313.729 280.2H276.529V218.4H313.729C320.529 218.4 326.329 216 331.129 211.2C335.929 206.4 338.329 200.4 338.329 193.2C338.329 184.8 335.729 178.2 330.529 173.4C325.329 168.6 318.729 166.2 310.729 166.2C303.529 166.2 297.329 168.4 292.129 172.8C287.329 177.2 284.929 182.8 284.929 189.6H218.329C218.329 164.8 227.129 144.6 244.729 129C262.329 113 284.929 105 312.529 105C340.129 105 362.529 112.2 379.729 126.6C397.329 141 406.129 160 406.129 183.6C406.129 200.8 401.529 214.8 392.329 225.6C383.129 236 371.329 243.2 356.929 247.2C374.129 252 387.729 260.2 397.729 271.8C408.129 283.4 413.329 298 413.329 315.6C413.329 340.4 403.929 360.8 385.129 376.8C366.329 392.8 341.929 400.8 311.929 400.8Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-4-mask)"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M103.864 167.6H78.6643V105.2H171.664V400.2H103.864V167.6Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-4-mask)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g
|
|
||||||
class="delay-400 duration-750 starting:opacity-0 motion-safe:starting:-translate-x-[102px] text-[#F3BEC7] opacity-100 mix-blend-hard-light transition-all dark:text-[#4B0600]"
|
|
||||||
>
|
|
||||||
<mask
|
|
||||||
id="path-5-mask"
|
|
||||||
maskUnits="userSpaceOnUse"
|
|
||||||
x="102.329"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
fill="black"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
fill="white"
|
|
||||||
x="102.329"
|
|
||||||
y="103"
|
|
||||||
width="338"
|
|
||||||
height="299"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
|
||||||
/>
|
|
||||||
</mask>
|
|
||||||
<path
|
|
||||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M337.593 400.8C306.793 400.8 281.593 392.4 261.993 375.6C242.793 358.8 233.193 337 233.193 310.2H303.393C303.393 318.2 306.393 324.8 312.393 330C318.393 335.2 326.393 337.8 336.393 337.8C345.993 337.8 353.793 335 359.793 329.4C366.193 323.8 369.393 316.6 369.393 307.8C369.393 299.8 366.593 293.2 360.993 288C355.393 282.8 348.193 280.2 339.393 280.2H302.193V218.4H339.393C346.193 218.4 351.993 216 356.793 211.2C361.593 206.4 363.993 200.4 363.993 193.2C363.993 184.8 361.393 178.2 356.193 173.4C350.993 168.6 344.393 166.2 336.393 166.2C329.193 166.2 322.993 168.4 317.793 172.8C312.993 177.2 310.593 182.8 310.593 189.6H243.993C243.993 164.8 252.793 144.6 270.393 129C287.993 113 310.593 105 338.193 105C365.793 105 388.193 112.2 405.393 126.6C422.993 141 431.793 160 431.793 183.6C431.793 200.8 427.193 214.8 417.993 225.6C408.793 236 396.993 243.2 382.593 247.2C399.793 252 413.393 260.2 423.393 271.8C433.793 283.4 438.993 298 438.993 315.6C438.993 340.4 429.593 360.8 410.793 376.8C391.993 392.8 367.593 400.8 337.593 400.8Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-5-mask)"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M129.529 167.6H104.329V105.2H197.329V400.2H129.529V167.6Z"
|
|
||||||
stroke="var(--stroke-color)"
|
|
||||||
stroke-width="2.4"
|
|
||||||
mask="url(#path-5-mask)"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 rounded-t-lg shadow-[inset_0px_0px_0px_1px_rgba(26,26,0,0.16)] lg:rounded-r-lg lg:rounded-t-none dark:shadow-[inset_0px_0px_0px_1px_#fffaed2d]"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
<div class="h-14.5 hidden lg:block"></div>
|
<footer
|
||||||
|
class="text-caption text-muted-foreground mx-auto flex w-full max-w-6xl flex-col gap-2 px-4 py-8 text-center sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<p class="text-foreground font-semibold">KanbanGit</p>
|
||||||
|
<p>{{ t('welcome.footer') }}</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,165 +1,39 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { create, index } from '@/routes/admin/users';
|
import { create, index } from '@/routes/admin/users';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'User management',
|
{ title: t('adminUsers.title'), href: index() },
|
||||||
href: index(),
|
{ title: t('adminUsers.create.title'), href: create() },
|
||||||
},
|
]);
|
||||||
{
|
|
||||||
title: 'Create user',
|
|
||||||
href: create(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Create user" />
|
<Head :title="t('adminUsers.create.title')" />
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||||
title="Create user"
|
|
||||||
description="Create a user account and optionally force a password change on the next login."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Set an initial password that the user can replace after
|
|
||||||
their first sign-in.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="UserManagementController.store.form()"
|
|
||||||
:transform="
|
|
||||||
(data) => ({
|
|
||||||
...data,
|
|
||||||
must_change_password_on_next_login:
|
|
||||||
data.must_change_password_on_next_login ===
|
|
||||||
true ||
|
|
||||||
data.must_change_password_on_next_login ===
|
|
||||||
'on',
|
|
||||||
})
|
|
||||||
"
|
|
||||||
class="space-y-6"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<header class="gap-stack grid min-w-0">
|
||||||
<Label for="name">Name</Label>
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
<Input
|
{{ t('adminUsers.eyebrow') }}
|
||||||
id="name"
|
</p>
|
||||||
name="name"
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
required
|
{{ t('adminUsers.create.title') }}
|
||||||
autofocus
|
</h1>
|
||||||
autocomplete="name"
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
placeholder="Jane Admin"
|
{{ t('adminUsers.create.description') }}
|
||||||
/>
|
</p>
|
||||||
<InputError :message="errors.name" />
|
</header>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<ManagedUserForm mode="create" @cancel="router.visit(index())" />
|
||||||
<Label for="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">Password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Temporary password"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm password</Label
|
|
||||||
>
|
|
||||||
<PasswordInput
|
|
||||||
id="password_confirmation"
|
|
||||||
name="password_confirmation"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
for="must_change_password_on_next_login"
|
|
||||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="must_change_password_on_next_login"
|
|
||||||
name="must_change_password_on_next_login"
|
|
||||||
type="checkbox"
|
|
||||||
checked
|
|
||||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
|
||||||
/>
|
|
||||||
<span class="space-y-1">
|
|
||||||
<span class="block font-medium">
|
|
||||||
Must change password on next login
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="text-muted-foreground block text-sm"
|
|
||||||
>
|
|
||||||
Redirect the user to the security
|
|
||||||
settings page until they save a new
|
|
||||||
password.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.must_change_password_on_next_login
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Create user
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="index()">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,170 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import ManagedUserForm from '@/components/ManagedUserForm.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { edit, index, save } from '@/routes/admin/users';
|
import { edit, index } from '@/routes/admin/users';
|
||||||
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
import type { AdminUserEditPageProps, BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<AdminUserEditPageProps>();
|
const props = defineProps<AdminUserEditPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('adminUsers.title'), href: index() },
|
||||||
{
|
{
|
||||||
title: 'User management',
|
title: t('adminUsers.edit.breadcrumb', { name: props.user.name }),
|
||||||
href: index(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Edit user',
|
|
||||||
href: edit(props.user.id),
|
href: edit(props.user.id),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
name: props.user.name,
|
|
||||||
email: props.user.email,
|
|
||||||
password: '',
|
|
||||||
password_confirmation: '',
|
|
||||||
must_change_password_on_next_login:
|
|
||||||
props.user.must_change_password_on_next_login,
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = (): void => {
|
|
||||||
form.patch(save.url(props.user.id), {
|
|
||||||
preserveScroll: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Edit ${user.name}`" />
|
<Head :title="t('adminUsers.edit.pageTitle', { name: user.name })" />
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-4xl flex-1 flex-col p-4"
|
||||||
:title="`Edit ${user.name}`"
|
|
||||||
description="Update user details, reset the password if needed, and control whether the user must change it on next login."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>User details</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Leave the password fields empty if the current password
|
|
||||||
should stay unchanged.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<form class="space-y-6" @submit.prevent="submit">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
v-model="form.name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
autocomplete="name"
|
|
||||||
placeholder="Jane Admin"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
v-model="form.email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password">New password</Label>
|
|
||||||
<PasswordInput
|
|
||||||
id="password"
|
|
||||||
v-model="form.password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Leave blank to keep current password"
|
|
||||||
/>
|
|
||||||
<InputError :message="form.errors.password" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="password_confirmation"
|
|
||||||
>Confirm new password</Label
|
|
||||||
>
|
>
|
||||||
<PasswordInput
|
<header
|
||||||
id="password_confirmation"
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
v-model="form.password_confirmation"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Confirm new password"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="form.errors.password_confirmation"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
for="must_change_password_on_next_login"
|
|
||||||
class="flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
||||||
>
|
>
|
||||||
<input
|
<UserAvatar
|
||||||
id="must_change_password_on_next_login"
|
:user="user"
|
||||||
v-model="
|
size="xl"
|
||||||
form.must_change_password_on_next_login
|
rounded
|
||||||
"
|
class="ring-border shrink-0 ring-1"
|
||||||
type="checkbox"
|
|
||||||
class="border-input text-primary focus:ring-ring mt-0.5 size-4 rounded"
|
|
||||||
/>
|
/>
|
||||||
<span class="space-y-1">
|
<div class="gap-stack grid min-w-0">
|
||||||
<span class="block font-medium">
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
Must change password on next login
|
{{ t('adminUsers.eyebrow') }}
|
||||||
</span>
|
</p>
|
||||||
<span
|
<h1
|
||||||
class="text-muted-foreground block text-sm"
|
class="text-headline-mobile md:text-headline break-words"
|
||||||
>
|
>
|
||||||
Keep the user locked to the security
|
{{ t('adminUsers.edit.title', { name: user.name }) }}
|
||||||
settings page until they save a new
|
</h1>
|
||||||
password.
|
<p class="text-muted-foreground break-all">
|
||||||
</span>
|
{{ user.email }}
|
||||||
</span>
|
</p>
|
||||||
</Label>
|
</div>
|
||||||
<InputError
|
</header>
|
||||||
:message="
|
|
||||||
form.errors
|
<ManagedUserForm
|
||||||
.must_change_password_on_next_login
|
mode="edit"
|
||||||
"
|
:user="user"
|
||||||
|
@cancel="router.visit(index())"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="form.processing">
|
|
||||||
Save user
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="index()">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,267 +1,468 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import {
|
import {
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Pencil,
|
Pencil,
|
||||||
Shield,
|
ShieldCheck,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
UsersRound,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
import { computed, ref } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { update } from '@/actions/App/Http/Controllers/Admin/UserManagementController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import {
|
||||||
create as createAdminUser,
|
create as createAdminUser,
|
||||||
edit as editAdminUser,
|
edit as editAdminUser,
|
||||||
index as adminUsers,
|
index as adminUsers,
|
||||||
} from '@/routes/admin/users';
|
} from '@/routes/admin/users';
|
||||||
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
|
import type {
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
AdminUsersPageProps,
|
||||||
|
BreadcrumbItem,
|
||||||
|
PermissionOption,
|
||||||
|
} from '@/types';
|
||||||
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
const props = defineProps<AdminUsersPageProps>();
|
const props = defineProps<AdminUsersPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const permissionsOpen = ref(false);
|
||||||
|
const selectedUser = ref<UserModel | null>(null);
|
||||||
|
const permissionForm = useForm('AdminUserGlobalPermissions', {
|
||||||
|
permissions: [] as string[],
|
||||||
|
});
|
||||||
|
const permissionProtection = useFormProtection(permissionForm, {
|
||||||
|
isDirty: () => permissionsOpen.value && permissionForm.isDirty,
|
||||||
|
});
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('adminUsers.title'), href: adminUsers() },
|
||||||
|
]);
|
||||||
|
const summaryItems = computed(() => [
|
||||||
{
|
{
|
||||||
title: 'User management',
|
label: t('adminUsers.summary.total'),
|
||||||
href: adminUsers(),
|
value: props.users.length,
|
||||||
|
icon: UsersRound,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
label: t('adminUsers.summary.admins'),
|
||||||
|
value: props.users.filter((user) =>
|
||||||
|
hasPermission(user.id, 'global:admin'),
|
||||||
|
).length,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('adminUsers.summary.passwordChanges'),
|
||||||
|
value: props.users.filter(
|
||||||
|
(user) => user.must_change_password_on_next_login,
|
||||||
|
).length,
|
||||||
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const hasPermission = (actorId: number, permission: string) =>
|
function permissionsFor(userId: number): string[] {
|
||||||
(props.directPermissions[actorId] ?? []).includes(permission);
|
return props.directPermissions[userId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(userId: number, permission: string): boolean {
|
||||||
|
return permissionsFor(userId).includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionLabel(option: PermissionOption): string {
|
||||||
|
if (option.value === 'global:admin') {
|
||||||
|
return t('adminUsers.permissions.globalAdmin');
|
||||||
|
}
|
||||||
|
if (option.value === 'project:create') {
|
||||||
|
return t('adminUsers.permissions.projectCreate');
|
||||||
|
}
|
||||||
|
return option.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPermissions(user: UserModel): void {
|
||||||
|
selectedUser.value = user;
|
||||||
|
const permissions = [...permissionsFor(user.id)];
|
||||||
|
permissionForm.defaults({ permissions });
|
||||||
|
permissionForm.resetAndClearErrors();
|
||||||
|
permissionProtection.clearFeedback();
|
||||||
|
permissionsOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePermission(value: string, checked: boolean): void {
|
||||||
|
permissionForm.permissions = checked
|
||||||
|
? [...new Set([...permissionForm.permissions, value])]
|
||||||
|
: permissionForm.permissions.filter(
|
||||||
|
(permission) => permission !== value,
|
||||||
|
);
|
||||||
|
permissionForm.clearErrors('permissions');
|
||||||
|
permissionProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePermissions(): void {
|
||||||
|
permissionsOpen.value = false;
|
||||||
|
selectedUser.value = null;
|
||||||
|
permissionForm.defaults({ permissions: [] });
|
||||||
|
permissionForm.resetAndClearErrors();
|
||||||
|
permissionProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePermissionsOpen(open: boolean): void {
|
||||||
|
if (open) {
|
||||||
|
permissionsOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissionForm.processing && permissionProtection.confirmLeave()) {
|
||||||
|
closePermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPermissions(): void {
|
||||||
|
if (!selectedUser.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionProtection.submit(update(selectedUser.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: closePermissions,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="User management" />
|
<Head :title="t('adminUsers.title')" />
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<header
|
||||||
<Heading
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
title="User management"
|
>
|
||||||
description="Assign the global permissions that unlock platform-wide administration and project creation."
|
<div class="gap-stack grid min-w-0">
|
||||||
/>
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('adminUsers.eyebrow') }}
|
||||||
<div class="flex items-center gap-3">
|
</p>
|
||||||
<Button as-child>
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
|
{{ t('adminUsers.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('adminUsers.description') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button as-child class="self-start">
|
||||||
<Link :href="createAdminUser()">
|
<Link :href="createAdminUser()">
|
||||||
<UserPlus class="size-4" />
|
<UserPlus aria-hidden="true" />
|
||||||
User create
|
{{ t('adminUsers.createAction') }}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Badge variant="outline" class="gap-1.5 px-3 py-1">
|
<FeedbackState
|
||||||
<Users class="size-3.5" />
|
|
||||||
Admin-only area
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
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"
|
state="success"
|
||||||
|
:title="t('adminUsers.updated')"
|
||||||
|
:description="status"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section :aria-label="t('adminUsers.summary.label')">
|
||||||
|
<ul class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="item in summaryItems"
|
||||||
|
:key="item.label"
|
||||||
|
class="min-w-0"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<Card class="h-full gap-0 rounded-md py-0">
|
||||||
|
<CardContent
|
||||||
|
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-words"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</p>
|
||||||
|
<p class="text-title mt-1 tabular-nums">
|
||||||
|
{{ item.value }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-labelledby="managed-users-title"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid">
|
||||||
|
<h2 id="managed-users-title" class="text-title">
|
||||||
|
{{
|
||||||
|
t('adminUsers.listTitle', {
|
||||||
|
count: users.length,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('adminUsers.listDescription') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
<FeedbackState
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Users</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Global permissions are assigned directly to users and
|
|
||||||
apply across the entire application.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<div
|
|
||||||
v-if="users.length === 0"
|
v-if="users.length === 0"
|
||||||
class="text-muted-foreground p-6 text-sm"
|
state="empty"
|
||||||
|
:title="t('adminUsers.empty.title')"
|
||||||
|
:description="t('adminUsers.empty.description')"
|
||||||
>
|
>
|
||||||
No users exist yet.
|
<Button as-child variant="outline">
|
||||||
</div>
|
<Link :href="createAdminUser()">
|
||||||
|
<UserPlus aria-hidden="true" />
|
||||||
|
{{ t('adminUsers.createAction') }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</FeedbackState>
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
<ul
|
||||||
<table
|
v-else
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
class="grid min-w-0 gap-4 lg:grid-cols-2 2xl:grid-cols-3"
|
||||||
>
|
>
|
||||||
<thead
|
<li
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-medium">User</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Email</th>
|
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Current access
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-4 py-3 text-right font-medium"
|
|
||||||
>
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="bg-background align-top"
|
class="min-w-0"
|
||||||
|
data-managed-user
|
||||||
>
|
>
|
||||||
<td class="px-4 py-4">
|
<article
|
||||||
<div
|
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-xl border"
|
||||||
class="text-foreground items-medium flex items-center"
|
|
||||||
>
|
>
|
||||||
|
<div class="flex min-w-0 items-start gap-4 p-5">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
:user="user"
|
:user="user"
|
||||||
class="mr-2 size-8"
|
size="lg"
|
||||||
rounded
|
rounded
|
||||||
|
class="ring-border shrink-0 ring-1"
|
||||||
/>
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3
|
||||||
|
class="text-body break-words font-semibold"
|
||||||
|
>
|
||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</div>
|
</h3>
|
||||||
</td>
|
<p
|
||||||
<td class="text-muted-foreground px-4 py-4">
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
{{ user.email }}
|
{{ user.email }}
|
||||||
</td>
|
</p>
|
||||||
<td class="px-4 py-4">
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<Badge variant="outline" class="shrink-0">
|
||||||
|
{{ t('adminUsers.managedAccount') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="gap-stack flex min-w-0 flex-1 flex-wrap content-start border-y px-5 py-4"
|
||||||
|
>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="
|
v-if="
|
||||||
user.must_change_password_on_next_login
|
user.must_change_password_on_next_login
|
||||||
"
|
"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="gap-1.5"
|
class="gap-1.5 whitespace-normal"
|
||||||
>
|
>
|
||||||
<KeyRound class="size-3.5" />
|
<KeyRound
|
||||||
Must change password
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ t('adminUsers.passwordChangePending') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="
|
v-for="option in permissionOptions"
|
||||||
hasPermission(
|
v-show="
|
||||||
user.id,
|
hasPermission(user.id, option.value)
|
||||||
'global:admin',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="gap-1.5"
|
|
||||||
>
|
|
||||||
<Shield class="size-3.5" />
|
|
||||||
Global admin
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="
|
|
||||||
hasPermission(
|
|
||||||
user.id,
|
|
||||||
'project:create',
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:key="option.value"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="gap-1.5"
|
class="gap-1.5 whitespace-normal"
|
||||||
>
|
>
|
||||||
<FolderPlus class="size-3.5" />
|
<ShieldCheck
|
||||||
Project create
|
v-if="option.value === 'global:admin'"
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<FolderPlus
|
||||||
|
v-else
|
||||||
|
class="size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ permissionLabel(option) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
v-if="
|
v-if="permissionsFor(user.id).length === 0"
|
||||||
(
|
class="text-caption text-muted-foreground"
|
||||||
directPermissions[
|
|
||||||
user.id
|
|
||||||
] ?? []
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
No direct global permissions
|
{{ t('adminUsers.permissions.none') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap justify-end gap-3"
|
class="grid gap-2 p-4 sm:grid-cols-2"
|
||||||
|
data-user-actions
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
as-child
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
@click="openPermissions(user)"
|
||||||
>
|
>
|
||||||
|
<ShieldCheck aria-hidden="true" />
|
||||||
|
{{ t('adminUsers.permissions.action') }}
|
||||||
|
</Button>
|
||||||
|
<Button as-child variant="ghost">
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="editAdminUser(user.id)"
|
||||||
editAdminUser(
|
:aria-label="
|
||||||
user.id,
|
t('adminUsers.edit.action', {
|
||||||
)
|
name: user.name,
|
||||||
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Pencil
|
<Pencil aria-hidden="true" />
|
||||||
class="size-4"
|
{{ t('adminUsers.edit.shortAction') }}
|
||||||
/>
|
|
||||||
Edit
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form
|
<Dialog :open="permissionsOpen" @update:open="updatePermissionsOpen">
|
||||||
v-bind="
|
<DialogContent>
|
||||||
UserManagementController.update.form(
|
<DialogHeader>
|
||||||
user.id,
|
<DialogTitle>
|
||||||
)
|
{{
|
||||||
"
|
t('adminUsers.permissions.title', {
|
||||||
class="flex flex-wrap justify-end gap-3"
|
name: selectedUser?.name ?? '',
|
||||||
v-slot="{ processing }"
|
})
|
||||||
|
}}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('adminUsers.permissions.description') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="gap-column grid"
|
||||||
|
:aria-busy="permissionForm.processing"
|
||||||
|
@submit.prevent="submitPermissions"
|
||||||
>
|
>
|
||||||
<label
|
<FormFeedback
|
||||||
|
:state="permissionProtection.state.value"
|
||||||
|
@retry="submitPermissions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
class="grid gap-3"
|
||||||
|
:disabled="permissionForm.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('adminUsers.permissions.available') }}
|
||||||
|
</legend>
|
||||||
|
<Label
|
||||||
v-for="option in permissionOptions"
|
v-for="option in permissionOptions"
|
||||||
:key="`${user.id}-${option.value}`"
|
:key="option.value"
|
||||||
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
:for="'permission-' + option.value"
|
||||||
|
class="bg-muted/35 flex cursor-pointer items-start gap-3 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
:id="'permission-' + option.value"
|
||||||
name="permissions[]"
|
|
||||||
:value="
|
|
||||||
option.value
|
|
||||||
"
|
|
||||||
:checked="
|
:checked="
|
||||||
hasPermission(
|
permissionForm.permissions.includes(
|
||||||
user.id,
|
|
||||||
option.value,
|
option.value,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
:value="option.value"
|
||||||
|
:invalid="!!permissionForm.errors.permissions"
|
||||||
|
:aria-describedby="
|
||||||
|
'permission-help-' + option.value
|
||||||
|
"
|
||||||
|
@update:checked="
|
||||||
|
(checked: boolean | 'indeterminate') =>
|
||||||
|
updatePermission(
|
||||||
|
option.value,
|
||||||
|
checked === true,
|
||||||
|
)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<span>{{
|
<span class="grid gap-1">
|
||||||
option.label
|
<span>{{ permissionLabel(option) }}</span>
|
||||||
}}</span>
|
<span
|
||||||
</label>
|
:id="'permission-help-' + option.value"
|
||||||
|
class="text-caption text-muted-foreground font-normal"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
option.value === 'global:admin'
|
||||||
|
? 'adminUsers.permissions.globalAdminHelp'
|
||||||
|
: 'adminUsers.permissions.projectCreateHelp',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<InputError
|
||||||
|
:message="permissionForm.errors.permissions"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="w-full sm:hidden"
|
class="border-warning/35 bg-warning/8 text-body rounded-md border p-4"
|
||||||
/>
|
>
|
||||||
|
{{ t('adminUsers.permissions.warning') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="permissionForm.processing"
|
||||||
|
@click="updatePermissionsOpen(false)"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
:loading="permissionForm.processing"
|
||||||
variant="outline"
|
data-test="confirm-user-permissions"
|
||||||
:disabled="processing"
|
|
||||||
>
|
>
|
||||||
Save
|
{{ t('adminUsers.permissions.confirm') }}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</DialogFooter>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</DialogContent>
|
||||||
</td>
|
</Dialog>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,174 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import { index, show } from '@/routes/projects';
|
||||||
index as projectsIndex,
|
import { create } from '@/routes/projects/templates';
|
||||||
show as projectShow,
|
|
||||||
} from '@/routes/projects/index';
|
|
||||||
import {
|
|
||||||
create as projectTemplateCreate,
|
|
||||||
store as projectTemplateStore,
|
|
||||||
} from '@/routes/projects/templates';
|
|
||||||
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesCreatePageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
const props = defineProps<ProjectTemplatesCreatePageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: projectsIndex(),
|
{ title: t('projects.saveTemplate'), href: create(props.project.id) },
|
||||||
},
|
]);
|
||||||
{
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Save ${project.name} as new template`" />
|
<Head :title="t('templates.createTitle', { project: project.name })" />
|
||||||
|
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<header class="gap-stack grid">
|
||||||
<div class="space-y-3">
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
<Badge variant="outline" class="px-3 py-1">{{
|
{{ t('templates.createTitle', { project: project.name }) }}
|
||||||
project.key
|
</h1>
|
||||||
}}</Badge>
|
<p class="text-muted-foreground">
|
||||||
<Heading
|
{{ t('templates.createDescription') }}
|
||||||
: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>
|
</p>
|
||||||
<InputError :message="form.errors.slug" />
|
</header>
|
||||||
</div>
|
<ProjectTemplateForm
|
||||||
|
:key="project.id"
|
||||||
<div class="grid gap-3">
|
:project="project"
|
||||||
<div>
|
:copy-area-options="copyAreaOptions"
|
||||||
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,223 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import ProjectTemplateForm from '@/components/projects/ProjectTemplateForm.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import { index, edit } from '@/routes/project-templates';
|
||||||
edit as projectTemplatesEdit,
|
|
||||||
index as projectTemplatesIndex,
|
|
||||||
update as projectTemplatesUpdate,
|
|
||||||
} from '@/routes/project-templates';
|
|
||||||
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesEditPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesEditPageProps>();
|
const props = defineProps<ProjectTemplatesEditPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.templates'), href: index() },
|
||||||
title: 'Project templates',
|
{ title: props.template.name, href: edit(props.template.id) },
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`Edit ${template.name}`" />
|
<Head :title="t('templates.editTitle', { name: template.name })" />
|
||||||
|
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-6xl">
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6 p-4">
|
<header class="gap-stack grid">
|
||||||
<div class="space-y-3">
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
{{ t('templates.editTitle', { name: template.name }) }}
|
||||||
<Badge variant="outline" class="px-3 py-1">{{
|
</h1>
|
||||||
template.slug
|
<p class="text-muted-foreground">
|
||||||
}}</Badge>
|
{{ t('templates.editDescription') }}
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</header>
|
||||||
|
<ProjectTemplateForm
|
||||||
<div class="grid gap-3">
|
:key="template.id"
|
||||||
<div>
|
:template="template"
|
||||||
<Label>Configuration groups</Label>
|
:copy-area-options="copyAreaOptions"
|
||||||
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,158 +1,258 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, router } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { ArrowRight, LayoutTemplate, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
import { useProjectTemplateAreas } from '@/composables/useProjectTemplateAreas';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { destroy, edit, index } from '@/routes/project-templates';
|
||||||
import {
|
import {
|
||||||
destroy as projectTemplatesDestroy,
|
create as createProject,
|
||||||
edit as projectTemplatesEdit,
|
|
||||||
index as projectTemplatesIndex,
|
|
||||||
} from '@/routes/project-templates';
|
|
||||||
import {
|
|
||||||
create as projectsCreate,
|
|
||||||
index as projectsIndex,
|
index as projectsIndex,
|
||||||
} from '@/routes/projects/index';
|
} from '@/routes/projects';
|
||||||
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTemplatesIndexPageProps } from '@/types';
|
||||||
|
|
||||||
|
import type { ProjectTemplateModel } from '@/types/models/project-template-model';
|
||||||
|
|
||||||
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
const props = defineProps<ProjectTemplatesIndexPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const { areaText } = useProjectTemplateAreas();
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'Project templates',
|
{ title: t('navigation.templates'), href: index() },
|
||||||
href: projectTemplatesIndex(),
|
]);
|
||||||
},
|
const templates = computed(() =>
|
||||||
];
|
props.templates.filter((template) => template.is_published),
|
||||||
|
);
|
||||||
const removeTemplate = (templateId: number): void => {
|
const pending = ref<ProjectTemplateModel | null>(null);
|
||||||
router.delete(projectTemplatesDestroy.url(templateId), {
|
const form = useForm({});
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
function requestDelete(template: ProjectTemplateModel): void {
|
||||||
|
form.resetAndClearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
pending.value = template;
|
||||||
|
}
|
||||||
|
function close(open: boolean): void {
|
||||||
|
if (!open && !form.processing) {
|
||||||
|
pending.value = null;
|
||||||
|
form.clearErrors();
|
||||||
|
protection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function removeTemplate(): void {
|
||||||
|
if (!pending.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
protection.submit(destroy(pending.value.id), {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
pending.value = null;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Project templates" />
|
<Head :title="t('navigation.templates')" />
|
||||||
|
<div class="p-page gap-page mx-auto grid w-full min-w-0 max-w-7xl">
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<header
|
||||||
<div
|
class="gap-column flex flex-col sm:flex-row sm:items-start sm:justify-between"
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
|
||||||
>
|
>
|
||||||
<Heading
|
<div class="gap-stack grid min-w-0">
|
||||||
title="Project templates"
|
<h1 class="text-headline-mobile md:text-headline">
|
||||||
description="Reusable published project templates. Internal project base templates stay hidden here by default."
|
{{ t('navigation.templates') }}
|
||||||
/>
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
<div class="flex flex-wrap gap-3">
|
{{ t('templates.description') }}
|
||||||
<Button as-child variant="outline">
|
</p>
|
||||||
<Link :href="projectsIndex()">Back to projects</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child>
|
|
||||||
<Link :href="projectsCreate()">Create project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button as-child class="shrink-0"
|
||||||
|
><Link :href="createProject()"
|
||||||
<div
|
><Plus aria-hidden="true" />{{
|
||||||
|
t('projects.create')
|
||||||
|
}}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<FeedbackState
|
||||||
v-if="status"
|
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"
|
state="success"
|
||||||
|
:title="t('templates.updated')"
|
||||||
|
/>
|
||||||
|
<section
|
||||||
|
class="bg-card gap-column flex min-w-0 flex-col rounded-md border p-5 sm:flex-row sm:items-center"
|
||||||
|
aria-labelledby="save-template-help"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<LayoutTemplate
|
||||||
|
class="text-primary size-6 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<h2 id="save-template-help" class="text-title">
|
||||||
|
{{ t('templates.fromProject') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('templates.fromProjectHelp') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button as-child variant="outline" class="shrink-0"
|
||||||
<div
|
><Link :href="projectsIndex()"
|
||||||
|
>{{ t('templates.browseProjects')
|
||||||
|
}}<ArrowRight aria-hidden="true" /></Link
|
||||||
|
></Button>
|
||||||
|
</section>
|
||||||
|
<FeedbackState
|
||||||
v-if="templates.length === 0"
|
v-if="templates.length === 0"
|
||||||
class="text-muted-foreground text-sm"
|
state="empty"
|
||||||
|
:title="t('templates.empty')"
|
||||||
|
:description="t('templates.emptyHelp')"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
:aria-label="t('templates.published')"
|
||||||
>
|
>
|
||||||
No published project templates have been saved yet.
|
<li
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<Card
|
|
||||||
v-for="template in templates"
|
v-for="template in templates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
class="h-full"
|
class="bg-card flex min-w-0 flex-col rounded-md border"
|
||||||
|
:data-template="template.id"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<div class="gap-column grid min-w-0 p-5">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="gap-stack grid min-w-0">
|
||||||
<CardTitle>{{ template.name }}</CardTitle>
|
<h2 class="text-title break-words">
|
||||||
<Badge variant="secondary">{{
|
{{ template.name }}
|
||||||
template.slug
|
</h2>
|
||||||
}}</Badge>
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
|
{{ template.slug }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="template.linked_project_count"
|
variant="secondary"
|
||||||
variant="outline"
|
class="w-fit whitespace-normal"
|
||||||
|
>{{
|
||||||
|
t('templates.linkedCount', {
|
||||||
|
count: template.linked_project_count ?? 0,
|
||||||
|
})
|
||||||
|
}}</Badge
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-words"
|
||||||
>
|
>
|
||||||
{{ template.linked_project_count }} linked
|
|
||||||
{{
|
{{
|
||||||
template.linked_project_count === 1
|
template.source_project
|
||||||
? 'project'
|
? t('templates.savedFrom', {
|
||||||
: 'projects'
|
project: template.source_project.name,
|
||||||
|
key: template.source_project.key,
|
||||||
|
})
|
||||||
|
: t('templates.sourceUnavailable')
|
||||||
}}
|
}}
|
||||||
</Badge>
|
</p>
|
||||||
</div>
|
<ul
|
||||||
<CardDescription>
|
class="flex flex-wrap gap-2"
|
||||||
<template v-if="template.source_project">
|
:aria-label="t('templates.groups')"
|
||||||
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 }}
|
<li v-for="area in template.copy_areas" :key="area">
|
||||||
</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="whitespace-normal"
|
||||||
|
>{{ areaText(area, 'label') }}</Badge
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gap-stack mt-auto flex flex-wrap border-t p-4">
|
||||||
<div class="flex flex-wrap gap-3">
|
<Button as-child variant="secondary"
|
||||||
<Button as-child variant="secondary">
|
><Link
|
||||||
<Link :href="projectTemplatesEdit(template.id)">
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link
|
|
||||||
:href="
|
:href="
|
||||||
projectsCreate({
|
createProject({
|
||||||
query: { template: template.id },
|
query: { template: template.id },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
|
>{{ t('templates.use') }}</Link
|
||||||
|
></Button
|
||||||
|
>
|
||||||
|
<Button as-child variant="outline"
|
||||||
|
><Link
|
||||||
|
:href="edit(template.id)"
|
||||||
|
:aria-label="
|
||||||
|
t('templates.editTitle', {
|
||||||
|
name: template.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>{{ t('templates.edit') }}</Link
|
||||||
|
></Button
|
||||||
>
|
>
|
||||||
Use template
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
variant="destructive"
|
size="icon"
|
||||||
@click="removeTemplate(template.id)"
|
class="text-destructive ml-auto"
|
||||||
|
:aria-label="
|
||||||
|
t('templates.deleteTitle', {
|
||||||
|
name: template.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="requestDelete(template)"
|
||||||
|
><Trash2 aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Dialog :open="pending !== null" @update:open="close">
|
||||||
|
<DialogContent :show-close-button="!form.processing">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('templates.deleteTitle', {
|
||||||
|
name: pending?.name ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('templates.deleteHelp')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<p v-if="pending?.linked_project_count" class="text-body">
|
||||||
|
{{
|
||||||
|
t('templates.deleteImpact', {
|
||||||
|
count: pending.linked_project_count,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<FormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
@retry="removeTemplate"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="form.processing"
|
||||||
|
@click="close(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
>
|
>
|
||||||
Delete
|
<Button
|
||||||
</Button>
|
variant="destructive"
|
||||||
</div>
|
:loading="form.processing"
|
||||||
</CardContent>
|
:loading-label="t('templates.deleting')"
|
||||||
</Card>
|
@click="removeTemplate"
|
||||||
</div>
|
>{{ t('templates.delete') }}</Button
|
||||||
</div>
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import TicketDetailBlocker from '@/components/tickets/detail/TicketDetailBlocker.vue';
|
||||||
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
import TicketDetailContent from '@/components/tickets/detail/TicketDetailContent.vue';
|
||||||
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
import TicketDetailGit from '@/components/tickets/detail/TicketDetailGit.vue';
|
||||||
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
import TicketDetailHeader from '@/components/tickets/detail/TicketDetailHeader.vue';
|
||||||
@@ -14,6 +15,7 @@ import { show as showTicket } from '@/routes/projects/tickets';
|
|||||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
const ticketContent = useTemplateRef('ticketContent');
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{ title: t('navigation.projects'), href: projectIndex() },
|
{ title: t('navigation.projects'), href: projectIndex() },
|
||||||
{ title: props.project.name, href: show(props.project.id) },
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
@@ -49,6 +51,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
class="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"
|
||||||
>
|
>
|
||||||
<TicketDetailContent
|
<TicketDetailContent
|
||||||
|
ref="ticketContent"
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
:can-edit-ticket="canEditTicket"
|
:can-edit-ticket="canEditTicket"
|
||||||
@@ -62,7 +65,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
/>
|
/>
|
||||||
<aside
|
<aside
|
||||||
:aria-label="t('ticketDetail.metadata')"
|
:aria-label="t('ticketDetail.metadata')"
|
||||||
class="min-w-0 xl:col-start-2 xl:row-span-4 xl:row-start-1"
|
class="min-w-0 xl:col-start-2 xl:row-span-5 xl:row-start-1"
|
||||||
>
|
>
|
||||||
<TicketDetailMetadata
|
<TicketDetailMetadata
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
@@ -71,6 +74,12 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:ticket-field-definitions="ticketFieldDefinitions"
|
:ticket-field-definitions="ticketFieldDefinitions"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
<TicketDetailBlocker
|
||||||
|
:ticket="ticket"
|
||||||
|
:project="project"
|
||||||
|
:can-edit-ticket="canEditTicket"
|
||||||
|
class="xl:col-start-1"
|
||||||
|
/>
|
||||||
<TicketDetailRelationships
|
<TicketDetailRelationships
|
||||||
:ticket="ticket"
|
:ticket="ticket"
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -80,6 +89,7 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
|||||||
:linked-tickets="linkedTickets"
|
:linked-tickets="linkedTickets"
|
||||||
:ticket-link-types="ticketLinkTypes"
|
:ticket-link-types="ticketLinkTypes"
|
||||||
:ticket-link-options="ticketLinkOptions"
|
:ticket-link-options="ticketLinkOptions"
|
||||||
|
@edit-parent="ticketContent?.editParent()"
|
||||||
class="xl:col-start-1"
|
class="xl:col-start-1"
|
||||||
/>
|
/>
|
||||||
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
<TicketDetailHistory :ticket="ticket" class="xl:col-start-1" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm, useRemember } from '@inertiajs/vue3';
|
import { Head, Link, useForm } from '@inertiajs/vue3';
|
||||||
import { computed, toRef, unref } from 'vue';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
import TicketFormField from '@/components/tickets/TicketFormField.vue';
|
|
||||||
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
import TicketFormFields from '@/components/tickets/TicketFormFields.vue';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import FormFeedback from '@/components/FormFeedback.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
@@ -17,10 +17,8 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import {
|
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
|
||||||
buildAcceptanceCriteria,
|
import type { AcceptanceCriteriaModel } from '@/types/models/acceptance-criteria-model';
|
||||||
parseChecklistLines,
|
|
||||||
} from '@/lib/acceptance-criteria';
|
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
@@ -71,19 +69,9 @@ const hasTicketMetadata = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const draftKey = `projects:${props.project.id}:tickets:create`;
|
const draftKey = `projects:${props.project.id}:tickets:create`;
|
||||||
const acceptanceCriteriaDraft = useRemember(
|
const draftGeneration = useSessionStorage(`${draftKey}:generation`, 0);
|
||||||
{ checklist: '' },
|
const form = useForm(`${draftKey}:${draftGeneration.value}`, {
|
||||||
`${draftKey}:checklist`,
|
acceptance_criteria: [] as AcceptanceCriteriaModel,
|
||||||
);
|
|
||||||
const acceptanceCriteriaChecklist = toRef(
|
|
||||||
unref(acceptanceCriteriaDraft),
|
|
||||||
'checklist',
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm(draftKey, {
|
|
||||||
acceptance_criteria: buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
),
|
|
||||||
assignee_user_id: '',
|
assignee_user_id: '',
|
||||||
description: '',
|
description: '',
|
||||||
parent_ticket_id: '',
|
parent_ticket_id: '',
|
||||||
@@ -96,24 +84,17 @@ const form = useForm(draftKey, {
|
|||||||
title: '',
|
title: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isDirty = computed(
|
const isDirty = computed(() => form.isDirty);
|
||||||
() => form.isDirty || acceptanceCriteriaChecklist.value !== '',
|
const protection = useFormProtection(form);
|
||||||
);
|
|
||||||
const protection = useFormProtection(form, { isDirty: () => isDirty.value });
|
|
||||||
const reset = (): void => {
|
const reset = (): void => {
|
||||||
if (form.processing || !protection.confirmLeave()) {
|
if (form.processing || !protection.confirmLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.reset();
|
form.reset();
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
acceptanceCriteriaChecklist.value = '';
|
|
||||||
protection.clearFeedback();
|
protection.clearFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptanceCriteriaPreviewItems = computed(() =>
|
|
||||||
parseChecklistLines(acceptanceCriteriaChecklist.value),
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptanceCriteriaError = computed(() => {
|
const acceptanceCriteriaError = computed(() => {
|
||||||
return (
|
return (
|
||||||
form.errors.acceptance_criteria ??
|
form.errors.acceptance_criteria ??
|
||||||
@@ -128,17 +109,19 @@ const submit = (): void => {
|
|||||||
if (!hasTicketMetadata.value || form.processing) {
|
if (!hasTicketMetadata.value || form.processing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
form.acceptance_criteria = buildAcceptanceCriteria(
|
|
||||||
acceptanceCriteriaChecklist.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
protection.submit(
|
protection.submit(
|
||||||
ProjectTicketController.store({ project: props.project.id }),
|
ProjectTicketController.store({ project: props.project.id }),
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// The source page is unmounted before this callback, so its storage watcher has stopped.
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`${draftKey}:generation`,
|
||||||
|
String(draftGeneration.value + 1),
|
||||||
|
);
|
||||||
|
draftGeneration.value++;
|
||||||
form.reset();
|
form.reset();
|
||||||
acceptanceCriteriaChecklist.value = '';
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -224,46 +207,11 @@ const submit = (): void => {
|
|||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<TicketFormField
|
<AcceptanceCriteriaChecklist
|
||||||
id="acceptance_criteria_checklist"
|
v-model="form.acceptance_criteria"
|
||||||
v-model="acceptanceCriteriaChecklist"
|
|
||||||
kind="textarea"
|
|
||||||
:label="t('ticketDetail.checklistEditor')"
|
|
||||||
:hint="t('ticketForm.checklistHelp')"
|
|
||||||
:placeholder="
|
|
||||||
t('ticketDetail.checklistPlaceholder')
|
|
||||||
"
|
|
||||||
:error="acceptanceCriteriaError"
|
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
|
:error="acceptanceCriteriaError"
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
v-if="acceptanceCriteriaPreviewItems.length"
|
|
||||||
class="bg-muted/30 min-w-0 rounded-xl border p-4 text-sm"
|
|
||||||
>
|
|
||||||
<p class="mb-3 font-medium">
|
|
||||||
{{ t('ticketForm.checklistPreview') }}
|
|
||||||
</p>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li
|
|
||||||
v-for="(
|
|
||||||
item, index
|
|
||||||
) in acceptanceCriteriaPreviewItems"
|
|
||||||
:key="index"
|
|
||||||
:style="{
|
|
||||||
marginInlineStart: `${item.indent * 0.75}rem`,
|
|
||||||
}"
|
|
||||||
class="flex min-w-0 items-start gap-2"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{{
|
|
||||||
item.checked ? '☑' : '☐'
|
|
||||||
}}</span>
|
|
||||||
<span
|
|
||||||
class="wrap-anywhere min-w-0"
|
|
||||||
>{{ item.text }}</span
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</TicketFormFields>
|
</TicketFormFields>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,674 +1,209 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import ProjectBoardSettingsController from '@/actions/App/Http/Controllers/ProjectBoardSettingsController';
|
import { Columns3, Inbox, Pencil, Plus, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
|
import WorkflowEditorDialog from '@/components/projects/WorkflowEditorDialog.vue';
|
||||||
|
import WorkflowStatusList from '@/components/projects/WorkflowStatusList.vue';
|
||||||
|
import WorkflowTransitionMatrix from '@/components/projects/WorkflowTransitionMatrix.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
Card,
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import {
|
import { index, show } from '@/routes/projects';
|
||||||
board as boardSettings,
|
import { board } from '@/routes/projects/settings';
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { WorkflowEditorTarget } from '@/types/project-workflow';
|
||||||
|
|
||||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const target = ref<WorkflowEditorTarget | null>(null);
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'Projects',
|
{ title: t('navigation.projects'), href: index() },
|
||||||
href: index(),
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
},
|
{ title: t('workflow.title'), href: board(props.project.id) },
|
||||||
{
|
]);
|
||||||
title: props.project.name,
|
|
||||||
href: show(props.project.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Board settings',
|
|
||||||
href: boardSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
|
|
||||||
if (
|
|
||||||
ticketStatus.board_column === null ||
|
|
||||||
ticketStatus.board_column === undefined
|
|
||||||
) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ticketStatus.board_column.name === ticketStatus.name) {
|
|
||||||
return ticketStatus.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const transitionTargetOptions = (
|
|
||||||
sourceStatus: TicketStatusModel,
|
|
||||||
): TicketStatusModel[] => {
|
|
||||||
return props.ticketStatuses.filter(
|
|
||||||
(ticketStatus) => ticketStatus.id !== sourceStatus.id,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusAllowsTarget = (
|
|
||||||
sourceStatus: TicketStatusModel,
|
|
||||||
targetStatus: TicketStatusModel,
|
|
||||||
): boolean => {
|
|
||||||
return (
|
|
||||||
sourceStatus.allowed_target_statuses?.some(
|
|
||||||
(allowedTargetStatus) => allowedTargetStatus.id === targetStatus.id,
|
|
||||||
) ?? false
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} board settings`" />
|
<Head :title="t('workflow.pageTitle', { project: project.name })" />
|
||||||
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<header class="grid gap-3">
|
||||||
<div
|
<h2 class="text-title">{{ t('workflow.title') }}</h2>
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
<p class="text-muted-foreground">
|
||||||
>
|
{{ t('workflow.description') }}
|
||||||
<div class="space-y-3">
|
</p>
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
</header>
|
||||||
{{ project.key }}
|
<FeedbackState
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} board settings`"
|
|
||||||
description="Configure the ordered board columns that define valid in-board ticket statuses for this project."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
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"
|
state="success"
|
||||||
>
|
:title="t('workflow.updated')"
|
||||||
{{ status }}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TemplateInheritancePanel
|
<TemplateInheritancePanel
|
||||||
|
:project-id="project.id"
|
||||||
|
:template-state="templateState"
|
||||||
:areas="[
|
:areas="[
|
||||||
'board_columns',
|
'board_columns',
|
||||||
'ticket_statuses',
|
'ticket_statuses',
|
||||||
'status_transitions',
|
'status_transitions',
|
||||||
]"
|
]"
|
||||||
:project-id="project.id"
|
|
||||||
:template-state="templateState"
|
|
||||||
/>
|
/>
|
||||||
|
<Card
|
||||||
<Card>
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Workflow model</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Backlog is always the intake queue; board columns group
|
|
||||||
the statuses that appear on the board.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="flex flex-wrap gap-3">
|
|
||||||
<Badge class="px-3 py-1">Backlog</Badge>
|
|
||||||
<Badge
|
|
||||||
v-for="ticketStatus in backlogTicketStatuses"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
variant="outline"
|
|
||||||
class="px-3 py-1"
|
|
||||||
>
|
>
|
||||||
{{ ticketStatus.name }}
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</Badge>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Inbox
|
||||||
|
class="text-primary size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ t('workflow.backlog') }}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
t('workflow.offBoard')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Tickets move between statuses; backlog statuses are not
|
{{ t('workflow.backlogDescription') }}
|
||||||
included in a board column.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Status moves</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Choose exactly where each status is allowed to move
|
|
||||||
next.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6"
|
||||||
<CardContent class="p-0">
|
><WorkflowStatusList
|
||||||
<div class="overflow-x-auto">
|
:column="null"
|
||||||
<table
|
:statuses="backlogTicketStatuses"
|
||||||
class="divide-border min-w-full divide-y text-sm"
|
@edit="target = $event"
|
||||||
|
/></CardContent>
|
||||||
|
</Card>
|
||||||
|
<section
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
aria-labelledby="workflow-columns"
|
||||||
>
|
>
|
||||||
<thead
|
<header
|
||||||
class="bg-muted/40 text-muted-foreground text-left"
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
<tr>
|
<div class="flex items-center gap-2">
|
||||||
<th class="px-4 py-3 font-medium">
|
<Columns3
|
||||||
Source status
|
class="text-primary size-5"
|
||||||
</th>
|
aria-hidden="true"
|
||||||
<th class="px-4 py-3 font-medium">
|
|
||||||
Allowed move targets
|
|
||||||
</th>
|
|
||||||
<th class="px-4 py-3 font-medium">Save</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-border divide-y">
|
|
||||||
<tr
|
|
||||||
v-for="sourceStatus in ticketStatuses"
|
|
||||||
:key="sourceStatus.id"
|
|
||||||
class="bg-background align-top"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{{
|
|
||||||
ticketStatusLabel(sourceStatus)
|
|
||||||
}}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Form
|
|
||||||
:id="`status-transitions-${sourceStatus.id}`"
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.updateStatusTransitions.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
sourceStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-3"
|
|
||||||
v-slot="{ errors }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
transitionTargetOptions(
|
|
||||||
sourceStatus,
|
|
||||||
).length === 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
>
|
|
||||||
No other statuses exist yet.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="grid gap-2 md:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
v-for="targetStatus in transitionTargetOptions(
|
|
||||||
sourceStatus,
|
|
||||||
)"
|
|
||||||
:key="targetStatus.id"
|
|
||||||
class="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="allowed_ticket_status_ids[]"
|
|
||||||
:value="targetStatus.id"
|
|
||||||
:checked="
|
|
||||||
statusAllowsTarget(
|
|
||||||
sourceStatus,
|
|
||||||
targetStatus,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-input text-primary focus:ring-ring size-4 rounded"
|
|
||||||
/>
|
/>
|
||||||
<span>{{
|
<h3 id="workflow-columns" class="font-semibold">
|
||||||
ticketStatusLabel(
|
{{ t('workflow.columns') }}
|
||||||
targetStatus,
|
</h3>
|
||||||
)
|
<Badge variant="secondary">{{
|
||||||
}}</span>
|
boardColumns.length
|
||||||
</label>
|
}}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.allowed_ticket_status_ids
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="outline"
|
class="w-full sm:w-auto"
|
||||||
:form="`status-transitions-${sourceStatus.id}`"
|
@click="target = { kind: 'column', action: 'create' }"
|
||||||
|
><Plus class="size-4" aria-hidden="true" />{{
|
||||||
|
t('workflow.addColumn')
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Save
|
</header>
|
||||||
</Button>
|
<p class="text-muted-foreground text-sm">
|
||||||
</td>
|
{{ t('workflow.columnsDescription') }}
|
||||||
</tr>
|
</p>
|
||||||
</tbody>
|
<FeedbackState
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Backlog statuses</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
New backlog tickets choose one of these statuses.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="ticketStatus in backlogTicketStatuses"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
class="border-border rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.updateStatus.form(
|
|
||||||
[project.id, ticketStatus.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`backlog-status-${ticketStatus.id}`"
|
|
||||||
>
|
|
||||||
Status name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`backlog-status-${ticketStatus.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="ticketStatus.name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroyStatus.form(
|
|
||||||
[project.id, ticketStatus.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<InputError
|
|
||||||
:message="errors.ticket_status_id"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="
|
|
||||||
processing ||
|
|
||||||
backlogTicketStatuses.length <= 1
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.storeStatus.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="backlog-status-name">
|
|
||||||
New backlog status
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="backlog-status-name"
|
|
||||||
name="name"
|
|
||||||
placeholder="Ready"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add status
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add column</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
New columns extend the project-specific workflow and
|
|
||||||
are inserted at the end unless you specify a
|
|
||||||
position later.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.store.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="name">Column name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="In Review"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add column
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Board columns</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Rename columns or change the numeric position to
|
|
||||||
reorder the board from left to right.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="boardColumns.length === 0"
|
v-if="boardColumns.length === 0"
|
||||||
class="text-muted-foreground text-sm"
|
state="empty"
|
||||||
|
:title="t('workflow.emptyColumns')"
|
||||||
|
:description="t('workflow.emptyColumnsDescription')"
|
||||||
|
/>
|
||||||
|
<ol
|
||||||
|
v-else
|
||||||
|
class="grid min-w-0 gap-4"
|
||||||
|
:aria-label="t('workflow.columns')"
|
||||||
>
|
>
|
||||||
No board columns exist yet.
|
<li
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="column in boardColumns"
|
v-for="column in boardColumns"
|
||||||
:key="column.id"
|
:key="column.id"
|
||||||
class="border-border rounded-xl border p-4"
|
class="bg-card min-w-0 overflow-hidden rounded-xl border"
|
||||||
|
:data-column-id="column.id"
|
||||||
>
|
>
|
||||||
<Form
|
<header
|
||||||
v-bind="
|
class="bg-muted/35 flex min-w-0 flex-wrap items-center gap-3 border-b p-4 sm:p-5"
|
||||||
ProjectBoardSettingsController.update.form([
|
>
|
||||||
project.id,
|
<span
|
||||||
column.id,
|
class="bg-accent text-accent-foreground flex size-9 shrink-0 items-center justify-center rounded-lg text-sm font-semibold"
|
||||||
])
|
:aria-label="
|
||||||
|
t('workflow.positionValue', {
|
||||||
|
position: column.position,
|
||||||
|
})
|
||||||
"
|
"
|
||||||
class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_8rem_auto_auto]"
|
>{{ column.position }}</span
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-2">
|
<h4
|
||||||
<Label :for="`column-name-${column.id}`">
|
class="min-w-0 flex-1 basis-24 break-words font-semibold"
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`column-name-${column.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="column.name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`column-position-${column.id}`"
|
|
||||||
>
|
>
|
||||||
Position
|
{{ column.name }}
|
||||||
</Label>
|
</h4>
|
||||||
<Input
|
<div class="flex gap-1">
|
||||||
:id="`column-position-${column.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="boardColumns.length"
|
|
||||||
:default-value="column.position"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
:disabled="processing"
|
size="icon"
|
||||||
>
|
:aria-label="
|
||||||
Save
|
t('workflow.changeColumn', {
|
||||||
</Button>
|
name: column.name,
|
||||||
</div>
|
})
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroy.form(
|
|
||||||
[project.id, column.id],
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
v-slot="{ processing }"
|
@click="
|
||||||
>
|
target = {
|
||||||
|
kind: 'column',
|
||||||
|
action: 'edit',
|
||||||
|
column,
|
||||||
|
}
|
||||||
|
"
|
||||||
|
><Pencil class="size-4" aria-hidden="true"
|
||||||
|
/></Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
:disabled="processing"
|
size="icon"
|
||||||
>
|
class="text-destructive"
|
||||||
Delete
|
:aria-label="
|
||||||
</Button>
|
t('workflow.removeColumn', {
|
||||||
</Form>
|
name: column.name,
|
||||||
</div>
|
})
|
||||||
|
|
||||||
<div class="border-border mt-4 border-t pt-4">
|
|
||||||
<div class="mb-3">
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
Ticket statuses
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Moving into this column requires a
|
|
||||||
specific status.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="ticketStatus in column.ticket_statuses ??
|
|
||||||
[]"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
class="border-border rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.updateStatus.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
ticketStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
@click="
|
||||||
v-slot="{ errors, processing }"
|
target = {
|
||||||
>
|
kind: 'column',
|
||||||
<div class="grid gap-2">
|
action: 'delete',
|
||||||
<Label
|
column,
|
||||||
:for="`column-status-${ticketStatus.id}`"
|
}
|
||||||
>
|
|
||||||
Status name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`column-status-${ticketStatus.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="
|
|
||||||
ticketStatus.name
|
|
||||||
"
|
"
|
||||||
required
|
><Trash2 class="size-4" aria-hidden="true"
|
||||||
/>
|
/></Button>
|
||||||
<InputError
|
</div>
|
||||||
:message="errors.name"
|
</header>
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<WorkflowStatusList
|
||||||
|
:column="column"
|
||||||
|
:statuses="column.ticket_statuses ?? []"
|
||||||
|
@edit="target = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</li>
|
||||||
<div class="flex items-end">
|
</ol>
|
||||||
<Button
|
</section>
|
||||||
type="submit"
|
<WorkflowTransitionMatrix
|
||||||
variant="outline"
|
:project-id="project.id"
|
||||||
:disabled="processing"
|
:statuses="ticketStatuses"
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.destroyStatus.form(
|
|
||||||
[
|
|
||||||
project.id,
|
|
||||||
ticketStatus.id,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.ticket_status_id
|
|
||||||
"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<WorkflowEditorDialog
|
||||||
type="submit"
|
:project-id="project.id"
|
||||||
variant="destructive"
|
:column-count="boardColumns.length"
|
||||||
:disabled="
|
:target="target"
|
||||||
processing ||
|
@close="target = null"
|
||||||
(column.ticket_statuses
|
|
||||||
?.length ?? 0) <= 1
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectBoardSettingsController.storeStatus.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-4 grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="board_column_id"
|
|
||||||
:value="column.id"
|
|
||||||
/>
|
/>
|
||||||
<div class="grid gap-2">
|
</SettingsLayout>
|
||||||
<Label
|
|
||||||
:for="`new-column-status-${column.id}`"
|
|
||||||
>
|
|
||||||
New status
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`new-column-status-${column.id}`"
|
|
||||||
name="name"
|
|
||||||
placeholder="Ready for QA"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Add status
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,281 +1,316 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head, Link, useForm } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import ProjectController from '@/actions/App/Http/Controllers/ProjectController';
|
import { Layers3, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
switchTemplate,
|
||||||
|
} from '@/actions/App/Http/Controllers/ProjectController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import {
|
import { general } from '@/routes/projects/settings';
|
||||||
board as boardSettings,
|
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
const props = defineProps<ProjectGeneralSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{ title: t('navigation.projects'), href: index() },
|
||||||
title: 'Projects',
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
href: index(),
|
{ title: t('navigation.general'), href: general(props.project.id) },
|
||||||
},
|
]);
|
||||||
{
|
const switchTemplateForm = useForm<{
|
||||||
title: props.project.name,
|
confirmation_key: string;
|
||||||
href: show(props.project.id),
|
source_template_id: number | undefined;
|
||||||
},
|
}>({
|
||||||
{
|
|
||||||
title: 'General settings',
|
|
||||||
href: generalSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const switchTemplateForm = useForm({
|
|
||||||
confirmation_key: '',
|
confirmation_key: '',
|
||||||
source_template_id: props.sourceTemplates.find(
|
source_template_id: undefined,
|
||||||
(template) => template.id !== props.project.project_template_id,
|
|
||||||
)?.id,
|
|
||||||
});
|
});
|
||||||
|
const deleteForm = useForm({ confirmation_key: '' });
|
||||||
const deleteForm = useForm({
|
const switchProtection = useFormProtection(switchTemplateForm);
|
||||||
confirmation_key: '',
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
});
|
const processing = computed(
|
||||||
|
() => switchTemplateForm.processing || deleteForm.processing,
|
||||||
|
);
|
||||||
const availableSwitchTemplates = computed(() =>
|
const availableSwitchTemplates = computed(() =>
|
||||||
props.sourceTemplates.filter(
|
props.sourceTemplates.filter(
|
||||||
(template) => template.id !== props.project.project_template_id,
|
(template) => template.id !== props.project.project_template_id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentTemplate = computed(() => props.project.project_template ?? null);
|
const currentTemplate = computed(() => props.project.project_template ?? null);
|
||||||
|
|
||||||
const isSwitchConfirmationValid = computed(
|
|
||||||
() => switchTemplateForm.confirmation_key === props.project.key,
|
|
||||||
);
|
|
||||||
|
|
||||||
const canSwitchTemplate = computed(
|
const canSwitchTemplate = computed(
|
||||||
() =>
|
() =>
|
||||||
isSwitchConfirmationValid.value &&
|
switchTemplateForm.confirmation_key === props.project.key &&
|
||||||
switchTemplateForm.source_template_id !== undefined,
|
availableSwitchTemplates.value.some(
|
||||||
|
(template) => template.id === switchTemplateForm.source_template_id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDeleteConfirmationValid = computed(
|
const isDeleteConfirmationValid = computed(
|
||||||
() => deleteForm.confirmation_key === props.project.key,
|
() => deleteForm.confirmation_key === props.project.key,
|
||||||
);
|
);
|
||||||
|
const inheritanceAreas = computed(() =>
|
||||||
|
[
|
||||||
|
...(props.templateState?.inheritedAreas ?? []),
|
||||||
|
...(props.templateState?.overriddenAreas ?? []),
|
||||||
|
].map((area) => area.value),
|
||||||
|
);
|
||||||
|
const settingsSource = computed(() => {
|
||||||
|
if (!props.templateState?.inheritsFromTemplate) {
|
||||||
|
return 'projectSettings.local';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.templateState.inheritedAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'projectSettings.inherited';
|
||||||
|
}
|
||||||
|
return props.templateState.overriddenAreas.some(
|
||||||
|
(area) => area.value === 'project_settings',
|
||||||
|
)
|
||||||
|
? 'projectSettings.override'
|
||||||
|
: 'projectSettings.local';
|
||||||
|
});
|
||||||
|
|
||||||
const submitSwitchTemplate = (): void => {
|
function submitSwitchTemplate(): void {
|
||||||
if (!canSwitchTemplate.value) {
|
if (!canSwitchTemplate.value || processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
switchProtection.submit(switchTemplate(props.project.id), {
|
||||||
switchTemplateForm.post(
|
|
||||||
ProjectController.switchTemplate.url(props.project.id),
|
|
||||||
{
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
},
|
onSuccess: () => switchTemplateForm.resetAndClearErrors(),
|
||||||
);
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const submitDelete = (): void => {
|
function submitDelete(): void {
|
||||||
if (!isDeleteConfirmationValid.value) {
|
if (!isDeleteConfirmationValid.value || processing.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deleteProtection.submit(destroy(props.project.id), {
|
||||||
deleteForm.delete(ProjectController.destroy.url(props.project.id), {
|
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} general settings`" />
|
<Head
|
||||||
|
:title="t('projectSettings.pageTitle', { project: project.name })"
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
|
||||||
{{ project.key }}
|
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} general settings`"
|
|
||||||
description="Review the core project identity and manage irreversible administrative actions."
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
|
<FeedbackState
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="boardSettings(project.id)">
|
|
||||||
Board settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
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"
|
state="success"
|
||||||
|
:title="t('projectSettings.updated')"
|
||||||
|
/>
|
||||||
|
<Card
|
||||||
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
</div>
|
<h2 class="text-title">{{ t('navigation.general') }}</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<Card>
|
{{ t('projectSettings.generalDescription') }}
|
||||||
<CardHeader>
|
</p>
|
||||||
<CardTitle>Project identity</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
These values define the project across tickets, boards,
|
|
||||||
and repository matching.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
<h3 class="font-semibold">
|
||||||
<p
|
{{ t('projectSettings.identity') }}
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
</h3>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
t('projectSettings.local')
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<dl class="grid min-w-0 gap-4 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
Project name
|
<dt class="text-caption text-muted-foreground">
|
||||||
</p>
|
{{ t('projectSettings.name') }}
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
</dt>
|
||||||
|
<dd class="break-words font-semibold">
|
||||||
{{ project.name }}
|
{{ project.name }}
|
||||||
</p>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
|
||||||
>
|
>
|
||||||
Project key
|
<dt class="text-caption text-muted-foreground">
|
||||||
</p>
|
{{ t('projectSettings.key') }}
|
||||||
<p class="text-foreground mt-2 text-sm font-semibold">
|
</dt>
|
||||||
|
<dd class="break-all font-semibold">
|
||||||
{{ project.key }}
|
{{ project.key }}
|
||||||
</p>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.identityDescription') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 border-t pt-4"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold">
|
||||||
|
{{ t('projectSettings.display') }}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
:variant="
|
||||||
|
settingsSource === 'projectSettings.inherited'
|
||||||
|
? 'outline'
|
||||||
|
: 'secondary'
|
||||||
|
"
|
||||||
|
>{{ t(settingsSource) }}</Badge
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<dl class="grid gap-3">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showAuthor') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_author
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<dt class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.showStoryPoints') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="font-medium">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
project.show_story_points
|
||||||
|
? 'projectSettings.enabled'
|
||||||
|
: 'projectSettings.disabled',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.displayDescription') }}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<TemplateInheritancePanel
|
||||||
<Card>
|
:project-id="project.id"
|
||||||
<CardHeader>
|
:areas="inheritanceAreas"
|
||||||
<CardTitle>Template baseline</CardTitle>
|
:template-state="templateState"
|
||||||
<CardDescription>
|
/>
|
||||||
Switch this project to another published template. Use
|
<Card
|
||||||
the inheritance panels to reset individual settings to
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
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
|
<CardHeader class="gap-stack grid-cols-1 border-b p-5 sm:p-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Layers3
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h2 class="text-title">
|
||||||
|
{{ t('projectSettings.template') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('projectSettings.templateDescription') }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
</CardHeader>
|
||||||
class="text-foreground mt-2 text-sm font-semibold"
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
|
<div
|
||||||
|
class="bg-muted/35 gap-stack grid min-w-0 rounded-lg border p-4"
|
||||||
>
|
>
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('projectSettings.currentTemplate') }}
|
||||||
|
</p>
|
||||||
|
<p class="break-words font-semibold">
|
||||||
{{
|
{{
|
||||||
currentTemplate?.name ??
|
currentTemplate?.name ??
|
||||||
'No linked template'
|
t('projectSettings.noTemplate')
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted-foreground mt-2 text-sm">
|
<Badge variant="outline" class="justify-self-start">{{
|
||||||
{{
|
t(
|
||||||
templateState?.inheritsFromTemplate
|
templateState?.inheritsFromTemplate
|
||||||
? 'Inherited and overridden areas stay visible after switching.'
|
? 'projectSettings.linked'
|
||||||
: 'This project currently behaves like a standalone baseline.'
|
: 'projectSettings.standalone',
|
||||||
}}
|
)
|
||||||
</p>
|
}}</Badge>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-border/70 rounded-xl border p-4">
|
|
||||||
<p
|
<p
|
||||||
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
|
v-if="!templateState?.inheritsFromTemplate"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
>
|
>
|
||||||
Warning
|
{{ t('projectSettings.standaloneDescription') }}
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FeedbackState
|
||||||
|
v-if="availableSwitchTemplates.length === 0"
|
||||||
<div class="grid gap-6">
|
state="empty"
|
||||||
|
:title="t('projectSettings.noAlternatives')"
|
||||||
|
:description="
|
||||||
|
t('projectSettings.noAlternativesDescription')
|
||||||
|
"
|
||||||
|
/>
|
||||||
<form
|
<form
|
||||||
class="space-y-4 rounded-xl border p-4"
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="switchTemplateForm.processing"
|
||||||
@submit.prevent="submitSwitchTemplate"
|
@submit.prevent="submitSwitchTemplate"
|
||||||
>
|
>
|
||||||
<div class="space-y-1">
|
<FormFeedback
|
||||||
<h3 class="text-sm font-semibold">
|
:state="switchProtection.state.value"
|
||||||
Switch to another template
|
:title="
|
||||||
</h3>
|
switchTemplateForm.processing
|
||||||
<p class="text-muted-foreground text-sm">
|
? t('projectSettings.switching')
|
||||||
Applying a different template replaces the
|
: undefined
|
||||||
copied areas with that template's current
|
"
|
||||||
baseline.
|
@retry="submitSwitchTemplate"
|
||||||
</p>
|
/>
|
||||||
</div>
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
<div class="grid gap-2">
|
:disabled="processing"
|
||||||
<Label for="source_template_id"
|
|
||||||
>Published template</Label
|
|
||||||
>
|
>
|
||||||
|
<legend class="mb-3 font-semibold">
|
||||||
|
{{ t('projectSettings.switch') }}
|
||||||
|
</legend>
|
||||||
|
<p
|
||||||
|
class="border-destructive/25 bg-destructive/8 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
{{ t('projectSettings.switchWarning') }}
|
||||||
|
</p>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="source_template_id">{{
|
||||||
|
t('projectSettings.publishedTemplate')
|
||||||
|
}}</Label>
|
||||||
<select
|
<select
|
||||||
id="source_template_id"
|
id="source_template_id"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.source_template_id
|
switchTemplateForm.source_template_id
|
||||||
"
|
"
|
||||||
:disabled="
|
class="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 min-h-11 w-full min-w-0 rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||||
switchTemplateForm.processing ||
|
:aria-invalid="
|
||||||
availableSwitchTemplates.length === 0
|
!!switchTemplateForm.errors
|
||||||
|
.source_template_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
|
aria-describedby="source-template-error"
|
||||||
>
|
>
|
||||||
<option :value="undefined" disabled>
|
<option :value="undefined" disabled>
|
||||||
Select a template
|
{{
|
||||||
|
t('projectSettings.selectTemplate')
|
||||||
|
}}
|
||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="template in availableSwitchTemplates"
|
v-for="template in availableSwitchTemplates"
|
||||||
@@ -286,120 +321,167 @@ const submitDelete = (): void => {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="source-template-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.source_template_id
|
.source_template_id
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="switch_confirmation_key"
|
<Label for="switch_confirmation_key">{{
|
||||||
>Confirmation key</Label
|
t('projectSettings.confirmation')
|
||||||
>
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="switch_confirmation_key"
|
id="switch_confirmation_key"
|
||||||
v-model="
|
v-model="
|
||||||
switchTemplateForm.confirmation_key
|
switchTemplateForm.confirmation_key
|
||||||
"
|
"
|
||||||
:disabled="switchTemplateForm.processing"
|
autocomplete="off"
|
||||||
placeholder="Type the project key exactly"
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!switchTemplateForm.errors
|
||||||
|
.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="switch-confirm-help switch-confirm-error"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
id="switch-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
|
id="switch-confirm-error"
|
||||||
:message="
|
:message="
|
||||||
switchTemplateForm.errors
|
switchTemplateForm.errors
|
||||||
.confirmation_key
|
.confirmation_key
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="
|
class="justify-self-start"
|
||||||
switchTemplateForm.processing ||
|
:disabled="!canSwitchTemplate"
|
||||||
!canSwitchTemplate
|
>{{
|
||||||
"
|
t(
|
||||||
|
switchTemplateForm.processing
|
||||||
|
? 'projectSettings.switching'
|
||||||
|
: 'projectSettings.switch',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Switch template
|
</fieldset>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
class="border-red-200/80 bg-red-50/60 dark:border-red-900/60 dark:bg-red-950/20"
|
class="border-destructive/30 min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader
|
||||||
<CardTitle class="text-red-900 dark:text-red-100">
|
class="bg-destructive/5 gap-stack grid-cols-1 border-b p-5 sm:p-6"
|
||||||
Danger zone
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription
|
|
||||||
class="text-red-800/80 dark:text-red-200/80"
|
|
||||||
>
|
>
|
||||||
Deleting this project is permanent. All tickets, board
|
<div class="flex items-center gap-2">
|
||||||
columns, ticket metadata, repository connections, and
|
<Trash2
|
||||||
project permissions will be removed.
|
class="text-destructive size-5"
|
||||||
</CardDescription>
|
aria-hidden="true"
|
||||||
</CardHeader>
|
/>
|
||||||
|
<h2 class="text-title">
|
||||||
<CardContent class="space-y-5">
|
{{ t('projectSettings.danger') }}
|
||||||
<div
|
</h2>
|
||||||
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
|
|
||||||
>
|
|
||||||
<p class="text-foreground text-sm font-medium">
|
|
||||||
Confirm by typing the exact project key:
|
|
||||||
<span class="font-semibold">{{ project.key }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
<form class="space-y-4" @submit.prevent="submitDelete">
|
{{ t('projectSettings.deleteDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="deleteForm.processing"
|
||||||
|
@submit.prevent="submitDelete"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:title="
|
||||||
|
deleteForm.processing
|
||||||
|
? t('projectSettings.deleting')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitDelete"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('projectSettings.delete') }}
|
||||||
|
</legend>
|
||||||
|
<p class="text-caption font-medium">
|
||||||
|
{{ t('projectSettings.irreversible') }}
|
||||||
|
</p>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="confirmation_key">
|
<Label for="confirmation_key">{{
|
||||||
Confirmation key
|
t('projectSettings.confirmation')
|
||||||
</Label>
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmation_key"
|
id="confirmation_key"
|
||||||
v-model="deleteForm.confirmation_key"
|
v-model="deleteForm.confirmation_key"
|
||||||
:disabled="deleteForm.processing"
|
autocomplete="off"
|
||||||
placeholder="Type the project key exactly"
|
:placeholder="
|
||||||
|
t(
|
||||||
|
'projectSettings.confirmPlaceholder',
|
||||||
|
{ key: project.key },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
|
aria-describedby="delete-confirm-help delete-confirm-error"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
id="delete-confirm-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('projectSettings.confirmHelp', {
|
||||||
|
key: project.key,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
<InputError
|
<InputError
|
||||||
:message="deleteForm.errors.confirmation_key"
|
id="delete-confirm-error"
|
||||||
|
:message="
|
||||||
|
deleteForm.errors.confirmation_key
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
:disabled="
|
class="justify-self-start"
|
||||||
deleteForm.processing ||
|
:disabled="!isDeleteConfirmationValid"
|
||||||
!isDeleteConfirmationValid
|
>{{
|
||||||
"
|
t(
|
||||||
|
deleteForm.processing
|
||||||
|
? 'projectSettings.deleting'
|
||||||
|
: 'projectSettings.delete',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Delete project
|
</fieldset>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,121 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import ProjectTicketMetadataSettingsController from '@/actions/App/Http/Controllers/ProjectTicketMetadataSettingsController';
|
import { computed, ref } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import MetadataEditorDialog from '@/components/projects/MetadataEditorDialog.vue';
|
||||||
|
import MetadataOptionList from '@/components/projects/MetadataOptionList.vue';
|
||||||
|
import TicketDisplaySettings from '@/components/projects/TicketDisplaySettings.vue';
|
||||||
|
import TicketFieldDefinitionSettings from '@/components/projects/TicketFieldDefinitionSettings.vue';
|
||||||
|
import TicketLinkTypeSettings from '@/components/projects/TicketLinkTypeSettings.vue';
|
||||||
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
import TemplateInheritancePanel from '@/components/projects/TemplateInheritancePanel.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import SettingsLayout from '@/layouts/projects/SettingsLayout.vue';
|
||||||
import {
|
import { index, show } from '@/routes/projects';
|
||||||
board as boardSettings,
|
import { metadata } from '@/routes/projects/settings';
|
||||||
general as generalSettings,
|
|
||||||
metadata as metadataSettings,
|
|
||||||
notifications as notificationSettings,
|
|
||||||
repositories as repositorySettings,
|
|
||||||
} from '@/routes/projects/settings';
|
|
||||||
import type {
|
import type {
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
ProjectTicketMetadataSettingsPageProps,
|
ProjectTicketMetadataSettingsPageProps,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import type { MetadataEditorTarget } from '@/types/project-metadata';
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
|
const props = defineProps<ProjectTicketMetadataSettingsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const target = ref<MetadataEditorTarget | null>(null);
|
||||||
{
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
title: 'Projects',
|
{ title: t('navigation.projects'), href: index() },
|
||||||
href: index(),
|
{ title: props.project.name, href: show(props.project.id) },
|
||||||
},
|
{ title: t('metadata.title'), href: metadata(props.project.id) },
|
||||||
{
|
]);
|
||||||
title: props.project.name,
|
|
||||||
href: show(props.project.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Ticket settings',
|
|
||||||
href: metadataSettings(props.project.id),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const replacementTicketTypes = (ticketTypeId: number) =>
|
|
||||||
props.ticketTypes.filter((ticketType) => ticketType.id !== ticketTypeId);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head :title="`${project.name} ticket settings`" />
|
<Head :title="t('metadata.pageTitle', { project: project.name })" />
|
||||||
|
<SettingsLayout :project-name="project.name" :project-key="project.key">
|
||||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
<header class="grid gap-3">
|
||||||
<div
|
<h2 class="text-title">{{ t('metadata.title') }}</h2>
|
||||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
<p class="text-muted-foreground">
|
||||||
>
|
{{ t('metadata.description') }}
|
||||||
<div class="space-y-3">
|
</p>
|
||||||
<Badge variant="outline" class="px-3 py-1">
|
</header>
|
||||||
{{ project.key }}
|
<FeedbackState
|
||||||
</Badge>
|
|
||||||
<Heading
|
|
||||||
:title="`${project.name} ticket settings`"
|
|
||||||
description="Configure project-specific ticket types, priorities, custom field definitions, and ticket link labels."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<Button
|
|
||||||
v-if="$page.props.auth.permissions.globalAdmin"
|
|
||||||
as-child
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<Link :href="generalSettings(project.id)">
|
|
||||||
General settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="boardSettings(project.id)">
|
|
||||||
Board settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="repositorySettings(project.id)">
|
|
||||||
Repository settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="notificationSettings(project.id)">
|
|
||||||
Notification settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="show(project.id)">Back to project</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status"
|
v-if="status"
|
||||||
:class="[
|
:state="error ? 'error' : 'success'"
|
||||||
'rounded-xl border px-4 py-3 text-sm',
|
:title="error ? status : t('metadata.updated')"
|
||||||
error
|
/>
|
||||||
? 'border-red-200/80 bg-red-50/60 text-red-800/80 dark:border-red-900/60 dark:bg-red-950/20 dark:text-red-200/80'
|
|
||||||
: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ status }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TemplateInheritancePanel
|
<TemplateInheritancePanel
|
||||||
:areas="[
|
:areas="[
|
||||||
'project_settings',
|
'project_settings',
|
||||||
@@ -127,675 +55,34 @@ const replacementTicketTypes = (ticketTypeId: number) =>
|
|||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
:template-state="templateState"
|
:template-state="templateState"
|
||||||
/>
|
/>
|
||||||
|
<TicketDisplaySettings :project="project" />
|
||||||
<div class="grid gap-6 xl:grid-cols-2">
|
<MetadataOptionList
|
||||||
<Card class="gap-0 overflow-hidden">
|
kind="type"
|
||||||
<CardHeader class="border-b">
|
:entries="ticketTypes"
|
||||||
<CardTitle>General ticket settings</CardTitle>
|
@edit="target = $event"
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.update.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<Label for="enable-story-points">
|
|
||||||
<Checkbox
|
|
||||||
id="enable-story-points"
|
|
||||||
name="show_story_points"
|
|
||||||
:default-value="project.show_story_points"
|
|
||||||
value="1"
|
|
||||||
/>
|
/>
|
||||||
Enable story points for tickets
|
<MetadataOptionList
|
||||||
</Label>
|
kind="priority"
|
||||||
|
:entries="ticketPriorities"
|
||||||
<InputError :message="errors.show_story_points" />
|
@edit="target = $event"
|
||||||
|
|
||||||
<Label for="enable-author">
|
|
||||||
<Checkbox
|
|
||||||
id="enable-author"
|
|
||||||
name="show_author"
|
|
||||||
:default-value="project.show_author"
|
|
||||||
value="1"
|
|
||||||
/>
|
/>
|
||||||
Show author on ticket details
|
<TicketFieldDefinitionSettings
|
||||||
</Label>
|
:definitions="ticketFieldDefinitions"
|
||||||
|
:field-type-options="fieldTypeOptions"
|
||||||
<InputError :message="errors.show_author" />
|
:project-id="project.id"
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Ticket types</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.storeType.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="type-name">New type name</Label>
|
|
||||||
<Input
|
|
||||||
id="type-name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
placeholder="Bug"
|
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.name" />
|
<TicketLinkTypeSettings
|
||||||
|
:link-types="ticketLinkTypes"
|
||||||
<Label for="type-is-parent">
|
:project-id="project.id"
|
||||||
<Checkbox
|
|
||||||
id="type-is-parent"
|
|
||||||
name="is_parent"
|
|
||||||
value="1"
|
|
||||||
/>
|
/>
|
||||||
Ticket type can act as a parent
|
|
||||||
</Label>
|
|
||||||
<InputError :message="errors.is_parent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
<MetadataEditorDialog
|
||||||
Add type
|
:project-id="project.id"
|
||||||
</Button>
|
:target="target"
|
||||||
</Form>
|
:types="ticketTypes"
|
||||||
|
:priorities="ticketPriorities"
|
||||||
<div
|
@close="target = null"
|
||||||
v-for="ticketType in ticketTypes"
|
|
||||||
:key="ticketType.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.updateType.form(
|
|
||||||
[project.id, ticketType.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<div
|
|
||||||
v-if="ticketType.is_parent"
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
Parent type
|
|
||||||
</Badge>
|
|
||||||
<span
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
This flag is fixed after creation.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Label :for="`type-name-${ticketType.id}`">
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`type-name-${ticketType.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="ticketType.name"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.name" />
|
</SettingsLayout>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`type-position-${ticketType.id}`"
|
|
||||||
>
|
|
||||||
Position
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`type-position-${ticketType.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="ticketTypes.length"
|
|
||||||
:default-value="ticketType.position"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.destroyType.form(
|
|
||||||
[project.id, ticketType.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 grid gap-3"
|
|
||||||
v-slot="{ errors, processing: deleting }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
(ticketType.tickets_count ?? 0) > 0 &&
|
|
||||||
ticketTypes.length > 1
|
|
||||||
"
|
|
||||||
class="grid gap-2"
|
|
||||||
>
|
|
||||||
<Label
|
|
||||||
:for="`replacement-type-${ticketType.id}`"
|
|
||||||
>
|
|
||||||
Replacement type for
|
|
||||||
{{ ticketType.tickets_count }} assigned
|
|
||||||
ticket{{
|
|
||||||
ticketType.tickets_count === 1
|
|
||||||
? ''
|
|
||||||
: 's'
|
|
||||||
}}
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
:id="`replacement-type-${ticketType.id}`"
|
|
||||||
name="replacement_ticket_type_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Choose type</option>
|
|
||||||
<option
|
|
||||||
v-for="replacementTicketType in replacementTicketTypes(
|
|
||||||
ticketType.id,
|
|
||||||
)"
|
|
||||||
:key="replacementTicketType.id"
|
|
||||||
:value="replacementTicketType.id"
|
|
||||||
>
|
|
||||||
{{ replacementTicketType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="
|
|
||||||
errors.replacement_ticket_type_id
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-if="
|
|
||||||
(ticketType.tickets_count ?? 0) > 0
|
|
||||||
"
|
|
||||||
class="text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Assigned tickets will be moved before
|
|
||||||
this type is deleted.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="deleting"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Priorities</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Priorities are fully project-specific and can be
|
|
||||||
reordered or removed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.storePriority.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="priority-name"
|
|
||||||
>New priority name</Label
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="priority-name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
placeholder="Urgent"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add priority
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="ticketPriority in ticketPriorities"
|
|
||||||
:key="ticketPriority.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.updatePriority.form(
|
|
||||||
[project.id, ticketPriority.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`priority-name-${ticketPriority.id}`"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`priority-name-${ticketPriority.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="ticketPriority.name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`priority-position-${ticketPriority.id}`"
|
|
||||||
>
|
|
||||||
Position
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`priority-position-${ticketPriority.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="ticketPriorities.length"
|
|
||||||
:default-value="ticketPriority.position"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.destroyPriority.form(
|
|
||||||
[project.id, ticketPriority.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing: deleting }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="deleting"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Custom fields</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Define project-specific user or number fields for
|
|
||||||
upcoming ticket data.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.storeFieldDefinition.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="field-label">New field label</Label>
|
|
||||||
<Input
|
|
||||||
id="field-label"
|
|
||||||
name="label"
|
|
||||||
required
|
|
||||||
placeholder="Reviewer"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.label" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="field-type">Field type</Label>
|
|
||||||
<select
|
|
||||||
id="field-type"
|
|
||||||
name="field_type"
|
|
||||||
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
|
|
||||||
v-for="option in fieldTypeOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.field_type" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add field
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="fieldDefinition in ticketFieldDefinitions"
|
|
||||||
:key="fieldDefinition.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.updateFieldDefinition.form(
|
|
||||||
[project.id, fieldDefinition.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`field-label-${fieldDefinition.id}`"
|
|
||||||
>
|
|
||||||
Label
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`field-label-${fieldDefinition.id}`"
|
|
||||||
name="label"
|
|
||||||
:default-value="fieldDefinition.label"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.label" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`field-type-${fieldDefinition.id}`"
|
|
||||||
>
|
|
||||||
Field type
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
:id="`field-type-${fieldDefinition.id}`"
|
|
||||||
name="field_type"
|
|
||||||
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
|
|
||||||
v-for="option in fieldTypeOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
:selected="
|
|
||||||
option.value ===
|
|
||||||
fieldDefinition.field_type
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError :message="errors.field_type" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`field-position-${fieldDefinition.id}`"
|
|
||||||
>
|
|
||||||
Position
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`field-position-${fieldDefinition.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="ticketFieldDefinitions.length"
|
|
||||||
:default-value="
|
|
||||||
fieldDefinition.position
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.destroyFieldDefinition.form(
|
|
||||||
[project.id, fieldDefinition.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing: deleting }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="deleting"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Link types</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Define project-specific labels for ticket-to-ticket
|
|
||||||
relationships.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.storeLinkType.form(
|
|
||||||
project.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="border-border space-y-4 rounded-xl border border-dashed p-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="link-type-name">
|
|
||||||
Source label
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="link-type-name"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
placeholder="Blocks"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="link-type-counterpart-name">
|
|
||||||
Counterpart label
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="link-type-counterpart-name"
|
|
||||||
name="counterpart_name"
|
|
||||||
required
|
|
||||||
placeholder="Blocked by"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.counterpart_name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add link type
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="ticketLinkType in ticketLinkTypes"
|
|
||||||
:key="ticketLinkType.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.updateLinkType.form(
|
|
||||||
[project.id, ticketLinkType.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`link-type-name-${ticketLinkType.id}`"
|
|
||||||
>
|
|
||||||
Source label
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`link-type-name-${ticketLinkType.id}`"
|
|
||||||
name="name"
|
|
||||||
:default-value="ticketLinkType.label"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`link-type-counterpart-name-${ticketLinkType.id}`"
|
|
||||||
>
|
|
||||||
Counterpart label
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`link-type-counterpart-name-${ticketLinkType.id}`"
|
|
||||||
name="counterpart_name"
|
|
||||||
:default-value="
|
|
||||||
ticketLinkType.counterpart_label
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.counterpart_name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`link-type-position-${ticketLinkType.id}`"
|
|
||||||
>
|
|
||||||
Position
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`link-type-position-${ticketLinkType.id}`"
|
|
||||||
name="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:max="ticketLinkTypes.length"
|
|
||||||
:default-value="ticketLinkType.position"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.position" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap justify-between gap-3"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
:disabled="processing"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
ProjectTicketMetadataSettingsController.destroyLinkType.form(
|
|
||||||
[project.id, ticketLinkType.id],
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="mt-3 flex justify-end"
|
|
||||||
v-slot="{ processing: deleting }"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
:disabled="deleting"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/appearance/index';
|
import { edit } from '@/routes/appearance/index';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: 'Appearance settings',
|
title: t('navigation.appearance'),
|
||||||
href: edit(),
|
href: edit(),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Appearance settings" />
|
<Head :title="t('appearance.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Appearance settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('appearance.title') }}</h2>
|
||||||
title="Appearance settings"
|
<p class="text-muted-foreground">
|
||||||
description="Update your account's appearance settings"
|
{{ t('appearance.description') }}
|
||||||
/>
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
<AppearanceTabs />
|
<AppearanceTabs />
|
||||||
</div>
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('appearance.persistenceHint') }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,198 +1,318 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import UserApplicationController from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
import { Check, Copy, KeyRound, Trash2 } from 'lucide-vue-next';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
destroy,
|
||||||
CardContent,
|
store,
|
||||||
CardDescription,
|
} from '@/actions/App/Http/Controllers/Settings/UserApplicationController';
|
||||||
CardHeader,
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
CardTitle,
|
import InputError from '@/components/InputError.vue';
|
||||||
} from '@/components/ui/card';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editApplications } from '@/routes/applications/index';
|
import { edit } from '@/routes/applications/index';
|
||||||
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsApplicationsPageProps } from '@/types';
|
||||||
|
|
||||||
defineProps<SettingsApplicationsPageProps>();
|
const props = defineProps<SettingsApplicationsPageProps>();
|
||||||
|
const { t, locale } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.applications'), href: edit() },
|
||||||
|
]);
|
||||||
|
const form = useForm({ name: '' });
|
||||||
|
const protection = useFormProtection(form);
|
||||||
|
const deleteForm = useForm({});
|
||||||
|
const deleteProtection = useFormProtection(deleteForm);
|
||||||
|
const selected = ref<
|
||||||
|
SettingsApplicationsPageProps['applications'][number] | null
|
||||||
|
>(null);
|
||||||
|
const copied = ref(false);
|
||||||
|
const copyError = ref(false);
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function formatDate(value: string | null): string {
|
||||||
{
|
if (!value) {
|
||||||
title: 'Applications',
|
return t('applications.never');
|
||||||
href: editApplications(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatDate = (value: string | null): string => {
|
|
||||||
if (value === null) {
|
|
||||||
return 'Never';
|
|
||||||
}
|
}
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
return new Intl.DateTimeFormat(undefined, {
|
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
};
|
}
|
||||||
|
function createApplication(): void {
|
||||||
|
protection.submit(store(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function closeDelete(open: boolean): void {
|
||||||
|
if (!open && !deleteForm.processing) {
|
||||||
|
selected.value = null;
|
||||||
|
deleteProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function deleteApplication(): void {
|
||||||
|
if (!selected.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteProtection.submit(destroy(selected.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
selected.value = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyKey(): Promise<void> {
|
||||||
|
const key = props.applicationKey;
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyError.value = false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(key);
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copied.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (props.applicationKey === key) {
|
||||||
|
copyError.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => props.applicationKey,
|
||||||
|
() => {
|
||||||
|
copied.value = false;
|
||||||
|
copyError.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Applications" />
|
<Head :title="t('applications.title')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<div class="gap-stack grid">
|
||||||
<div class="space-y-2">
|
<h2 class="text-title">{{ t('applications.title') }}</h2>
|
||||||
<Heading
|
<p class="text-muted-foreground">
|
||||||
variant="small"
|
{{ t('applications.description') }}
|
||||||
title="Applications"
|
|
||||||
description="Create API keys for tools that should use KanbanGit with your account permissions."
|
|
||||||
/>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
Keys are shown once. Delete an application when a tool
|
|
||||||
should lose access.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
<div
|
|
||||||
v-if="status"
|
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"
|
role="status"
|
||||||
|
class="bg-success/10 text-success-foreground rounded-lg border p-4 text-sm"
|
||||||
>
|
>
|
||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="applicationKey"
|
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-100"
|
|
||||||
>
|
|
||||||
<p class="font-medium">Save this API key now.</p>
|
|
||||||
<p class="mt-1">
|
|
||||||
It will not be shown again after you leave this page.
|
|
||||||
</p>
|
</p>
|
||||||
|
<section
|
||||||
|
v-if="applicationKey"
|
||||||
|
class="bg-warning/10 gap-stack grid min-w-0 rounded-xl border p-5"
|
||||||
|
aria-labelledby="application-key-title"
|
||||||
|
>
|
||||||
|
<h3 id="application-key-title" class="font-semibold">
|
||||||
|
{{ t('applications.saveKey') }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('applications.keyOnce') }}
|
||||||
|
</p>
|
||||||
|
<Label for="application-key" class="sr-only">{{
|
||||||
|
t('applications.apiKey')
|
||||||
|
}}</Label>
|
||||||
|
<div class="flex min-w-0 flex-wrap gap-2">
|
||||||
<Input
|
<Input
|
||||||
class="mt-3 font-mono text-xs"
|
id="application-key"
|
||||||
:model-value="applicationKey"
|
:model-value="applicationKey"
|
||||||
readonly
|
readonly
|
||||||
|
class="min-w-0 basis-full font-mono text-xs sm:flex-1 sm:basis-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button variant="outline" @click="copyKey"
|
||||||
|
><Check v-if="copied" /><Copy v-else />{{
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
t(copied ? 'security.copied' : 'security.copyKey')
|
||||||
<Card>
|
}}</Button
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Create application</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Name the tool that will receive an API key.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="UserApplicationController.store.form()"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="copied"
|
||||||
|
role="status"
|
||||||
|
class="text-caption text-success-foreground"
|
||||||
|
>
|
||||||
|
{{ t('security.copied') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
v-if="copyError"
|
||||||
|
:message="t('security.copyError')"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<h3 class="flex items-center gap-2 font-semibold">
|
||||||
|
<KeyRound class="text-primary size-5" />{{
|
||||||
|
t('applications.create')
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.nameDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid"
|
||||||
|
:aria-busy="form.processing"
|
||||||
|
@submit.prevent="createApplication"
|
||||||
|
>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="protection.state.value"
|
||||||
|
:response-status="protection.responseStatus.value"
|
||||||
|
:loading-title="t('applications.creating')"
|
||||||
|
@retry="createApplication"
|
||||||
|
/>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="name">Application name</Label>
|
<Label for="application-name">{{
|
||||||
|
t('applications.name')
|
||||||
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="application-name"
|
||||||
|
v-model="form.name"
|
||||||
name="name"
|
name="name"
|
||||||
type="text"
|
|
||||||
required
|
required
|
||||||
autofocus
|
maxlength="255"
|
||||||
placeholder="Claude Desktop"
|
autocomplete="off"
|
||||||
|
:placeholder="t('applications.namePlaceholder')"
|
||||||
|
:disabled="form.processing"
|
||||||
|
:aria-invalid="!!form.errors.name"
|
||||||
|
aria-describedby="application-name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="application-name-error"
|
||||||
|
:message="form.errors.name"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Create application
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
|
||||||
<CardHeader class="border-b">
|
|
||||||
<CardTitle>Saved applications</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Existing keys stay hidden. Create a new
|
|
||||||
application when another key is needed.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent class="space-y-4 p-6">
|
|
||||||
<div
|
|
||||||
v-if="applications.length === 0"
|
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
|
||||||
>
|
|
||||||
No applications have been created yet.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="application in applications"
|
|
||||||
:key="application.id"
|
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-start justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge>API key</Badge>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ application.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Created:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.created_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Last used:
|
|
||||||
{{
|
|
||||||
formatDate(
|
|
||||||
application.last_used_at,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
UserApplicationController.destroy.form(
|
|
||||||
application.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
v-slot="{ processing }"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
class="justify-self-start"
|
||||||
:disabled="processing"
|
:loading="form.processing"
|
||||||
|
:loading-label="t('applications.creating')"
|
||||||
|
>{{ t('applications.create') }}</Button
|
||||||
>
|
>
|
||||||
Delete
|
</form>
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
|
<h3 class="font-semibold">{{ t('applications.saved') }}</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('applications.hiddenKeys') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="gap-stack grid p-5 sm:p-6">
|
||||||
|
<p
|
||||||
|
v-if="!applications.length"
|
||||||
|
class="text-body text-muted-foreground rounded-lg border border-dashed p-6 text-center"
|
||||||
|
>
|
||||||
|
{{ t('applications.empty') }}
|
||||||
|
</p>
|
||||||
|
<article
|
||||||
|
v-for="application in applications"
|
||||||
|
:key="application.id"
|
||||||
|
class="flex min-w-0 flex-wrap items-start justify-between gap-4 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1 basis-40">
|
||||||
|
<h4 class="break-words font-semibold">
|
||||||
|
{{ application.name }}
|
||||||
|
</h4>
|
||||||
|
<dl
|
||||||
|
class="gap-stack text-caption text-muted-foreground grid"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium">
|
||||||
|
{{ t('applications.created') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="inline">
|
||||||
|
{{ formatDate(application.created_at) }}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium">
|
||||||
|
{{ t('applications.lastUsed') }}
|
||||||
|
</dt>
|
||||||
|
<dd class="inline">
|
||||||
|
{{
|
||||||
|
formatDate(application.last_used_at)
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="text-destructive"
|
||||||
|
:aria-label="
|
||||||
|
t('applications.deleteNamed', {
|
||||||
|
name: application.name,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="selected = application"
|
||||||
|
><Trash2 />{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</article>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Dialog :open="!!selected" @update:open="closeDelete">
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!deleteForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
deleteForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('applications.deleteTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('applications.deleteDescription', {
|
||||||
|
name: selected?.name ?? '',
|
||||||
|
})
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="deleteProtection.state.value"
|
||||||
|
:response-status="deleteProtection.responseStatus.value"
|
||||||
|
@retry="deleteApplication"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="deleteForm.processing"
|
||||||
|
@click="closeDelete(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
:loading="deleteForm.processing"
|
||||||
|
@click="deleteApplication"
|
||||||
|
>{{ t('applications.delete') }}</Button
|
||||||
|
>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,267 +1,493 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, usePage } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
import {
|
||||||
import Heading from '@/components/Heading.vue';
|
GitBranch,
|
||||||
|
LockKeyhole,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
destroy,
|
||||||
|
store,
|
||||||
|
update,
|
||||||
|
} from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
||||||
|
import FeedbackState from '@/components/FeedbackState.vue';
|
||||||
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Card,
|
Dialog,
|
||||||
CardContent,
|
DialogContent,
|
||||||
CardDescription,
|
DialogDescription,
|
||||||
CardHeader,
|
DialogFooter,
|
||||||
CardTitle,
|
DialogHeader,
|
||||||
} from '@/components/ui/card';
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
import { edit } from '@/routes/git-connections/index';
|
||||||
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
||||||
|
import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model';
|
||||||
|
|
||||||
defineProps<SettingsGitConnectionsPageProps>();
|
const props = defineProps<SettingsGitConnectionsPageProps>();
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.gitConnections'), href: edit() },
|
||||||
|
]);
|
||||||
|
const mode = ref<'create' | 'edit' | 'remove'>('create');
|
||||||
|
const dialogOpen = ref(false);
|
||||||
|
const selectedConnection = ref<UserGitConnectionModel | null>(null);
|
||||||
|
// Keep provider credentials out of Inertia's remembered browser history.
|
||||||
|
const connectionForm = useForm({
|
||||||
|
provider: 'gitea',
|
||||||
|
base_url: '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
const connectionProtection = useFormProtection(connectionForm);
|
||||||
|
const removalForm = useForm({});
|
||||||
|
const removalProtection = useFormProtection(removalForm);
|
||||||
|
const processing = computed(
|
||||||
|
() => connectionForm.processing || removalForm.processing,
|
||||||
|
);
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
t(
|
||||||
|
mode.value === 'create'
|
||||||
|
? 'gitConnections.add'
|
||||||
|
: mode.value === 'edit'
|
||||||
|
? 'gitConnections.edit'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const successTitle = computed(() =>
|
||||||
|
props.status === 'Git connection was added.'
|
||||||
|
? t('gitConnections.added')
|
||||||
|
: props.status === 'Git connection was updated.'
|
||||||
|
? t('gitConnections.updated')
|
||||||
|
: t('gitConnections.removed'),
|
||||||
|
);
|
||||||
|
|
||||||
const page = usePage();
|
function resetForms(): void {
|
||||||
const appName = String(page.props.name);
|
connectionForm.defaults({ provider: 'gitea', base_url: '', api_token: '' });
|
||||||
|
connectionForm.resetAndClearErrors();
|
||||||
|
connectionProtection.clearFeedback();
|
||||||
|
removalForm.resetAndClearErrors();
|
||||||
|
removalProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
function openDialog(
|
||||||
{
|
nextMode: typeof mode.value,
|
||||||
title: 'Git connections',
|
connection: UserGitConnectionModel | null = null,
|
||||||
href: editGitConnections(),
|
): void {
|
||||||
},
|
resetForms();
|
||||||
];
|
mode.value = nextMode;
|
||||||
|
selectedConnection.value = connection;
|
||||||
|
connectionForm.defaults({
|
||||||
|
provider: connection?.provider ?? 'gitea',
|
||||||
|
base_url: connection?.base_url ?? '',
|
||||||
|
api_token: '',
|
||||||
|
});
|
||||||
|
connectionForm.reset();
|
||||||
|
dialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(open: boolean): void {
|
||||||
|
if (open || processing.value || !connectionProtection.confirmLeave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogOpen.value = false;
|
||||||
|
resetForms();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
resetForms();
|
||||||
|
dialogOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConnection(): void {
|
||||||
|
const target =
|
||||||
|
mode.value === 'edit' && selectedConnection.value
|
||||||
|
? update(selectedConnection.value.id)
|
||||||
|
: store();
|
||||||
|
connectionProtection.submit(target, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConnection(): void {
|
||||||
|
if (!selectedConnection.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removalProtection.submit(destroy(selectedConnection.value.id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Git connections" />
|
<Head :title="t('navigation.gitConnections')" />
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col gap-6">
|
<Card
|
||||||
<div class="space-y-2">
|
class="min-w-0 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
<Heading
|
>
|
||||||
variant="small"
|
<CardHeader
|
||||||
title="Git connections"
|
class="gap-column min-w-0 grid-cols-1 border-b p-5 sm:p-6"
|
||||||
description="Add your personal Git provider credentials here. Project repository links will use these connections to fetch repositories, branches, and pull requests you can access."
|
>
|
||||||
/>
|
<div
|
||||||
<p class="text-muted-foreground text-sm">
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||||
Each user needs their own application key. Without a
|
>
|
||||||
matching personal connection, {{ appName }} can only
|
<div class="gap-stack grid min-w-0">
|
||||||
show repository names and will link you back to this
|
<h2 class="text-title">
|
||||||
page.
|
{{ t('navigation.gitConnections') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('gitConnections.description') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
<div
|
type="button"
|
||||||
v-if="status"
|
class="shrink-0 self-start"
|
||||||
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"
|
@click="openDialog('create')"
|
||||||
>
|
>
|
||||||
{{ status }}
|
<Plus aria-hidden="true" />{{
|
||||||
</div>
|
t('gitConnections.add')
|
||||||
|
}}
|
||||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Add Gitea connection</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Connect a Gitea account by storing the instance
|
|
||||||
base URL and a personal application token.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="GitConnectionController.store.form()"
|
|
||||||
class="space-y-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
value="gitea"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="base_url">Gitea base URL</Label>
|
|
||||||
<Input
|
|
||||||
id="base_url"
|
|
||||||
name="base_url"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="https://git.example.com"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.base_url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="api_token"
|
|
||||||
>Application token</Label
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="api_token"
|
|
||||||
name="api_token"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Paste your Gitea application key"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.api_token" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Add connection
|
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</div>
|
||||||
</CardContent>
|
<div
|
||||||
</Card>
|
class="bg-muted/45 text-caption text-muted-foreground flex items-start gap-2 rounded-lg border p-3"
|
||||||
|
>
|
||||||
<Card class="gap-0 overflow-hidden">
|
<LockKeyhole
|
||||||
<CardHeader class="border-b">
|
class="text-primary mt-0.5 size-4 shrink-0"
|
||||||
<CardTitle>Saved connections</CardTitle>
|
aria-hidden="true"
|
||||||
<CardDescription>
|
/>
|
||||||
Tokens stay hidden after save. Updating a
|
<p>{{ t('gitConnections.privacy') }}</p>
|
||||||
connection requires entering a fresh token.
|
</div>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent class="gap-column grid min-w-0 p-5 sm:p-6">
|
||||||
<CardContent class="space-y-4 p-6">
|
<FeedbackState
|
||||||
<div
|
v-if="status"
|
||||||
v-if="gitConnections.length === 0"
|
state="success"
|
||||||
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
|
:title="successTitle"
|
||||||
>
|
/>
|
||||||
No Git connections have been added yet.
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<h3 class="font-semibold">
|
||||||
|
{{ t('gitConnections.saved') }}
|
||||||
<div
|
</h3>
|
||||||
v-for="gitConnection in gitConnections"
|
<Badge variant="secondary">{{
|
||||||
:key="gitConnection.id"
|
gitConnections.length
|
||||||
class="border-border rounded-xl border p-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="mb-4 flex flex-wrap items-start justify-between gap-3"
|
|
||||||
>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div
|
|
||||||
class="flex flex-wrap items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge>{{
|
|
||||||
gitConnection.provider_label
|
|
||||||
}}</Badge>
|
}}</Badge>
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{
|
|
||||||
gitConnection.account_login
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
<FeedbackState
|
||||||
class="text-muted-foreground text-sm"
|
v-if="gitConnections.length === 0"
|
||||||
>
|
state="empty"
|
||||||
{{
|
:title="t('gitConnections.empty')"
|
||||||
gitConnection.account_name ??
|
:description="t('gitConnections.emptyDescription')"
|
||||||
'Unnamed remote account'
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="text-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
{{ gitConnection.base_url }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
Remote user ID:
|
|
||||||
{{ gitConnection.provider_user_id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form
|
|
||||||
v-bind="
|
|
||||||
GitConnectionController.update.form(
|
|
||||||
gitConnection.id,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="grid gap-4"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
name="provider"
|
|
||||||
type="hidden"
|
|
||||||
:value="gitConnection.provider"
|
|
||||||
/>
|
/>
|
||||||
|
<ul
|
||||||
<div class="grid gap-2">
|
v-else
|
||||||
<Label
|
class="gap-column grid min-w-0"
|
||||||
:for="`base-url-${gitConnection.id}`"
|
:aria-label="t('gitConnections.saved')"
|
||||||
>
|
>
|
||||||
Gitea base URL
|
<li
|
||||||
</Label>
|
v-for="connection in gitConnections"
|
||||||
<Input
|
:key="connection.id"
|
||||||
:id="`base-url-${gitConnection.id}`"
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
||||||
name="base_url"
|
|
||||||
type="url"
|
|
||||||
:default-value="
|
|
||||||
gitConnection.base_url
|
|
||||||
"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.base_url"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label
|
|
||||||
:for="`token-${gitConnection.id}`"
|
|
||||||
>
|
>
|
||||||
New application token
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
:id="`token-${gitConnection.id}`"
|
|
||||||
name="api_token"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
placeholder="Paste a new token to verify and save"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.api_token"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap justify-between gap-3"
|
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<GitBranch
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<h4
|
||||||
|
class="min-w-0 break-all font-semibold"
|
||||||
|
>
|
||||||
|
{{ connection.account_login }}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="secondary">{{
|
||||||
|
connection.provider_label
|
||||||
|
}}</Badge>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-words"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
connection.account_name ??
|
||||||
|
t('gitConnections.unnamedAccount')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="text-caption text-muted-foreground flex min-w-0 items-start gap-2"
|
||||||
|
>
|
||||||
|
<Server
|
||||||
|
class="mt-0.5 size-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">{{
|
||||||
|
connection.base_url
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground break-all"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('gitConnections.remoteId', {
|
||||||
|
id: connection.provider_user_id,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap gap-2 border-t pt-3 sm:justify-end"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:disabled="processing"
|
:aria-label="
|
||||||
|
t('gitConnections.editAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('edit', connection)"
|
||||||
>
|
>
|
||||||
Save changes
|
<Pencil aria-hidden="true" />{{
|
||||||
|
t('gitConnections.editAction')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="
|
||||||
|
t('gitConnections.removeAccount', {
|
||||||
|
account: connection.account_login,
|
||||||
|
server: connection.base_url,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@click="openDialog('remove', connection)"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />{{
|
||||||
|
t('gitConnections.remove')
|
||||||
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</li>
|
||||||
|
</ul>
|
||||||
<Form
|
</CardContent>
|
||||||
v-bind="
|
</Card>
|
||||||
GitConnectionController.destroy.form(
|
<Dialog :open="dialogOpen" @update:open="closeDialog">
|
||||||
gitConnection.id,
|
<DialogContent
|
||||||
|
:show-close-button="!processing"
|
||||||
|
@escape-key-down="processing && $event.preventDefault()"
|
||||||
|
@interact-outside="processing && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ dialogTitle }}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t(
|
||||||
|
mode === 'remove'
|
||||||
|
? 'gitConnections.removeDescription'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.editDescription'
|
||||||
|
: 'gitConnections.addDescription',
|
||||||
)
|
)
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
v-if="mode !== 'remove'"
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="connectionForm.processing"
|
||||||
|
@submit.prevent="saveConnection"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="connectionProtection.state.value"
|
||||||
|
:title="
|
||||||
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: connectionForm.processing
|
||||||
|
? t('gitConnections.verifying')
|
||||||
|
: undefined
|
||||||
"
|
"
|
||||||
class="mt-3 flex justify-end"
|
:description="
|
||||||
v-slot="{ processing }"
|
connectionProtection.responseStatus.value ===
|
||||||
|
403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
connectionProtection.responseStatus.value !==
|
||||||
|
403
|
||||||
|
"
|
||||||
|
@retry="saveConnection"
|
||||||
|
/>
|
||||||
|
<fieldset
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:disabled="connectionForm.processing"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">{{ dialogTitle }}</legend>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
class="justify-self-start"
|
||||||
|
>{{ t('gitConnections.gitea') }}</Badge
|
||||||
|
>
|
||||||
|
<InputError
|
||||||
|
:message="connectionForm.errors.provider"
|
||||||
|
/>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-base-url">{{
|
||||||
|
t('gitConnections.baseUrl')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-base-url"
|
||||||
|
v-model="connectionForm.base_url"
|
||||||
|
name="base_url"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
placeholder="https://git.example.com"
|
||||||
|
autocomplete="url"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.base_url
|
||||||
|
"
|
||||||
|
aria-describedby="git-base-url-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="git-base-url-error"
|
||||||
|
:message="connectionForm.errors.base_url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="git-token">{{
|
||||||
|
t(
|
||||||
|
mode === 'edit'
|
||||||
|
? 'gitConnections.newToken'
|
||||||
|
: 'gitConnections.token',
|
||||||
|
)
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="git-token"
|
||||||
|
v-model="connectionForm.api_token"
|
||||||
|
name="api_token"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="
|
||||||
|
t('gitConnections.tokenPlaceholder')
|
||||||
|
"
|
||||||
|
:aria-invalid="
|
||||||
|
!!connectionForm.errors.api_token
|
||||||
|
"
|
||||||
|
aria-describedby="git-token-help git-token-error"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
id="git-token-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('gitConnections.tokenHelp') }}
|
||||||
|
</p>
|
||||||
|
<InputError
|
||||||
|
id="git-token-error"
|
||||||
|
:message="connectionForm.errors.api_token"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">{{
|
||||||
|
t(
|
||||||
|
connectionForm.processing
|
||||||
|
? 'gitConnections.verifying'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'gitConnections.save'
|
||||||
|
: 'gitConnections.add',
|
||||||
|
)
|
||||||
|
}}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
v-else
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="removalForm.processing"
|
||||||
|
@submit.prevent="removeConnection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-destructive/8 border-destructive/25 text-body grid min-w-0 gap-2 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="break-all font-semibold">
|
||||||
|
{{ selectedConnection?.account_login }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground break-all">
|
||||||
|
{{ selectedConnection?.base_url }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FormFeedback
|
||||||
|
:state="removalProtection.state.value"
|
||||||
|
:title="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbidden')
|
||||||
|
: removalForm.processing
|
||||||
|
? t('gitConnections.removing')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:description="
|
||||||
|
removalProtection.responseStatus.value === 403
|
||||||
|
? t('gitConnections.forbiddenDescription')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:retryable="
|
||||||
|
removalProtection.responseStatus.value !== 403
|
||||||
|
"
|
||||||
|
@retry="removeConnection"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="removalForm.processing"
|
||||||
|
@click="closeDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
:disabled="processing"
|
:disabled="removalForm.processing"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
removalForm.processing
|
||||||
|
? 'gitConnections.removing'
|
||||||
|
: 'gitConnections.removeTitle',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
Remove connection
|
</DialogFooter>
|
||||||
</Button>
|
</form>
|
||||||
</Form>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,209 +1,385 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { Trash2 } from 'lucide-vue-next';
|
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import DeleteUser from '@/components/DeleteUser.vue';
|
import DeleteUser from '@/components/DeleteUser.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
defineProps<SettingsProfilePageProps>();
|
defineProps<SettingsProfilePageProps>();
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
|
||||||
title: 'Profile settings',
|
|
||||||
href: edit(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user);
|
||||||
const avatarPreview = ref<string | null>(null);
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{
|
||||||
|
title: t('navigation.profile'),
|
||||||
|
href: edit(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const profileForm = useForm('SettingsProfile', {
|
||||||
|
name: user.value.name,
|
||||||
|
email: user.value.email,
|
||||||
|
avatar: null as File | null,
|
||||||
|
});
|
||||||
|
const profileProtection = useFormProtection(profileForm);
|
||||||
const avatarDeleteForm = useForm({});
|
const avatarDeleteForm = useForm({});
|
||||||
|
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
|
||||||
|
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
|
||||||
|
const avatarPreview = ref<string | null>(null);
|
||||||
const displayedAvatar = computed(
|
const displayedAvatar = computed(
|
||||||
() => avatarPreview.value ?? user.value.avatar,
|
() => avatarPreview.value ?? user.value.avatar,
|
||||||
);
|
);
|
||||||
|
const displayedUser = computed<UserModel>(() => ({
|
||||||
|
...user.value,
|
||||||
|
avatar: displayedAvatar.value,
|
||||||
|
}));
|
||||||
const canRemoveAvatar = computed(
|
const canRemoveAvatar = computed(
|
||||||
() => user.value.avatar !== null && !user.value.avatar_is_generated,
|
() =>
|
||||||
|
avatarPreview.value === null &&
|
||||||
|
user.value.avatar !== null &&
|
||||||
|
!user.value.avatar_is_generated,
|
||||||
);
|
);
|
||||||
|
const profileUpdateForm = ProfileController.update.form();
|
||||||
|
const profileUpdateTarget = {
|
||||||
|
url: profileUpdateForm.action,
|
||||||
|
method: profileUpdateForm.method,
|
||||||
|
};
|
||||||
|
|
||||||
const clearAvatarPreview = (): void => {
|
function clearAvatarPreview(): void {
|
||||||
if (avatarPreview.value) {
|
if (avatarPreview.value) {
|
||||||
URL.revokeObjectURL(avatarPreview.value);
|
URL.revokeObjectURL(avatarPreview.value);
|
||||||
avatarPreview.value = null;
|
avatarPreview.value = null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAvatarChange = (event: Event) => {
|
function clearAvatarSelection(): void {
|
||||||
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = null;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
if (avatarInput.value) {
|
||||||
|
avatarInput.value.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0] ?? null;
|
||||||
|
|
||||||
clearAvatarPreview();
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = file;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
avatarPreview.value = URL.createObjectURL(file);
|
avatarPreview.value = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeAvatar = (): void => {
|
function submitProfile(): void {
|
||||||
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
|
profileProtection.submit(profileUpdateTarget, {
|
||||||
|
forceFormData: true,
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: clearAvatarPreview,
|
onSuccess: () => {
|
||||||
|
profileForm.defaults({
|
||||||
|
name: profileForm.name,
|
||||||
|
email: profileForm.email,
|
||||||
|
avatar: null,
|
||||||
});
|
});
|
||||||
};
|
clearAvatarSelection();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
function removeAvatar(): void {
|
||||||
clearAvatarPreview();
|
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
|
||||||
});
|
preserveScroll: true,
|
||||||
|
onSuccess: clearAvatarSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearAvatarPreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Profile settings" />
|
<Head :title="t('profile.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Profile settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('profile.title') }}</h2>
|
||||||
title="Profile information"
|
<p class="text-muted-foreground">
|
||||||
description="Update your name, email address, and profile picture"
|
{{ t('profile.description') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid min-w-0"
|
||||||
|
:aria-busy="profileForm.processing"
|
||||||
|
@submit.prevent="submitProfile"
|
||||||
|
>
|
||||||
|
<FormFeedback
|
||||||
|
:state="profileProtection.state.value"
|
||||||
|
:title="
|
||||||
|
profileForm.processing
|
||||||
|
? t('profile.saving')
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="submitProfile"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<fieldset
|
||||||
v-bind="ProfileController.update.form()"
|
class="gap-column grid min-w-0"
|
||||||
class="space-y-6"
|
:disabled="profileForm.processing"
|
||||||
v-slot="{
|
|
||||||
errors,
|
|
||||||
processing,
|
|
||||||
progress,
|
|
||||||
recentlySuccessful,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div class="grid gap-3">
|
<legend class="sr-only">
|
||||||
<Label for="avatar">Profile picture</Label>
|
{{ t('profile.information') }}
|
||||||
<div class="flex items-center gap-4">
|
</legend>
|
||||||
<UserAvatar class="size-16" />
|
|
||||||
<div class="grid flex-1 gap-2">
|
<section
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
aria-labelledby="profile-picture-title"
|
||||||
<Input
|
class="gap-column grid min-w-0 border-b pb-6"
|
||||||
id="avatar"
|
>
|
||||||
name="avatar"
|
<div class="gap-stack grid">
|
||||||
type="file"
|
<h3
|
||||||
accept="image/png,image/jpeg,image/webp"
|
id="profile-picture-title"
|
||||||
@change="handleAvatarChange"
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('profile.picture') }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureDescription') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="displayedUser"
|
||||||
|
size="xl"
|
||||||
|
class="ring-border shrink-0 ring-1"
|
||||||
|
:label="t('profile.picturePreview')"
|
||||||
/>
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<label
|
||||||
|
for="avatar"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
avatarPreview
|
||||||
|
? t(
|
||||||
|
'profile.changePicture',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'profile.choosePicture',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="canRemoveAvatar"
|
v-if="avatarPreview"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
:disabled="avatarDeleteForm.processing"
|
@click="clearAvatarSelection"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
t('profile.discardPicture')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="canRemoveAvatar"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
"
|
||||||
data-test="delete-profile-picture-button"
|
data-test="delete-profile-picture-button"
|
||||||
@click="removeAvatar"
|
@click="removeAvatar"
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4" />
|
<Trash2 aria-hidden="true" />
|
||||||
Remove
|
{{ t('profile.removePicture') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">
|
<input
|
||||||
PNG, JPG, or WebP up to 2 MB.
|
id="avatar"
|
||||||
|
ref="avatarInput"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
class="sr-only"
|
||||||
|
aria-describedby="avatar-help avatar-error"
|
||||||
|
:aria-invalid="
|
||||||
|
!!profileForm.errors.avatar
|
||||||
|
"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="profileForm.avatar"
|
||||||
|
class="text-caption min-w-0 break-all font-medium"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('profile.selectedPicture', {
|
||||||
|
name: profileForm.avatar
|
||||||
|
.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="avatar-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureHelp') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<progress
|
<progress
|
||||||
v-if="progress"
|
v-if="profileForm.progress"
|
||||||
class="h-1 w-full overflow-hidden rounded-full"
|
class="h-1 w-full overflow-hidden rounded-full"
|
||||||
:value="progress.percentage"
|
:value="
|
||||||
|
profileForm.progress.percentage
|
||||||
|
"
|
||||||
max="100"
|
max="100"
|
||||||
>
|
>
|
||||||
{{ progress.percentage }}%
|
{{
|
||||||
|
profileForm.progress.percentage
|
||||||
|
}}%
|
||||||
</progress>
|
</progress>
|
||||||
<InputError class="mt-1" :message="errors.avatar" />
|
<InputError
|
||||||
|
id="avatar-error"
|
||||||
|
:message="profileForm.errors.avatar"
|
||||||
|
/>
|
||||||
|
<FormFeedback
|
||||||
|
:state="
|
||||||
|
avatarDeleteProtection.state
|
||||||
|
.value
|
||||||
|
"
|
||||||
|
:title="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
? t(
|
||||||
|
'profile.removingPicture',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="removeAvatar"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="name">Name</Label>
|
<Label for="name">{{
|
||||||
|
t('profile.name')
|
||||||
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
class="mt-1 block w-full"
|
v-model="profileForm.name"
|
||||||
name="name"
|
name="name"
|
||||||
:default-value="user.name"
|
|
||||||
required
|
required
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
placeholder="Full name"
|
:placeholder="t('profile.namePlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="profileForm.errors.name"
|
||||||
/>
|
/>
|
||||||
<InputError class="mt-2" :message="errors.name" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="email">Email address</Label>
|
<Label for="email">{{
|
||||||
|
t('profile.email')
|
||||||
|
}}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
|
v-model="profileForm.email"
|
||||||
type="email"
|
type="email"
|
||||||
class="mt-1 block w-full"
|
|
||||||
name="email"
|
name="email"
|
||||||
:default-value="user.email"
|
|
||||||
required
|
required
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
placeholder="Email address"
|
:placeholder="t('profile.emailPlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.email"
|
||||||
|
aria-describedby="email-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="email-error"
|
||||||
|
:message="profileForm.errors.email"
|
||||||
/>
|
/>
|
||||||
<InputError class="mt-2" :message="errors.email" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
<div
|
||||||
<p class="text-muted-foreground -mt-4 text-sm">
|
v-if="
|
||||||
Your email address is unverified.
|
mustVerifyEmail && !user.email_verified_at
|
||||||
|
"
|
||||||
|
class="bg-muted/55 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('profile.emailUnverified') }}
|
||||||
<Link
|
<Link
|
||||||
:href="send()"
|
:href="send()"
|
||||||
as="button"
|
as="button"
|
||||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
class="text-primary font-medium underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Click here to resend the verification email.
|
{{ t('profile.resendVerification') }}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status === 'verification-link-sent'"
|
|
||||||
class="mt-2 text-sm font-medium text-green-600"
|
|
||||||
>
|
|
||||||
A new verification link has been sent to your email
|
|
||||||
address.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="update-profile-button"
|
|
||||||
>Save</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition ease-in-out"
|
|
||||||
enter-from-class="opacity-0"
|
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
|
||||||
<p
|
<p
|
||||||
v-show="recentlySuccessful"
|
v-if="status === 'verification-link-sent'"
|
||||||
class="text-sm text-neutral-600"
|
role="status"
|
||||||
|
class="text-success-foreground mt-2 text-sm font-medium"
|
||||||
>
|
>
|
||||||
Saved.
|
{{ t('profile.verificationSent') }}
|
||||||
</p>
|
</p>
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:disabled="profileForm.processing"
|
||||||
|
data-test="update-profile-button"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
profileForm.processing
|
||||||
|
? t('profile.saving')
|
||||||
|
: t('profile.save')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<p
|
||||||
|
v-if="profileForm.isDirty"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.unsaved') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<DeleteUser />
|
<DeleteUser />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|||||||
@@ -1,222 +1,340 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
import { ShieldCheck } from 'lucide-vue-next';
|
import { ShieldCheck } from 'lucide-vue-next';
|
||||||
import { onUnmounted, ref } from 'vue';
|
import { computed, onUnmounted, ref } from 'vue';
|
||||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
import { update } from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
import TwoFactorRecoveryCodes from '@/components/TwoFactorRecoveryCodes.vue';
|
||||||
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
import TwoFactorSetupModal from '@/components/TwoFactorSetupModal.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/security/index';
|
import { edit } from '@/routes/security/index';
|
||||||
import { disable, enable } from '@/routes/two-factor';
|
import { disable, enable } from '@/routes/two-factor';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
canManageTwoFactor?: boolean;
|
canManageTwoFactor?: boolean;
|
||||||
mustChangePassword?: boolean;
|
|
||||||
requiresConfirmation?: boolean;
|
requiresConfirmation?: boolean;
|
||||||
status?: string | null;
|
|
||||||
twoFactorEnabled?: boolean;
|
twoFactorEnabled?: boolean;
|
||||||
};
|
}>(),
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
canManageTwoFactor: false,
|
|
||||||
mustChangePassword: false,
|
|
||||||
requiresConfirmation: false,
|
|
||||||
status: null,
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Security settings',
|
canManageTwoFactor: false,
|
||||||
href: edit(),
|
requiresConfirmation: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{ title: t('navigation.security'), href: edit() },
|
||||||
|
]);
|
||||||
|
const passwordForm = useForm({
|
||||||
|
current_password: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
}).dontRemember('current_password', 'password', 'password_confirmation');
|
||||||
|
const passwordProtection = useFormProtection(passwordForm, {
|
||||||
|
isDirty: () => false,
|
||||||
|
});
|
||||||
|
const twoFactorForm = useForm({});
|
||||||
|
const twoFactorProtection = useFormProtection(twoFactorForm);
|
||||||
|
const showSetupModal = ref(false);
|
||||||
|
const showDisableDialog = ref(false);
|
||||||
|
const setupStarted = ref(false);
|
||||||
|
|
||||||
const { hasSetupData, clearTwoFactorAuthData } = useTwoFactorAuth();
|
function savePassword(): void {
|
||||||
const showSetupModal = ref<boolean>(false);
|
passwordProtection.submit(update(), {
|
||||||
|
preserveScroll: true,
|
||||||
onUnmounted(() => clearTwoFactorAuthData());
|
onFinish: () => passwordForm.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startSetup(): void {
|
||||||
|
if (setupStarted.value) {
|
||||||
|
showSetupModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
twoFactorProtection.submit(enable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
setupStarted.value = true;
|
||||||
|
showSetupModal.value = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function disableTwoFactor(): void {
|
||||||
|
twoFactorProtection.submit(disable(), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
showDisableDialog.value = false;
|
||||||
|
setupStarted.value = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function updateDisableDialog(open: boolean): void {
|
||||||
|
if (!twoFactorForm.processing) {
|
||||||
|
showDisableDialog.value = open;
|
||||||
|
twoFactorProtection.clearFeedback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onUnmounted(() => passwordForm.reset());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<Head title="Security settings" />
|
<Head :title="t('security.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Security settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
v-if="mustChangePassword || status"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
|
<h2 class="text-title">
|
||||||
|
{{ t('security.passwordTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{ t('security.passwordDescription') }}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-5 sm:p-6">
|
||||||
|
<form
|
||||||
|
class="gap-column grid"
|
||||||
|
:aria-busy="passwordForm.processing"
|
||||||
|
@submit.prevent="savePassword"
|
||||||
>
|
>
|
||||||
{{
|
<AuthFormFeedback
|
||||||
mustChangePassword
|
:state="passwordProtection.state.value"
|
||||||
? 'You must change your password before continuing anywhere else in the app.'
|
:response-status="
|
||||||
: status
|
passwordProtection.responseStatus.value
|
||||||
}}
|
"
|
||||||
</div>
|
:loading-title="t('security.savingPassword')"
|
||||||
|
:success-title="t('security.passwordSaved')"
|
||||||
<div class="space-y-6">
|
@retry="savePassword"
|
||||||
<Heading
|
|
||||||
variant="small"
|
|
||||||
title="Update password"
|
|
||||||
description="Ensure your account is using a long, random password to stay secure"
|
|
||||||
/>
|
/>
|
||||||
|
<fieldset
|
||||||
<Form
|
class="gap-column grid min-w-0"
|
||||||
v-bind="SecurityController.update.form()"
|
:disabled="passwordForm.processing"
|
||||||
:options="{
|
|
||||||
preserveScroll: true,
|
|
||||||
}"
|
|
||||||
reset-on-success
|
|
||||||
:reset-on-error="[
|
|
||||||
'password',
|
|
||||||
'password_confirmation',
|
|
||||||
'current_password',
|
|
||||||
]"
|
|
||||||
class="space-y-6"
|
|
||||||
v-slot="{ errors, processing, recentlySuccessful }"
|
|
||||||
>
|
>
|
||||||
|
<legend class="sr-only">
|
||||||
|
{{ t('security.passwordTitle') }}
|
||||||
|
</legend>
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="current_password">Current password</Label>
|
<Label for="current_password">{{
|
||||||
|
t('security.currentPassword')
|
||||||
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="current_password"
|
id="current_password"
|
||||||
|
v-model="passwordForm.current_password"
|
||||||
name="current_password"
|
name="current_password"
|
||||||
class="mt-1 block w-full"
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
placeholder="Current password"
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors.current_password
|
||||||
|
"
|
||||||
|
aria-describedby="current-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="current-password-error"
|
||||||
|
:message="
|
||||||
|
passwordForm.errors.current_password
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.current_password" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="password">New password</Label>
|
<Label for="password">{{
|
||||||
|
t('security.newPassword')
|
||||||
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
|
v-model="passwordForm.password"
|
||||||
name="password"
|
name="password"
|
||||||
class="mt-1 block w-full"
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="New password"
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors.password
|
||||||
|
"
|
||||||
|
aria-describedby="new-password-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="new-password-error"
|
||||||
|
:message="passwordForm.errors.password"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.password" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="password_confirmation"
|
<Label for="password_confirmation">{{
|
||||||
>Confirm password</Label
|
t('security.confirmPassword')
|
||||||
>
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
class="mt-1 block w-full"
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="Confirm password"
|
:disabled="passwordForm.processing"
|
||||||
|
:aria-invalid="
|
||||||
|
!!passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
|
aria-describedby="password-confirmation-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="password-confirmation-error"
|
||||||
|
:message="
|
||||||
|
passwordForm.errors
|
||||||
|
.password_confirmation
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.password_confirmation" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
:disabled="processing"
|
type="submit"
|
||||||
|
class="justify-self-start"
|
||||||
|
:loading="passwordForm.processing"
|
||||||
|
:loading-label="t('security.savingPassword')"
|
||||||
data-test="update-password-button"
|
data-test="update-password-button"
|
||||||
|
>{{ t('security.savePassword') }}</Button
|
||||||
>
|
>
|
||||||
Save password
|
</fieldset>
|
||||||
</Button>
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Transition
|
<Card
|
||||||
enter-active-class="transition ease-in-out"
|
v-if="canManageTwoFactor"
|
||||||
enter-from-class="opacity-0"
|
class="gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
>
|
||||||
<p
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
v-show="recentlySuccessful"
|
<div
|
||||||
class="text-sm text-neutral-600"
|
class="flex flex-wrap items-center justify-between gap-3"
|
||||||
>
|
>
|
||||||
Saved.
|
<h2 class="text-title flex items-center gap-2">
|
||||||
|
<ShieldCheck class="text-primary size-5" />{{
|
||||||
|
t('security.twoFactorTitle')
|
||||||
|
}}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
class="bg-muted rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
role="status"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.enabled'
|
||||||
|
: setupStarted
|
||||||
|
? 'security.pending'
|
||||||
|
: 'security.disabled',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
props.twoFactorEnabled
|
||||||
|
? 'security.enabledDescription'
|
||||||
|
: 'security.twoFactorDescription',
|
||||||
|
)
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</Transition>
|
</CardHeader>
|
||||||
</div>
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
</Form>
|
<AuthFormFeedback
|
||||||
</div>
|
v-if="!showDisableDialog"
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
<div v-if="canManageTwoFactor" class="space-y-6">
|
:response-status="
|
||||||
<Heading
|
twoFactorProtection.responseStatus.value
|
||||||
variant="small"
|
"
|
||||||
title="Two-factor authentication"
|
@retry="
|
||||||
description="Manage your two-factor authentication settings"
|
props.twoFactorEnabled
|
||||||
|
? (showDisableDialog = true)
|
||||||
|
: startSetup()
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!twoFactorEnabled"
|
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
|
||||||
>
|
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
When you enable two-factor authentication, you will be
|
|
||||||
prompted for a secure pin during login. This pin can be
|
|
||||||
retrieved from a TOTP-supported application on your
|
|
||||||
phone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="hasSetupData"
|
v-if="!twoFactorEnabled"
|
||||||
@click="showSetupModal = true"
|
class="justify-self-start"
|
||||||
|
:loading="twoFactorForm.processing"
|
||||||
|
@click="startSetup"
|
||||||
|
>{{
|
||||||
|
t(
|
||||||
|
setupStarted
|
||||||
|
? 'security.continueSetup'
|
||||||
|
: 'security.enable',
|
||||||
|
)
|
||||||
|
}}</Button
|
||||||
>
|
>
|
||||||
<ShieldCheck />Continue setup
|
<template v-else>
|
||||||
</Button>
|
<Button
|
||||||
<Form
|
variant="outline"
|
||||||
v-else
|
class="text-destructive justify-self-start"
|
||||||
v-bind="enable.form()"
|
@click="showDisableDialog = true"
|
||||||
@success="showSetupModal = true"
|
>{{ t('security.disable') }}</Button
|
||||||
#default="{ processing }"
|
|
||||||
>
|
>
|
||||||
<Button type="submit" :disabled="processing">
|
<TwoFactorRecoveryCodes />
|
||||||
Enable 2FA
|
</template>
|
||||||
</Button>
|
</CardContent>
|
||||||
</Form>
|
</Card>
|
||||||
</div>
|
<TwoFactorSetupModal
|
||||||
</div>
|
v-if="canManageTwoFactor"
|
||||||
|
v-model:is-open="showSetupModal"
|
||||||
<div
|
:requires-confirmation="requiresConfirmation"
|
||||||
v-else
|
:two-factor-enabled="twoFactorEnabled"
|
||||||
class="flex flex-col items-start justify-start space-y-4"
|
/>
|
||||||
|
<Dialog
|
||||||
|
:open="showDisableDialog"
|
||||||
|
@update:open="updateDisableDialog"
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
:show-close-button="!twoFactorForm.processing"
|
||||||
|
@escape-key-down="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
@interact-outside="
|
||||||
|
twoFactorForm.processing && $event.preventDefault()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{
|
||||||
|
t('security.disableTitle')
|
||||||
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>{{
|
||||||
|
t('security.disableDescription')
|
||||||
|
}}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<AuthFormFeedback
|
||||||
|
:state="twoFactorProtection.state.value"
|
||||||
|
:response-status="
|
||||||
|
twoFactorProtection.responseStatus.value
|
||||||
|
"
|
||||||
|
@retry="disableTwoFactor"
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:disabled="twoFactorForm.processing"
|
||||||
|
@click="updateDisableDialog(false)"
|
||||||
|
>{{ t('common.cancel') }}</Button
|
||||||
>
|
>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
You will be prompted for a secure, random pin during
|
|
||||||
login, which you can retrieve from the TOTP-supported
|
|
||||||
application on your phone.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="relative inline">
|
|
||||||
<Form v-bind="disable.form()" #default="{ processing }">
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
type="submit"
|
:loading="twoFactorForm.processing"
|
||||||
:disabled="processing"
|
@click="disableTwoFactor"
|
||||||
|
>{{ t('security.disable') }}</Button
|
||||||
>
|
>
|
||||||
Disable 2FA
|
</DialogFooter>
|
||||||
</Button>
|
</DialogContent>
|
||||||
</Form>
|
</Dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<TwoFactorRecoveryCodes />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TwoFactorSetupModal
|
|
||||||
v-model:isOpen="showSetupModal"
|
|
||||||
:requiresConfirmation="requiresConfirmation"
|
|
||||||
:twoFactorEnabled="twoFactorEnabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type TicketFieldDefinitionModel = {
|
|||||||
label: string;
|
label: string;
|
||||||
position: number;
|
position: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
|
ticket_field_values_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type TicketLinkTypeModel = {
|
|||||||
counterpart_label: string;
|
counterpart_label: string;
|
||||||
position: number;
|
position: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
|
ticket_links_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
|
||||||
|
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||||
|
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
|
||||||
|
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
|
||||||
|
|
||||||
|
export type MetadataKind = 'type' | 'priority';
|
||||||
|
export type MetadataEntry = TicketPriorityModel &
|
||||||
|
Partial<Pick<TicketTypeModel, 'is_parent' | 'tickets_count'>>;
|
||||||
|
export type MetadataEditorTarget = { kind: MetadataKind } & (
|
||||||
|
| { action: 'create'; entry?: never }
|
||||||
|
| { action: 'edit' | 'delete'; entry: MetadataEntry }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TicketFieldDefinitionEditorTarget =
|
||||||
|
| { action: 'create'; entry?: never }
|
||||||
|
| {
|
||||||
|
action: 'edit' | 'delete';
|
||||||
|
entry: TicketFieldDefinitionModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketLinkTypeEditorTarget =
|
||||||
|
| { action: 'create'; entry?: never }
|
||||||
|
| { action: 'edit' | 'delete'; entry: TicketLinkTypeModel };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NotificationActionModel } from '@/types/models/notification-action-model';
|
||||||
|
|
||||||
|
export type NotificationEditorTarget =
|
||||||
|
| { action: 'create'; entry?: never }
|
||||||
|
| { action: 'edit' | 'delete'; entry: NotificationActionModel };
|
||||||
|
|
||||||
|
export type NotificationFilterFormState = {
|
||||||
|
id: number;
|
||||||
|
attribute: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
export type WorkflowEditorTarget =
|
||||||
|
| { kind: 'column'; action: 'create'; column?: never }
|
||||||
|
| { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel }
|
||||||
|
| {
|
||||||
|
kind: 'status';
|
||||||
|
action: 'create';
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
status?: never;
|
||||||
|
replacement?: never;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'status';
|
||||||
|
action: 'edit' | 'delete';
|
||||||
|
column: BoardColumnModel | null;
|
||||||
|
status: TicketStatusModel;
|
||||||
|
replacement?: TicketStatusModel;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProjectTicketPageProps } from '@/types/page-props';
|
import type { ProjectTicketPageProps } from '@/types/page-props';
|
||||||
|
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||||
export type TicketDetailHeaderProps = Pick<
|
export type TicketDetailHeaderProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
'ticket' | 'project' | 'canEditTicket'
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
@@ -15,6 +16,10 @@ export type TicketDetailContentProps = Pick<
|
|||||||
| 'ticketTypes'
|
| 'ticketTypes'
|
||||||
| 'users'
|
| 'users'
|
||||||
>;
|
>;
|
||||||
|
export type TicketDetailBlockerProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'canEditTicket'
|
||||||
|
>;
|
||||||
export type TicketDetailMetadataProps = Pick<
|
export type TicketDetailMetadataProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
'ticket' | 'project' | 'canManageProject' | 'ticketFieldDefinitions'
|
||||||
@@ -28,6 +33,24 @@ export type TicketDetailGitProps = Pick<
|
|||||||
| 'repositoryPullRequests'
|
| 'repositoryPullRequests'
|
||||||
| 'suggestedBranchName'
|
| 'suggestedBranchName'
|
||||||
>;
|
>;
|
||||||
|
export type TicketGitBranchesProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
| 'ticket'
|
||||||
|
| 'project'
|
||||||
|
| 'canEditTicket'
|
||||||
|
| 'repositoryBranches'
|
||||||
|
| 'suggestedBranchName'
|
||||||
|
>;
|
||||||
|
export type TicketGitPullRequestsProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'repositoryPullRequests'
|
||||||
|
>;
|
||||||
|
export type TicketBranchCreateDialogProps = Pick<
|
||||||
|
ProjectTicketPageProps,
|
||||||
|
'ticket' | 'project' | 'suggestedBranchName'
|
||||||
|
> & {
|
||||||
|
repositoryConnections: RepositoryConnectionModel[];
|
||||||
|
};
|
||||||
export type TicketDetailRelationshipsProps = Pick<
|
export type TicketDetailRelationshipsProps = Pick<
|
||||||
ProjectTicketPageProps,
|
ProjectTicketPageProps,
|
||||||
| 'ticket'
|
| 'ticket'
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AcceptanceCriteriaChecklistTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_checklist_preserves_hierarchy_identity_and_accessible_controls(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/acceptance-criteria.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Acceptance criteria behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Admin;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UserManagementUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_user_management_pages_render_responsive_actions_and_protected_forms(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/user-management.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$process->isSuccessful(),
|
||||||
|
$process->getOutput().$process->getErrorOutput(),
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'User management rendering checks passed.',
|
||||||
|
$process->getOutput(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,6 +298,79 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure saved outgoing targets reach both consumers without changing the reverse direction.
|
||||||
|
*/
|
||||||
|
public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create(['key' => 'CORE']);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$sourceStatus = TicketStatus::factory()->for($project)->for($column)->create([
|
||||||
|
'name' => 'Progress',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$reviewStatus = TicketStatus::factory()->for($project)->create([
|
||||||
|
'name' => 'Review',
|
||||||
|
'position' => 2,
|
||||||
|
]);
|
||||||
|
$doneStatus = TicketStatus::factory()->for($project)->create([
|
||||||
|
'name' => 'Done',
|
||||||
|
'position' => 3,
|
||||||
|
]);
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]);
|
||||||
|
$sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]);
|
||||||
|
$reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||||
|
'allowed_ticket_status_ids' => [$reviewStatus->id],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
|
||||||
|
$this->withApplicationKey($user)
|
||||||
|
->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.current_status.id', $sourceStatus->id)
|
||||||
|
->assertJsonCount(1, 'data.target_statuses')
|
||||||
|
->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id);
|
||||||
|
|
||||||
|
$this->get(route('projects.board.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Board')
|
||||||
|
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||||
|
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1)
|
||||||
|
->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [
|
||||||
|
'allowed_ticket_status_ids' => [],
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get());
|
||||||
|
$this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys());
|
||||||
|
|
||||||
|
$this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket]))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.target_statuses', []);
|
||||||
|
|
||||||
|
$this->get(route('projects.board.index', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/Board')
|
||||||
|
->where('boardColumns.0.tickets.0.id', $ticket->id)
|
||||||
|
->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
* Ensure transition updates only accept targets from the same project and never the source status itself.
|
||||||
*/
|
*/
|
||||||
@@ -372,4 +445,51 @@ class ProjectBoardSettingsControllerTest extends TestCase
|
|||||||
'id' => $backlogStatus->id,
|
'id' => $backlogStatus->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$backlog = TicketStatus::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$statuses = TicketStatus::factory()->for($project)->for($column)->count(2)
|
||||||
|
->sequence(['position' => 1], ['position' => 2])->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]);
|
||||||
|
$statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.settings.board.columns.destroy', [$project, $column]))
|
||||||
|
->assertRedirect(route('projects.settings.board', $project))
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertModelMissing($column);
|
||||||
|
$this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
$this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all());
|
||||||
|
$this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all());
|
||||||
|
$this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null));
|
||||||
|
$this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
$column = BoardColumn::factory()->for($project)->create(['position' => 1]);
|
||||||
|
$status = TicketStatus::factory()->for($project)->for($column)->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||||
|
->delete(route('projects.settings.board.statuses.destroy', [$project, $status]))
|
||||||
|
->assertSessionHasErrors('ticket_status_id');
|
||||||
|
$this->assertModelExists($status);
|
||||||
|
$this->assertSame($status->id, $ticket->fresh()->ticket_status_id);
|
||||||
|
|
||||||
|
$this->actingAs($user)->from(route('projects.settings.board', $project))
|
||||||
|
->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0])
|
||||||
|
->assertSessionHasErrors('position');
|
||||||
|
$this->assertSame(1, $column->fresh()->position);
|
||||||
|
$this->assertSame($column->name, $column->fresh()->name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectBoardSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_workflow_renders_ordered_groups_deletion_protection_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-board-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectGeneralSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_general_settings_render_setting_sources_confirmations_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-general-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project general settings rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -430,4 +430,66 @@ class ProjectNotificationSettingsControllerTest extends TestCase
|
|||||||
->missing('notificationActions.0.delivery_url'),
|
->missing('notificationActions.0.delivery_url'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_editor_accepts_array_filters_and_can_clear_discord_mentions(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$notificationAction = NotificationAction::factory()->discord()->for($project)->create([
|
||||||
|
'channel_configuration' => ['allowed_mentions' => ['users']],
|
||||||
|
'delivery_url' => 'https://discord.com/api/webhooks/123456/original-secret',
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
|
||||||
|
'name' => 'Paused alerts',
|
||||||
|
'event_type' => NotificationEventType::TicketAssigneeChanged->value,
|
||||||
|
'channel' => NotificationChannel::Discord->value,
|
||||||
|
'enabled' => false,
|
||||||
|
'delivery_url' => null,
|
||||||
|
'message_template' => '$ticket_key: $url',
|
||||||
|
'channel_configuration' => ['allowed_mentions' => []],
|
||||||
|
'filters' => [
|
||||||
|
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
|
||||||
|
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
|
||||||
|
],
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('projects.settings.notifications', $project));
|
||||||
|
|
||||||
|
$notificationAction->refresh();
|
||||||
|
$this->assertFalse($notificationAction->enabled);
|
||||||
|
$this->assertSame(['allowed_mentions' => []], $notificationAction->channel_configuration);
|
||||||
|
$this->assertSame('https://discord.com/api/webhooks/123456/original-secret', $notificationAction->delivery_url);
|
||||||
|
$this->assertSame([
|
||||||
|
['attribute' => 'ticket.key', 'operator' => 'in', 'value' => ['CORE-1', 'CORE-2']],
|
||||||
|
['attribute' => 'actor.name', 'operator' => 'exists', 'value' => null],
|
||||||
|
], $notificationAction->filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_editor_update_does_not_change_saved_configuration(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$notificationAction = NotificationAction::factory()->discord()->for($project)->create();
|
||||||
|
$original = $notificationAction->fresh()->getRawOriginal();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.notifications', $project))
|
||||||
|
->patch(route('projects.settings.notifications.update', [$project, $notificationAction]), [
|
||||||
|
'name' => 'Invalid update',
|
||||||
|
'event_type' => NotificationEventType::TicketCreate->value,
|
||||||
|
'channel' => NotificationChannel::Discord->value,
|
||||||
|
'enabled' => false,
|
||||||
|
'delivery_url' => 'invalid-url',
|
||||||
|
'message_template' => '',
|
||||||
|
'channel_configuration' => ['allowed_mentions' => ['unsupported']],
|
||||||
|
'filters' => [['attribute' => 'ticket.key', 'operator' => 'in', 'value' => []]],
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors(['delivery_url', 'message_template', 'channel_configuration.allowed_mentions.0', 'filters.0.value']);
|
||||||
|
|
||||||
|
$this->assertSame($original, $notificationAction->fresh()->getRawOriginal());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectNotificationSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_notification_settings_render_actions_and_serialize_filter_values(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-notification-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Notification settings checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,58 @@ class ProjectRepositorySettingsControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure repository discovery distinguishes empty provider results from provider failures.
|
||||||
|
*/
|
||||||
|
public function test_repository_settings_expose_personal_access_and_provider_states(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$emptyGitConnection = UserGitConnection::factory()->for($user)->create([
|
||||||
|
'account_login' => 'empty-account',
|
||||||
|
'base_url' => 'https://empty.git.test',
|
||||||
|
'provider' => GitProviderKey::Gitea,
|
||||||
|
]);
|
||||||
|
UserGitConnection::factory()->for($user)->create([
|
||||||
|
'account_login' => 'broken-account',
|
||||||
|
'base_url' => 'https://broken.git.test',
|
||||||
|
'provider' => GitProviderKey::Gitea,
|
||||||
|
]);
|
||||||
|
RepositoryConnection::factory()->for($project)->create([
|
||||||
|
'base_url' => $emptyGitConnection->base_url,
|
||||||
|
'provider' => GitProviderKey::Gitea,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake([
|
||||||
|
'https://empty.git.test/api/v1/user/repos*' => Http::response([]),
|
||||||
|
'https://broken.git.test/api/v1/user/repos*' => Http::response([], 502),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('projects.settings.repositories', $project))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/settings/Repositories')
|
||||||
|
->has('availableRepositorySources', 2)
|
||||||
|
->where('availableRepositorySources', function ($sources): bool {
|
||||||
|
$sourcesByAccount = collect($sources)->keyBy('account_login');
|
||||||
|
$emptySource = $sourcesByAccount->get('empty-account');
|
||||||
|
$brokenSource = $sourcesByAccount->get('broken-account');
|
||||||
|
|
||||||
|
return data_get($emptySource, 'provider') === GitProviderKey::Gitea->value
|
||||||
|
&& data_get($emptySource, 'provider_label') === GitProviderKey::Gitea->label()
|
||||||
|
&& data_get($emptySource, 'repositories') === []
|
||||||
|
&& data_get($emptySource, 'error') === null
|
||||||
|
&& data_get($brokenSource, 'repositories') === []
|
||||||
|
&& data_get($brokenSource, 'error') === config('app.name').' could not load repositories from this Git connection right now.';
|
||||||
|
})
|
||||||
|
->where('repositoryConnections.0.has_personal_access', true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure users without edit permission cannot manage repository settings.
|
* Ensure users without edit permission cannot manage repository settings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase
|
|||||||
->assertNotFound();
|
->assertNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$source = Project::factory()->create();
|
||||||
|
$template = ProjectTemplate::factory()->create([
|
||||||
|
'source_project_id' => $source->id,
|
||||||
|
]);
|
||||||
|
$this->syncGlobalPermissions($user, ['project:create']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('project-templates.update', $template), [
|
||||||
|
'name' => 'Updated reusable template',
|
||||||
|
'copy_areas' => ['project_settings'],
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors()
|
||||||
|
->assertRedirect(route('project-templates.index'));
|
||||||
|
|
||||||
|
$this->assertSame('Updated reusable template', $template->fresh()->name);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTemplatesUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_template_pages_show_reusable_configuration_and_protect_linked_projects(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-templates.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Project template checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,27 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
TicketLinkType::factory()->for($project)->create([
|
TicketLinkType::factory()->for($project)->create([
|
||||||
'label' => 'Relates to',
|
'label' => 'Relates to',
|
||||||
|
'counterpart_label' => 'Related from',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([
|
||||||
|
'field_type' => 'number',
|
||||||
|
'label' => 'Complexity',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$sourceTicket = Ticket::factory()->for($project)->create();
|
||||||
|
$targetTicket = Ticket::factory()->for($project)->create([
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'TEST-2',
|
||||||
|
]);
|
||||||
|
$fieldDefinition->ticketFieldValues()->create([
|
||||||
|
'ticket_id' => $sourceTicket->id,
|
||||||
|
'value_number' => 8,
|
||||||
|
]);
|
||||||
|
$project->ticketLinkTypes()->firstOrFail()->ticketLinks()->create([
|
||||||
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
|
'target_ticket_id' => $targetTicket->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$template = ProjectTemplate::factory()->create([
|
$template = ProjectTemplate::factory()->create([
|
||||||
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
|
'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [
|
||||||
@@ -71,7 +90,14 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
->where('ticketTypes.1.name', 'Release')
|
->where('ticketTypes.1.name', 'Release')
|
||||||
->where('ticketTypes.1.is_parent', true)
|
->where('ticketTypes.1.is_parent', true)
|
||||||
->where('ticketTypes.2.name', 'Bug')
|
->where('ticketTypes.2.name', 'Bug')
|
||||||
|
->where('fieldTypeOptions.0.value', 'user')
|
||||||
|
->where('fieldTypeOptions.1.value', 'number')
|
||||||
|
->where('ticketFieldDefinitions.0.label', 'Complexity')
|
||||||
|
->where('ticketFieldDefinitions.0.field_type', 'number')
|
||||||
|
->where('ticketFieldDefinitions.0.ticket_field_values_count', 1)
|
||||||
->where('ticketLinkTypes.0.label', 'Relates to')
|
->where('ticketLinkTypes.0.label', 'Relates to')
|
||||||
|
->where('ticketLinkTypes.0.counterpart_label', 'Related from')
|
||||||
|
->where('ticketLinkTypes.0.ticket_links_count', 1)
|
||||||
->where('templateState.templateName', 'Issue Tracker Template')
|
->where('templateState.templateName', 'Issue Tracker Template')
|
||||||
->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
|
->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS),
|
||||||
);
|
);
|
||||||
@@ -387,6 +413,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'field_type' => 'number',
|
'field_type' => 'number',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$ticket = Ticket::factory()->for($project)->create();
|
||||||
|
$fieldValue = $fieldDefinition->ticketFieldValues()->create([
|
||||||
|
'ticket_id' => $ticket->id,
|
||||||
|
'value_number' => 5,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
@@ -412,6 +443,11 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'label' => 'Risk score',
|
'label' => 'Risk score',
|
||||||
'field_type' => 'number',
|
'field_type' => 'number',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_field_values', [
|
||||||
|
'id' => $fieldValue->id,
|
||||||
|
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||||
|
'value_number' => 5,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -446,6 +482,15 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'counterpart_label' => 'Relates to',
|
'counterpart_label' => 'Relates to',
|
||||||
'position' => 1,
|
'position' => 1,
|
||||||
]);
|
]);
|
||||||
|
$sourceTicket = Ticket::factory()->for($project)->create();
|
||||||
|
$targetTicket = Ticket::factory()->for($project)->create([
|
||||||
|
'sequence_number' => 2,
|
||||||
|
'ticket_key' => 'TEST-2',
|
||||||
|
]);
|
||||||
|
$ticketLink = $ticketLinkType->ticketLinks()->create([
|
||||||
|
'source_ticket_id' => $sourceTicket->id,
|
||||||
|
'target_ticket_id' => $targetTicket->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->syncProjectPermissions($project, $user, ['edit']);
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
@@ -477,6 +522,10 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
'label' => 'Depends on',
|
'label' => 'Depends on',
|
||||||
'counterpart_label' => 'Required by',
|
'counterpart_label' => 'Required by',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_links', [
|
||||||
|
'id' => $ticketLink->id,
|
||||||
|
'ticket_link_type_id' => $ticketLinkType->id,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
|
->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType]))
|
||||||
@@ -486,5 +535,79 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
|
|||||||
$this->assertDatabaseMissing('ticket_link_types', [
|
$this->assertDatabaseMissing('ticket_link_types', [
|
||||||
'id' => $ticketLinkType->id,
|
'id' => $ticketLinkType->id,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseMissing('ticket_links', [
|
||||||
|
'id' => $ticketLink->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_last_priority_is_protected_from_deletion(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
$priority = TicketPriority::factory()->for($project)->create();
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority]))
|
||||||
|
->assertRedirect(route('projects.settings.metadata', $project))
|
||||||
|
->assertSessionHas('error', true)
|
||||||
|
->assertSessionHas('status', 'The last ticket priority cannot be deleted.');
|
||||||
|
|
||||||
|
$this->assertModelExists($priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_type_order_and_usage_counts_are_returned_after_reordering(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$types = $project->ticketTypes()->get();
|
||||||
|
$first = $types->first();
|
||||||
|
$last = $types->last();
|
||||||
|
Ticket::factory()->for($project)->create([
|
||||||
|
'ticket_type_id' => $last->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('projects.settings.metadata.types.update', [$project, $last]), [
|
||||||
|
'name' => 'Customer request', 'position' => 1,
|
||||||
|
])
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->get(route('projects.settings.metadata', $project))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->where('ticketTypes.0.id', $last->id)
|
||||||
|
->where('ticketTypes.0.name', 'Customer request')
|
||||||
|
->where('ticketTypes.0.position', 1)
|
||||||
|
->where('ticketTypes.0.tickets_count', 1)
|
||||||
|
->where('ticketTypes.1.id', $first->id)
|
||||||
|
->where('ticketTypes.1.position', 2)
|
||||||
|
->where('ticketTypes.1.tickets_count', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$project = Project::factory()->create();
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
$type = $project->ticketTypes()->firstOrFail();
|
||||||
|
$foreignType = TicketType::factory()->create();
|
||||||
|
$ticket = Ticket::factory()->for($project)->create([
|
||||||
|
'ticket_type_id' => $type->id,
|
||||||
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
|
]);
|
||||||
|
$this->syncProjectPermissions($project, $user, ['edit']);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('projects.settings.metadata', $project))
|
||||||
|
->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [
|
||||||
|
'replacement_ticket_type_id' => $foreignType->id,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('replacement_ticket_type_id');
|
||||||
|
|
||||||
|
$this->assertModelExists($type);
|
||||||
|
$this->assertSame($type->id, $ticket->fresh()->ticket_type_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProjectTicketMetadataSettingsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_metadata_settings_show_configured_values_and_deletion_protection(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/project-metadata-settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Metadata settings checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,11 +25,15 @@ class GitConnectionTest extends TestCase
|
|||||||
'base_url' => 'https://git.example.test',
|
'base_url' => 'https://git.example.test',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('git-connections.edit'))
|
->get(route('git-connections.edit'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/GitConnections')
|
->component('settings/GitConnections')
|
||||||
|
->has('gitConnections', 1)
|
||||||
|
->missing('gitConnections.0.api_token')
|
||||||
->where('gitConnections.0.account_login', 'nico')
|
->where('gitConnections.0.account_login', 'nico')
|
||||||
->where('gitConnections.0.base_url', 'https://git.example.test')
|
->where('gitConnections.0.base_url', 'https://git.example.test')
|
||||||
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
->where('gitConnections.0.provider', GitProviderKey::Gitea->value),
|
||||||
@@ -93,7 +97,8 @@ class GitConnectionTest extends TestCase
|
|||||||
'api_token' => 'wrong-token',
|
'api_token' => 'wrong-token',
|
||||||
])
|
])
|
||||||
->assertRedirect(route('git-connections.edit'))
|
->assertRedirect(route('git-connections.edit'))
|
||||||
->assertSessionHasErrors('api_token');
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,4 +186,59 @@ class GitConnectionTest extends TestCase
|
|||||||
'id' => $gitConnection->id,
|
'id' => $gitConnection->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_empty_personal_connections_are_displayed(): void
|
||||||
|
{
|
||||||
|
UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get(route('git-connections.edit'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('settings/GitConnections')
|
||||||
|
->where('gitConnections', []),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_guests_cannot_access_git_connections(): void
|
||||||
|
{
|
||||||
|
$this->get(route('git-connections.edit'))->assertRedirect(route('login'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_users_cannot_delete_foreign_connections(): void
|
||||||
|
{
|
||||||
|
$connection = UserGitConnection::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->delete(route('git-connections.destroy', $connection))
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertModelExists($connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_failed_provider_update_preserves_connection_and_does_not_flash_token(): void
|
||||||
|
{
|
||||||
|
Http::preventStrayRequests();
|
||||||
|
Http::fake(['https://git.changed.test/api/v1/user' => Http::response([], 503)]);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$connection = UserGitConnection::factory()->for($user)->create([
|
||||||
|
'base_url' => 'https://git.original.test',
|
||||||
|
'api_token' => 'original-secret',
|
||||||
|
]);
|
||||||
|
$original = $connection->fresh()->getAttributes();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->from(route('git-connections.edit'))
|
||||||
|
->patch(route('git-connections.update', $connection), [
|
||||||
|
'provider' => 'gitea',
|
||||||
|
'base_url' => 'https://git.changed.test',
|
||||||
|
'api_token' => 'replacement-secret',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('git-connections.edit'))
|
||||||
|
->assertSessionHasErrors('api_token')
|
||||||
|
->assertSessionMissing('_old_input.api_token');
|
||||||
|
|
||||||
|
$this->assertSame($original, $connection->fresh()->getAttributes());
|
||||||
|
$this->assertSame('original-secret', $connection->fresh()->api_token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GitConnectionsUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_personal_git_connections_rendering_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/git-connections.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Git connections rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
class ProfileUpdateTest extends TestCase
|
||||||
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$response->assertSessionHasErrors('avatar');
|
$response->assertSessionHasErrors('avatar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
public function test_user_can_delete_their_account()
|
||||||
{
|
{
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -249,4 +250,55 @@ class SecurityTest extends TestCase
|
|||||||
|
|
||||||
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
$this->assertTrue(Hash::check('password', $user->fresh()->password));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_two_factor_setup_requires_a_valid_code_before_protection_is_enabled(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()])
|
||||||
|
->from(route('security.edit'))
|
||||||
|
->post(route('two-factor.enable'))->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertNotNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->getJson(route('two-factor.secret-key'))->assertOk()->assertJsonStructure(['secretKey']);
|
||||||
|
$this->getJson(route('two-factor.qr-code'))->assertOk()->assertJsonStructure(['svg']);
|
||||||
|
|
||||||
|
$this->mock(TwoFactorAuthenticationProvider::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '000000')->once()->andReturn(false);
|
||||||
|
$mock->shouldReceive('verify')->withArgs(fn ($secret, $code) => $code === '123456')->once()->andReturn(true);
|
||||||
|
});
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '000000'])
|
||||||
|
->assertSessionHasErrors('code', errorBag: 'confirmTwoFactorAuthentication');
|
||||||
|
$this->assertFalse($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
$this->post(route('two-factor.confirm'), ['code' => '123456'])->assertSessionHasNoErrors();
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_recovery_codes_can_be_retrieved_regenerated_and_removed_with_two_factor(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user)->withSession(['auth.password_confirmed_at' => time()]);
|
||||||
|
$oldCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertSame($user->recoveryCodes(), $oldCodes);
|
||||||
|
$this->post(route('two-factor.regenerate-recovery-codes'))->assertSessionHasNoErrors();
|
||||||
|
$newCodes = $this->getJson(route('two-factor.recovery-codes'))->assertOk()->json();
|
||||||
|
$this->assertCount(8, $newCodes);
|
||||||
|
$this->assertEmpty(array_intersect($oldCodes, $newCodes));
|
||||||
|
$this->delete(route('two-factor.disable'))->assertSessionHasNoErrors();
|
||||||
|
$this->assertNull($user->refresh()->two_factor_secret);
|
||||||
|
$this->assertNull($user->two_factor_recovery_codes);
|
||||||
|
$this->assertFalse($user->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sensitive_two_factor_endpoints_reject_expired_password_confirmation(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->withTwoFactor()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
foreach (['two-factor.qr-code', 'two-factor.secret-key', 'two-factor.recovery-codes'] as $route) {
|
||||||
|
$this->getJson(route($route))->assertStatus(423);
|
||||||
|
}
|
||||||
|
$this->deleteJson(route('two-factor.disable'))->assertStatus(423);
|
||||||
|
$this->postJson(route('two-factor.regenerate-recovery-codes'))->assertStatus(423);
|
||||||
|
$this->assertTrue($user->refresh()->hasEnabledTwoFactorAuthentication());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Settings;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SecurityUiTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_security_and_application_key_rendering(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/security.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Security UI rendering checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ class UserApplicationTest extends TestCase
|
|||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/Applications')
|
->component('settings/Applications')
|
||||||
->where('applications.0.name', 'Claude Desktop')
|
->where('applications.0.name', 'Claude Desktop')
|
||||||
->where('applicationKey', null),
|
->where('applicationKey', null)
|
||||||
|
->missing('applications.0.key_hash')
|
||||||
|
->missing('applications.0.plain_text_key'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,4 +102,26 @@ class UserApplicationTest extends TestCase
|
|||||||
'id' => $application->id,
|
'id' => $application->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_application_list_only_contains_the_current_users_keys(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
UserApplication::factory()->for($user)->create(['name' => 'Own tool']);
|
||||||
|
UserApplication::factory()->create(['name' => 'Foreign tool']);
|
||||||
|
$this->actingAs($user)->get(route('applications.edit'))
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->has('applications', 1)
|
||||||
|
->where('applications.0.name', 'Own tool')
|
||||||
|
->where('applicationKey', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_application_name_does_not_issue_a_key(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user)->from(route('applications.edit'))
|
||||||
|
->post(route('applications.store'), ['name' => ''])
|
||||||
|
->assertSessionHasErrors('name')
|
||||||
|
->assertSessionMissing('applicationKey');
|
||||||
|
$this->assertDatabaseCount('user_applications', 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WelcomeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_public_entry_page_is_available_to_guests(): void
|
||||||
|
{
|
||||||
|
$this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('Welcome')
|
||||||
|
->where('auth.user', null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_entry_page_recognizes_authenticated_users(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('Welcome')
|
||||||
|
->where('auth.user.id', $user->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_entry_page_presentation_and_localization(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/welcome.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Welcome behavior checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4176';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const checklist = () =>
|
||||||
|
page
|
||||||
|
.locator('form')
|
||||||
|
.first()
|
||||||
|
.getByRole('region', { name: 'Acceptance criteria' });
|
||||||
|
const savedChecklist = () => page.locator('#ticket-checklist');
|
||||||
|
const field = (number) =>
|
||||||
|
page.getByRole('textbox', { name: `Criterion ${number}`, exact: true });
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
|
||||||
|
const waitState = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).first().waitFor();
|
||||||
|
const save = async () => {
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('success');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
const boxes = savedChecklist().getByRole('checkbox');
|
||||||
|
await setResult('server');
|
||||||
|
await boxes.first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await page
|
||||||
|
.getByText('Checklist status could not be saved. Please try again.')
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await boxes.first().isChecked(),
|
||||||
|
false,
|
||||||
|
'Failure must restore saved checkbox',
|
||||||
|
);
|
||||||
|
await setResult('success');
|
||||||
|
let payload;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.method() === 'PATCH') {
|
||||||
|
payload = request.postDataJSON();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await boxes.first().tap();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.querySelectorAll('section[aria-busy="true"]').length === 0,
|
||||||
|
);
|
||||||
|
await savedChecklist().getByText('3 of 4 complete').waitFor();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[11, 12, 13, 14],
|
||||||
|
);
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
await field(2).fill('Edited child');
|
||||||
|
await field(2).press('Alt+ArrowLeft');
|
||||||
|
assert.deepEqual(
|
||||||
|
await checklist()
|
||||||
|
.locator('li')
|
||||||
|
.evaluateAll((items) =>
|
||||||
|
items.map((item) => Number(item.dataset.indent)),
|
||||||
|
),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await button('Remove criterion 1').tap();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(
|
||||||
|
await field(1).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await button('Add criterion').focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
assert.equal(
|
||||||
|
await field(4).evaluate((el) => el === document.activeElement),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await field(4).fill('New criterion');
|
||||||
|
await button('Indent criterion 4').click();
|
||||||
|
await setResult('validation');
|
||||||
|
await button('Save changes').click();
|
||||||
|
await waitState('validation');
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
await setResult('success');
|
||||||
|
await save();
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.id),
|
||||||
|
[12, 13, 14, undefined],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
payload.acceptance_criteria.map((item) => item.indent),
|
||||||
|
[0, 1, 0, 1],
|
||||||
|
);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#ticket-edit > summary').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(4).inputValue(), 'New criterion');
|
||||||
|
// Blank/whitespace text cannot silently delete a persisted criterion.
|
||||||
|
await field(1).fill(' ');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), false);
|
||||||
|
const beforeInvalidSave = payload;
|
||||||
|
await button('Save changes').click();
|
||||||
|
assert.equal(payload, beforeInvalidSave);
|
||||||
|
await button('Discard changes').click();
|
||||||
|
assert.equal(await field(1).inputValue(), 'Edited child');
|
||||||
|
assert.equal(await field(1).evaluate((el) => el.checkValidity()), true);
|
||||||
|
for (const width of [1280, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await page.evaluate(
|
||||||
|
(theme) =>
|
||||||
|
document.documentElement.classList.toggle(
|
||||||
|
'dark',
|
||||||
|
theme === 'dark',
|
||||||
|
),
|
||||||
|
theme,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await checklist().evaluate(
|
||||||
|
(el) => el.scrollWidth <= el.clientWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await checklist().screenshot({
|
||||||
|
path: `/private/tmp/kan32-checklist-${width}-${theme}.png`,
|
||||||
|
});
|
||||||
|
await button('Outdent criterion 4').tap();
|
||||||
|
await button('Indent criterion 4').tap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.goto(`${base}/projects/7/tickets/31?readonly=1`);
|
||||||
|
await savedChecklist().waitFor();
|
||||||
|
assert.equal(await savedChecklist().getByRole('textbox').count(), 0);
|
||||||
|
assert.equal(await savedChecklist().getByRole('button').count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await savedChecklist().getByRole('checkbox').first().isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
|
await page.locator('#title').fill('Created with criteria');
|
||||||
|
for (const text of ['First', 'Second']) {
|
||||||
|
await button('Add criterion').click();
|
||||||
|
await page.locator('textarea').last().fill(text);
|
||||||
|
}
|
||||||
|
await button('Indent criterion 2').tap();
|
||||||
|
await checklist().getByRole('checkbox').first().focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'POST' && request.url().endsWith('/tickets'),
|
||||||
|
);
|
||||||
|
await button('Create ticket').click();
|
||||||
|
const created = (await request).postDataJSON();
|
||||||
|
assert.deepEqual(created.acceptance_criteria, [
|
||||||
|
{ text: 'First', checked: true, indent: 0 },
|
||||||
|
{ text: 'Second', checked: true, indent: 1 },
|
||||||
|
]);
|
||||||
|
await page.getByRole('heading', { name: 'Destination' }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Acceptance criteria browser checks passed: persistence, errors, keyboard, touch, readonly, creation, mobile and themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/acceptance-criteria-fixture',
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
canIndentAcceptanceCriterion: canIndent,
|
||||||
|
indentAcceptanceCriterion: indent,
|
||||||
|
removeAcceptanceCriterion: remove,
|
||||||
|
syncAcceptanceCriteriaDescendantCheckedState: toggle,
|
||||||
|
normalizeAcceptanceCriteria: normalize,
|
||||||
|
} = await server.ssrLoadModule('/resources/js/lib/acceptance-criteria.ts');
|
||||||
|
const items = [
|
||||||
|
{ id: 11, text: 'Parent', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Child', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Grandchild', indent: 2, checked: false },
|
||||||
|
{ id: 14, text: 'Next', indent: 0, checked: false },
|
||||||
|
];
|
||||||
|
const original = structuredClone(items);
|
||||||
|
assert.equal(canIndent(items, 0), false);
|
||||||
|
assert.equal(canIndent(items, 1), false);
|
||||||
|
assert.equal(canIndent(items, 3), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
indent(items, 1, -1).map((item) => item.indent),
|
||||||
|
[0, 0, 1, 0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(indent(indent(items, 1, -1), 1, 1), items);
|
||||||
|
assert.deepEqual(indent(items, 0, -1), items);
|
||||||
|
assert.deepEqual(indent(items, -1, 1), items);
|
||||||
|
assert.deepEqual(remove(items, 0), [
|
||||||
|
{ ...items[1], indent: 0 },
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 1), [
|
||||||
|
items[0],
|
||||||
|
{ ...items[2], indent: 1 },
|
||||||
|
items[3],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(remove(items, 99), items);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 0, true).map((item) => item.checked),
|
||||||
|
[true, true, true, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
toggle(items, 1, false).map((item) => item.checked),
|
||||||
|
[false, false, false, false],
|
||||||
|
);
|
||||||
|
const deep = Array.from({ length: 9 }, (_, indent) => ({
|
||||||
|
text: 'Deep',
|
||||||
|
checked: false,
|
||||||
|
indent,
|
||||||
|
}));
|
||||||
|
assert.equal(
|
||||||
|
canIndent([{ text: 'Before', indent: 0, checked: false }, ...deep], 1),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalize(remove(items, 0)).map((item) => item.id),
|
||||||
|
[12, 13, 14],
|
||||||
|
);
|
||||||
|
assert.deepEqual(items, original, 'Editing must not mutate saved criteria');
|
||||||
|
const { default: Checklist } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/components/tickets/AcceptanceCriteriaChecklist.vue',
|
||||||
|
);
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const render = (props) =>
|
||||||
|
renderToString(
|
||||||
|
createSSRApp({ render: () => h(Checklist, props) }).use(
|
||||||
|
createLocalization(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const editable = await render({ modelValue: items });
|
||||||
|
assert.equal((editable.match(/<textarea/g) ?? []).length, 4);
|
||||||
|
assert.match(editable, /Indent criterion 4/);
|
||||||
|
assert.match(editable, /1 of 4 complete/);
|
||||||
|
const readonly = await render({ modelValue: items, readonly: true });
|
||||||
|
assert.doesNotMatch(readonly, /<textarea|Add criterion|Edit checklist/);
|
||||||
|
assert.equal((readonly.match(/type="checkbox" disabled/g) ?? []).length, 4);
|
||||||
|
assert.match(
|
||||||
|
await render({ modelValue: [], error: 'Server validation' }),
|
||||||
|
/Server validation/,
|
||||||
|
);
|
||||||
|
console.log('Acceptance criteria behavior checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -84,15 +84,11 @@ try {
|
|||||||
renderTheme('light'),
|
renderTheme('light'),
|
||||||
renderTheme('dark'),
|
renderTheme('dark'),
|
||||||
]);
|
]);
|
||||||
const selectedButton = (html) =>
|
assert.match(light, /value="light"[^>]*checked/);
|
||||||
html.match(
|
assert.match(dark, /value="dark"[^>]*checked/);
|
||||||
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
|
||||||
)[1];
|
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
|
||||||
assert.match(selectedButton(light), />Light</);
|
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
|
||||||
assert.match(selectedButton(dark), />Dark</);
|
|
||||||
assert.match(light, /role="group" aria-label="Color theme"/);
|
|
||||||
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
|
||||||
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
|
||||||
assert.equal(normalizeAppearance('unexpected'), 'system');
|
assert.equal(normalizeAppearance('unexpected'), 'system');
|
||||||
|
|
||||||
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
||||||
|
|||||||
@@ -320,15 +320,18 @@ try {
|
|||||||
await page.goto(`${base}/projects/7/tickets/create`);
|
await page.goto(`${base}/projects/7/tickets/create`);
|
||||||
await page.locator('#title').fill('New ticket draft');
|
await page.locator('#title').fill('New ticket draft');
|
||||||
await page.locator('#description').fill('Keep this description');
|
await page.locator('#description').fill('Keep this description');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Keep this checklist');
|
.fill('Keep this checklist');
|
||||||
await setResult('validation');
|
await setResult('validation');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('validation');
|
await state('validation');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
@@ -349,8 +352,10 @@ try {
|
|||||||
await page.locator('#title').waitFor();
|
await page.locator('#title').waitFor();
|
||||||
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Keep this checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Keep this checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
@@ -359,9 +364,10 @@ try {
|
|||||||
await page.goto(`${base}/projects/7/tickets/31`);
|
await page.goto(`${base}/projects/7/tickets/31`);
|
||||||
await page.locator('#ticket-edit > summary').click();
|
await page.locator('#ticket-edit > summary').click();
|
||||||
await page.locator('#title').fill('Updated ticket draft');
|
await page.locator('#title').fill('Updated ticket draft');
|
||||||
|
await page.getByRole('button', { name: 'Add criterion' }).click();
|
||||||
await page
|
await page
|
||||||
.locator('#acceptance_criteria_checklist')
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
.fill('[ ] Edited checklist');
|
.fill('Edited checklist');
|
||||||
await setResult('server');
|
await setResult('server');
|
||||||
await page.locator('form button[type="submit"]').first().click();
|
await page.locator('form button[type="submit"]').first().click();
|
||||||
await state('server');
|
await state('server');
|
||||||
@@ -370,8 +376,10 @@ try {
|
|||||||
'Updated ticket draft',
|
'Updated ticket draft',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.locator('#acceptance_criteria_checklist').inputValue(),
|
await page
|
||||||
'[ ] Edited checklist',
|
.getByRole('textbox', { name: 'Criterion 1', exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'Edited checklist',
|
||||||
);
|
);
|
||||||
await setResult('success');
|
await setResult('success');
|
||||||
await page.getByRole('button', { name: 'Try again' }).click();
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import process from 'node:process';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import tailwind from '@tailwindcss/vite';
|
import tailwind from '@tailwindcss/vite';
|
||||||
import { createServer } from 'vite';
|
import { createServer } from 'vite';
|
||||||
@@ -36,7 +37,19 @@ let ticket = {
|
|||||||
ticket_status: status,
|
ticket_status: status,
|
||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
story_points: 0,
|
story_points: 0,
|
||||||
acceptance_criteria: [],
|
acceptance_criteria: process.env.CRITERIA_FIXTURE
|
||||||
|
? [
|
||||||
|
{ id: 11, text: 'Parent criterion', indent: 0, checked: false },
|
||||||
|
{ id: 12, text: 'Nested criterion', indent: 1, checked: true },
|
||||||
|
{ id: 13, text: 'Deep criterion', indent: 2, checked: false },
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
text: 'Independent criterion',
|
||||||
|
indent: 0,
|
||||||
|
checked: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
field_values: [],
|
field_values: [],
|
||||||
worklog_entries: [],
|
worklog_entries: [],
|
||||||
created_at: '2026-09-10T12:00:00Z',
|
created_at: '2026-09-10T12:00:00Z',
|
||||||
@@ -125,7 +138,7 @@ server.middlewares.use(async (request, response, next) => {
|
|||||||
ticketStatusOptions: [status],
|
ticketStatusOptions: [status],
|
||||||
users: [],
|
users: [],
|
||||||
parentTicketOptions: [],
|
parentTicketOptions: [],
|
||||||
canEditTicket: true,
|
canEditTicket: !url.searchParams.has('readonly'),
|
||||||
canManageProject: true,
|
canManageProject: true,
|
||||||
childTicketTree: [],
|
childTicketTree: [],
|
||||||
linkedTickets: [],
|
linkedTickets: [],
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4186';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const button = (name) => dialog().getByRole('button', { name, exact: true });
|
||||||
|
const state = (value) =>
|
||||||
|
dialog()
|
||||||
|
.locator(`[data-form-state="${value}"]`)
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'git_test_result', value, url: base }]);
|
||||||
|
const add = () =>
|
||||||
|
page.getByRole('button', { name: 'Add connection', exact: true }).click();
|
||||||
|
const edit = () =>
|
||||||
|
page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Edit alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
const visit = async (query = '?reset') => {
|
||||||
|
await page.goto(`${base}/settings/git-connections${query}`);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Git connections', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const fill = async (token = 'test-secret') => {
|
||||||
|
await dialog()
|
||||||
|
.getByLabel('Gitea base URL', { exact: true })
|
||||||
|
.fill('https://git.example.test');
|
||||||
|
await page.locator('#git-token').fill(token);
|
||||||
|
};
|
||||||
|
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('[data-slot="card"]')
|
||||||
|
.first()
|
||||||
|
.evaluate(
|
||||||
|
(element) =>
|
||||||
|
element.getBoundingClientRect().right <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Connection list clips at ${width}px in ${theme}`,
|
||||||
|
);
|
||||||
|
if (width === 390) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/KAN-38-${theme}-list.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await add();
|
||||||
|
await dialog().waitFor();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('type'),
|
||||||
|
'password',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('autocomplete'),
|
||||||
|
'new-password',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
element.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
Promise.all(
|
||||||
|
element
|
||||||
|
.getAnimations()
|
||||||
|
.map((animation) => animation.finished),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (width === 390) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `/private/tmp/KAN-38-${theme}-dialog.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Add connection',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.evaluate((element) => document.activeElement === element),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await edit();
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-base-url').inputValue(),
|
||||||
|
'https://git.example.test',
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await noOverflow();
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?empty');
|
||||||
|
await page.getByText('No Git connections yet', { exact: true }).waitFor();
|
||||||
|
await add();
|
||||||
|
await fill();
|
||||||
|
await setResult('validation');
|
||||||
|
await button('Add connection').click();
|
||||||
|
await state('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.locator('#git-token').getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
|
||||||
|
await page
|
||||||
|
.getByText(
|
||||||
|
'Gitea could not authenticate with this base URL and token.',
|
||||||
|
{ exact: true },
|
||||||
|
)
|
||||||
|
.waitFor();
|
||||||
|
assert.doesNotMatch(
|
||||||
|
await page.evaluate(() => JSON.stringify(history.state)),
|
||||||
|
/test-secret/,
|
||||||
|
);
|
||||||
|
await page.route('**/settings/git-connections', (route) =>
|
||||||
|
route.abort('failed'),
|
||||||
|
);
|
||||||
|
await button('Add connection').click();
|
||||||
|
await state('network');
|
||||||
|
await page.unroute('**/settings/git-connections');
|
||||||
|
await setResult('server');
|
||||||
|
await button('Try again').click();
|
||||||
|
await state('server');
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), 'test-secret');
|
||||||
|
await setResult('success');
|
||||||
|
const submitted = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.method() === 'POST' &&
|
||||||
|
request.url().endsWith('/settings/git-connections'),
|
||||||
|
);
|
||||||
|
await button('Try again').click();
|
||||||
|
assert.deepEqual((await submitted).postDataJSON(), {
|
||||||
|
provider: 'gitea',
|
||||||
|
base_url: 'https://git.example.test',
|
||||||
|
api_token: 'test-secret',
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await page.getByText('Git connection added.', { exact: true }).waitFor();
|
||||||
|
await add();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
|
||||||
|
await visit();
|
||||||
|
await edit();
|
||||||
|
await page.locator('#git-token').fill('updated-secret');
|
||||||
|
const patch = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'PATCH',
|
||||||
|
);
|
||||||
|
await button('Save changes').click();
|
||||||
|
assert.equal((await patch).postDataJSON().api_token, 'updated-secret');
|
||||||
|
await closed();
|
||||||
|
await page.getByText('Git connection updated.', { exact: true }).waitFor();
|
||||||
|
await edit();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await page.locator('#git-token').fill('discard-me');
|
||||||
|
page.once('dialog', (confirmation) => confirmation.dismiss());
|
||||||
|
await button('Cancel').click();
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
page.once('dialog', (confirmation) => confirmation.accept());
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
await edit();
|
||||||
|
assert.equal(await page.locator('#git-token').inputValue(), '');
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Remove alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await setResult('forbidden');
|
||||||
|
await button('Remove connection').click();
|
||||||
|
await state('server');
|
||||||
|
await page.getByText('Connection access denied', { exact: true }).waitFor();
|
||||||
|
assert.equal(await button('Try again').count(), 0);
|
||||||
|
await button('Cancel').click();
|
||||||
|
await closed();
|
||||||
|
await page
|
||||||
|
.getByRole('button', {
|
||||||
|
name: 'Remove alex at https://git.example.test',
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await setResult('success');
|
||||||
|
const deletion = page.waitForRequest(
|
||||||
|
(request) => request.method() === 'DELETE',
|
||||||
|
);
|
||||||
|
await button('Remove connection').click();
|
||||||
|
assert.equal(
|
||||||
|
new URL((await deletion).url()).pathname,
|
||||||
|
'/settings/git-connections/1',
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
await page.getByText('No Git connections yet', { exact: true }).waitFor();
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Git connections browser checks passed: create, edit, remove, token privacy, provider errors, retries, permissions, discard, keyboard and both themes at 320/390/1440px.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import GitConnections from '../../resources/js/pages/settings/GitConnections.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: () => GitConnections,
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/git-connections-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
const connection = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
provider: 'gitea',
|
||||||
|
provider_label: 'Gitea',
|
||||||
|
base_url: 'https://git.example.test',
|
||||||
|
provider_user_id: '42',
|
||||||
|
account_login: 'alex',
|
||||||
|
account_name: 'Alex Morgan',
|
||||||
|
created_at: null,
|
||||||
|
updated_at: null,
|
||||||
|
};
|
||||||
|
function makePage(extra = {}) {
|
||||||
|
return {
|
||||||
|
component: 'GitConnections',
|
||||||
|
url: '/settings/git-connections',
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors: {},
|
||||||
|
sidebarOpen: true,
|
||||||
|
appearance: 'system',
|
||||||
|
status: null,
|
||||||
|
gitConnections: [connection],
|
||||||
|
auth: {
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: null,
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: true,
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (serve) {
|
||||||
|
let connections = [connection];
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/settings')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
|
const mutation = request.method !== 'GET';
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(/git_test_result=(\w+)/)?.[1] ??
|
||||||
|
'success';
|
||||||
|
let extra = {};
|
||||||
|
if (!mutation && url.searchParams.has('reset')) {
|
||||||
|
connections = [connection];
|
||||||
|
}
|
||||||
|
if (mutation && ['server', 'forbidden'].includes(result)) {
|
||||||
|
response.statusCode = result === 'forbidden' ? 403 : 503;
|
||||||
|
response.end('Simulated request failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mutation) {
|
||||||
|
let body = '';
|
||||||
|
for await (const chunk of request) {
|
||||||
|
body += chunk;
|
||||||
|
}
|
||||||
|
const data = body ? JSON.parse(body) : {};
|
||||||
|
if (result === 'validation') {
|
||||||
|
extra.errors = {
|
||||||
|
api_token:
|
||||||
|
'Gitea could not authenticate with this base URL and token.',
|
||||||
|
};
|
||||||
|
} else if (request.method === 'DELETE') {
|
||||||
|
connections = connections.filter(
|
||||||
|
(item) =>
|
||||||
|
item.id !== Number(url.pathname.split('/').at(-1)),
|
||||||
|
);
|
||||||
|
extra.status = 'alex @ https://git.example.test was removed.';
|
||||||
|
} else if (request.method === 'PATCH') {
|
||||||
|
connections = connections.map((item) =>
|
||||||
|
item.id === Number(url.pathname.split('/').at(-1))
|
||||||
|
? { ...item, base_url: data.base_url }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
extra.status = 'Git connection was updated.';
|
||||||
|
} else {
|
||||||
|
connections.push({
|
||||||
|
...connection,
|
||||||
|
id: 2,
|
||||||
|
account_login: 'new-account',
|
||||||
|
base_url: data.base_url,
|
||||||
|
});
|
||||||
|
extra.status = 'Git connection was added.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = makePage({
|
||||||
|
gitConnections: url.searchParams.has('empty') ? [] : connections,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/git-connections-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log('Git connections fixture ready.');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const component = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/GitConnections.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (extra = {}, locale = createLocalization()) =>
|
||||||
|
(
|
||||||
|
await createInertiaApp({
|
||||||
|
page: makePage(extra),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance()),
|
||||||
|
})
|
||||||
|
).body;
|
||||||
|
const html = await render();
|
||||||
|
assert.match(html, /Gitea/);
|
||||||
|
assert.match(html, /https:\/\/git.example.test/);
|
||||||
|
assert.match(html, /Edit alex at https:\/\/git.example.test/);
|
||||||
|
assert.doesNotMatch(html, /name="api_token"/);
|
||||||
|
assert.match(
|
||||||
|
await render({ gitConnections: [] }),
|
||||||
|
/No Git connections yet/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await render(
|
||||||
|
{},
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'gitConnections.add': 'Verbindung hinzufügen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/Verbindung hinzufügen/,
|
||||||
|
);
|
||||||
|
console.log('Git connections rendering checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4188';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
const requests = [];
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const button = (name) => page.getByRole('button', { name, exact: true });
|
||||||
|
const dialog = () => page.getByRole('dialog');
|
||||||
|
const result = (value) =>
|
||||||
|
context.addCookies([{ name: 'workflow_test_result', value, url: base }]);
|
||||||
|
const visit = async (query = '?reset') => {
|
||||||
|
await result('success');
|
||||||
|
await page.goto(base + '/projects/1/settings/board' + query);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Board workflow', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
};
|
||||||
|
const noOverflow = async () =>
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const submit = async (label, method, pathname, expected) => {
|
||||||
|
const sent = page.waitForRequest((request) => request.method() === method);
|
||||||
|
await dialog().getByRole('button', { name: label, exact: true }).click();
|
||||||
|
const request = await sent;
|
||||||
|
assert.equal(new URL(request.url()).pathname, pathname);
|
||||||
|
assert.deepEqual(request.postDataJSON(), expected);
|
||||||
|
};
|
||||||
|
const closed = () => dialog().waitFor({ state: 'hidden' });
|
||||||
|
const formState = (state) =>
|
||||||
|
dialog()
|
||||||
|
.locator('[data-form-state="' + state + '"]')
|
||||||
|
.waitFor({ state: 'visible' });
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await visit();
|
||||||
|
await noOverflow();
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Backlog').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Ready').isDisabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await button('Delete status Review').isDisabled(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const trigger = button('Edit column In progress');
|
||||||
|
await trigger.focus();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await dialog().waitFor();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.inputValue(),
|
||||||
|
'In progress',
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
assert.equal(
|
||||||
|
await dialog().evaluate((element) =>
|
||||||
|
element.contains(document.activeElement),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await noOverflow();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await trigger.evaluate(
|
||||||
|
(element) => document.activeElement === element,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if ([390, 1440].includes(width)) {
|
||||||
|
await page.screenshot({
|
||||||
|
path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png',
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit('?reset&empty');
|
||||||
|
await page.getByText('No board columns yet', { exact: true }).waitFor();
|
||||||
|
await visit();
|
||||||
|
await button('Add column').click();
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.fill('Quality assurance');
|
||||||
|
await result('validation');
|
||||||
|
await submit('Add column', 'POST', '/projects/1/settings/board/columns', {
|
||||||
|
name: 'Quality assurance',
|
||||||
|
});
|
||||||
|
await formState('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||||
|
'Quality assurance',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Column name', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await result('server');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Add column', exact: true })
|
||||||
|
.click();
|
||||||
|
await formState('server');
|
||||||
|
await result('success');
|
||||||
|
await dialog()
|
||||||
|
.getByRole('button', { name: 'Try again', exact: true })
|
||||||
|
.click();
|
||||||
|
await closed();
|
||||||
|
await button('Edit column Quality assurance').waitFor();
|
||||||
|
const newColumnId = await page
|
||||||
|
.locator('[data-column-id]')
|
||||||
|
.last()
|
||||||
|
.getAttribute('data-column-id');
|
||||||
|
await button('Edit column Quality assurance').click();
|
||||||
|
await page.getByLabel('Column name', { exact: true }).fill('QA');
|
||||||
|
await page.getByLabel('Position', { exact: true }).fill('1');
|
||||||
|
await result('position');
|
||||||
|
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
await formState('validation');
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.getByLabel('Position', { exact: true })
|
||||||
|
.getAttribute('aria-invalid'),
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
await result('success');
|
||||||
|
await submit(
|
||||||
|
'Save',
|
||||||
|
'PATCH',
|
||||||
|
'/projects/1/settings/board/columns/' + newColumnId,
|
||||||
|
{ name: 'QA', position: 1 },
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('[data-column-id]')
|
||||||
|
.first()
|
||||||
|
.getAttribute('data-column-id'),
|
||||||
|
newColumnId,
|
||||||
|
);
|
||||||
|
await button('Edit column QA').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Position', { exact: true }).inputValue(),
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Column name', { exact: true }).inputValue(),
|
||||||
|
'QA',
|
||||||
|
);
|
||||||
|
await page.getByLabel('Column name', { exact: true }).fill('Unsaved');
|
||||||
|
page.once('dialog', (prompt) => prompt.dismiss());
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
assert.equal(await dialog().isVisible(), true);
|
||||||
|
page.once('dialog', (prompt) => prompt.accept());
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
await button('Add status to Backlog').click();
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Status name', { exact: true }).inputValue(),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Triage');
|
||||||
|
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||||
|
name: 'Triage',
|
||||||
|
board_column_id: null,
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await button('Rename status Triage').waitFor();
|
||||||
|
await button('Add status to Todo').click();
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Refinement');
|
||||||
|
await submit('Add status', 'POST', '/projects/1/settings/board/statuses', {
|
||||||
|
name: 'Refinement',
|
||||||
|
board_column_id: 10,
|
||||||
|
});
|
||||||
|
await closed();
|
||||||
|
await button('Rename status Review').click();
|
||||||
|
await page.getByLabel('Status name', { exact: true }).fill('Peer review');
|
||||||
|
await page.route('**/statuses/4', (route) => route.abort('failed'));
|
||||||
|
await dialog().getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
await formState('network');
|
||||||
|
await page.unroute('**/statuses/4');
|
||||||
|
await submit(
|
||||||
|
'Try again',
|
||||||
|
'PATCH',
|
||||||
|
'/projects/1/settings/board/statuses/4',
|
||||||
|
{ name: 'Peer review' },
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
const before = requests.length;
|
||||||
|
await button('Delete status Peer review').click();
|
||||||
|
await dialog()
|
||||||
|
.getByText(/assigned to “Building”/)
|
||||||
|
.waitFor();
|
||||||
|
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
assert.equal(requests.length, before);
|
||||||
|
await button('Delete status Peer review').click();
|
||||||
|
await result('validation');
|
||||||
|
await submit(
|
||||||
|
'Delete',
|
||||||
|
'DELETE',
|
||||||
|
'/projects/1/settings/board/statuses/4',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await formState('validation');
|
||||||
|
await dialog()
|
||||||
|
.getByText('This group needs at least one status.', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await result('success');
|
||||||
|
await dialog().getByRole('button', { name: 'Delete', exact: true }).click();
|
||||||
|
await closed();
|
||||||
|
assert.equal(await button('Delete status Building').isDisabled(), true);
|
||||||
|
await button('Delete column In progress').click();
|
||||||
|
await dialog()
|
||||||
|
.getByText(/statuses move to the backlog/)
|
||||||
|
.waitFor();
|
||||||
|
await submit(
|
||||||
|
'Delete',
|
||||||
|
'DELETE',
|
||||||
|
'/projects/1/settings/board/columns/20',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await closed();
|
||||||
|
await page
|
||||||
|
.getByRole('list', { name: 'Statuses in Backlog', exact: true })
|
||||||
|
.getByText('Building', { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await button('Edit column In progress').count(), 0);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Board from '../../resources/js/pages/projects/settings/Board.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) =>
|
||||||
|
name === 'Board'
|
||||||
|
? Board
|
||||||
|
: { render: () => h('h1', 'Destination reached') },
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user