fixed missing features
This commit is contained in:
@@ -30,20 +30,16 @@ interface GitProviderClient
|
|||||||
* @return array<int, GitBranch>
|
* @return array<int, GitBranch>
|
||||||
*/
|
*/
|
||||||
public function listBranches(
|
public function listBranches(
|
||||||
string $baseUrl,
|
|
||||||
string $token,
|
string $token,
|
||||||
string $owner,
|
GitRepository $repository,
|
||||||
string $repository,
|
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, GitPullRequest>
|
* @return array<int, GitPullRequest>
|
||||||
*/
|
*/
|
||||||
public function listPullRequests(
|
public function listPullRequests(
|
||||||
string $baseUrl,
|
|
||||||
string $token,
|
string $token,
|
||||||
string $owner,
|
GitRepository $repository,
|
||||||
string $repository,
|
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
public function createBranch(
|
public function createBranch(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace App\Actions\Git\Data;
|
|||||||
final readonly class GitRepository
|
final readonly class GitRepository
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
public string $baseUrl,
|
||||||
public string $providerRepositoryId,
|
public string $providerRepositoryId,
|
||||||
public string $owner,
|
public string $owner,
|
||||||
public string $name,
|
public string $name,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
->throw()
|
->throw()
|
||||||
->json();
|
->json();
|
||||||
|
|
||||||
return $this->mapRepository($payload);
|
return $this->mapRepository($payload, $baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +85,7 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
->json();
|
->json();
|
||||||
|
|
||||||
foreach ($payload as $repository) {
|
foreach ($payload as $repository) {
|
||||||
$repositories[] = $this->mapRepository($repository);
|
$repositories[] = $this->mapRepository($repository, $baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
$page++;
|
$page++;
|
||||||
@@ -98,19 +98,17 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
* @return array<int, GitBranch>
|
* @return array<int, GitBranch>
|
||||||
*/
|
*/
|
||||||
public function listBranches(
|
public function listBranches(
|
||||||
string $baseUrl,
|
|
||||||
string $token,
|
string $token,
|
||||||
string $owner,
|
GitRepository $repository,
|
||||||
string $repository,
|
|
||||||
): array {
|
): array {
|
||||||
$page = 1;
|
$page = 1;
|
||||||
$branches = [];
|
$branches = [];
|
||||||
|
|
||||||
do {
|
do {
|
||||||
/** @var array<int, array{name: string}> $payload */
|
/** @var array<int, array{name: string}> $payload */
|
||||||
$payload = $this->request($baseUrl, $token)
|
$payload = $this->request($repository->baseUrl, $token)
|
||||||
->get(
|
->get(
|
||||||
'/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/branches',
|
'/repos/'.rawurlencode($repository->owner).'/'.rawurlencode($repository->name).'/branches',
|
||||||
[
|
[
|
||||||
'limit' => 100,
|
'limit' => 100,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
@@ -135,10 +133,8 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
* @return array<int, GitPullRequest>
|
* @return array<int, GitPullRequest>
|
||||||
*/
|
*/
|
||||||
public function listPullRequests(
|
public function listPullRequests(
|
||||||
string $baseUrl,
|
|
||||||
string $token,
|
string $token,
|
||||||
string $owner,
|
GitRepository $repository,
|
||||||
string $repository,
|
|
||||||
): array {
|
): array {
|
||||||
$page = 1;
|
$page = 1;
|
||||||
$pullRequests = [];
|
$pullRequests = [];
|
||||||
@@ -153,9 +149,9 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
* head: array{ref: string}
|
* head: array{ref: string}
|
||||||
* }> $payload
|
* }> $payload
|
||||||
*/
|
*/
|
||||||
$payload = $this->request($baseUrl, $token)
|
$payload = $this->request($repository->baseUrl, $token)
|
||||||
->get(
|
->get(
|
||||||
'/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/pulls',
|
'/repos/'.rawurlencode($repository->owner).'/'.rawurlencode($repository->name).'/pulls',
|
||||||
[
|
[
|
||||||
'limit' => 100,
|
'limit' => 100,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
@@ -248,9 +244,10 @@ class GiteaProviderClient implements GitProviderClient
|
|||||||
* private: bool
|
* private: bool
|
||||||
* } $repository
|
* } $repository
|
||||||
*/
|
*/
|
||||||
private function mapRepository(array $repository): GitRepository
|
private function mapRepository(array $repository, string $baseUrl): GitRepository
|
||||||
{
|
{
|
||||||
return new GitRepository(
|
return new GitRepository(
|
||||||
|
baseUrl: $baseUrl,
|
||||||
providerRepositoryId: (string) $repository['id'],
|
providerRepositoryId: (string) $repository['id'],
|
||||||
owner: $repository['owner']['login'],
|
owner: $repository['owner']['login'],
|
||||||
name: $repository['name'],
|
name: $repository['name'],
|
||||||
|
|||||||
@@ -66,10 +66,8 @@ class ResolveTicketBranchMatches
|
|||||||
$this->gitProviderManager
|
$this->gitProviderManager
|
||||||
->client($userGitConnection->provider)
|
->client($userGitConnection->provider)
|
||||||
->listBranches(
|
->listBranches(
|
||||||
$userGitConnection->base_url,
|
|
||||||
$userGitConnection->api_token,
|
$userGitConnection->api_token,
|
||||||
$repositoryConnection->owner_name,
|
$repositoryConnection->toGitRepository(),
|
||||||
$repositoryConnection->repository_name,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
->filter(fn (GitBranch $branch): bool => $this->ticketKeyReferenceMatcher->matches(
|
->filter(fn (GitBranch $branch): bool => $this->ticketKeyReferenceMatcher->matches(
|
||||||
@@ -85,7 +83,7 @@ class ResolveTicketBranchMatches
|
|||||||
$error = null;
|
$error = null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$branches = [];
|
$branches = [];
|
||||||
$error = 'KanbanGit could not load branches from this repository right now.';
|
$error = ((string) config('app.name')).' could not load branches from this repository right now.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -72,10 +72,8 @@ class ResolveTicketPullRequestMatches
|
|||||||
$this->gitProviderManager
|
$this->gitProviderManager
|
||||||
->client($userGitConnection->provider)
|
->client($userGitConnection->provider)
|
||||||
->listPullRequests(
|
->listPullRequests(
|
||||||
$userGitConnection->base_url,
|
|
||||||
$userGitConnection->api_token,
|
$userGitConnection->api_token,
|
||||||
$repositoryConnection->owner_name,
|
$repositoryConnection->toGitRepository(),
|
||||||
$repositoryConnection->repository_name,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
->filter(fn (GitPullRequest $pullRequest): bool => $this->ticketKeyReferenceMatcher->matches(
|
->filter(fn (GitPullRequest $pullRequest): bool => $this->ticketKeyReferenceMatcher->matches(
|
||||||
@@ -94,7 +92,7 @@ class ResolveTicketPullRequestMatches
|
|||||||
|
|
||||||
$error = null;
|
$error = null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$error = 'KanbanGit could not load pull requests from this repository right now.';
|
$error = ((string) config('app.name')).' could not load pull requests from this repository right now.';
|
||||||
$pullRequests = [];
|
$pullRequests = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class BuildProjectBacklogPageProps
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
Project $project,
|
||||||
|
bool $canEditTickets,
|
||||||
|
bool $canCreateTickets,
|
||||||
|
?string $status,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'backlogTicketStatuses' => $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->get(),
|
||||||
|
'canCreateTickets' => $canCreateTickets,
|
||||||
|
'canEditTickets' => $canEditTickets,
|
||||||
|
'project' => $project,
|
||||||
|
'status' => $status,
|
||||||
|
'ticketStatusOptions' => $project->ticketStatuses()
|
||||||
|
->with('boardColumn')
|
||||||
|
->get(),
|
||||||
|
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||||
|
'ticketTypes' => $project->ticketTypes()->get(),
|
||||||
|
'tickets' => Ticket::query()
|
||||||
|
->with([
|
||||||
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
'ticketType',
|
||||||
|
])
|
||||||
|
->where('project_id', $project->id)
|
||||||
|
->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id'))
|
||||||
|
->orderByDesc('sequence_number')
|
||||||
|
->get(),
|
||||||
|
'users' => User::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Projects;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
|
||||||
|
class BuildProjectBoardPageProps
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
Project $project,
|
||||||
|
bool $canEditTickets,
|
||||||
|
bool $canCreateTickets,
|
||||||
|
?string $status,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'boardColumns' => $project->boardColumns()
|
||||||
|
->with([
|
||||||
|
'ticketStatuses',
|
||||||
|
'tickets' => fn ($query) => $query
|
||||||
|
->with([
|
||||||
|
'ticketPriority',
|
||||||
|
'ticketStatus.allowedTargetStatuses.boardColumn',
|
||||||
|
'ticketStatus.boardColumn',
|
||||||
|
'ticketType',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->get(),
|
||||||
|
'canCreateTickets' => $canCreateTickets,
|
||||||
|
'canEditTickets' => $canEditTickets,
|
||||||
|
'project' => $project,
|
||||||
|
'status' => $status,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class BuildProjectTicketUpdateAttributes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $validated
|
||||||
|
* @return array{
|
||||||
|
* acceptance_criteria: string|null,
|
||||||
|
* assignee_user_id: int|null,
|
||||||
|
* author_user_id: int|null,
|
||||||
|
* blocked_comment: string|null,
|
||||||
|
* description: string|null,
|
||||||
|
* is_blocked: bool,
|
||||||
|
* parent_ticket_id: int|null,
|
||||||
|
* story_points: int|null,
|
||||||
|
* ticket_priority_id: int,
|
||||||
|
* ticket_type_id: int,
|
||||||
|
* title: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function handle(Ticket $ticket, array $validated): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'),
|
||||||
|
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
|
||||||
|
? Arr::get($validated, 'assignee_user_id')
|
||||||
|
: $ticket->assignee_user_id,
|
||||||
|
'author_user_id' => Arr::exists($validated, 'author_user_id')
|
||||||
|
? Arr::get($validated, 'author_user_id')
|
||||||
|
: $ticket->author_user_id,
|
||||||
|
'blocked_comment' => Arr::get($validated, 'blocked_comment'),
|
||||||
|
'description' => Arr::get($validated, 'description'),
|
||||||
|
'is_blocked' => Arr::get($validated, 'is_blocked', false),
|
||||||
|
'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'),
|
||||||
|
'story_points' => Arr::exists($validated, 'story_points')
|
||||||
|
? Arr::get($validated, 'story_points')
|
||||||
|
: $ticket->story_points,
|
||||||
|
'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'),
|
||||||
|
'ticket_type_id' => Arr::get($validated, 'ticket_type_id'),
|
||||||
|
'title' => Arr::get($validated, 'title'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Tickets;
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketStatus;
|
use App\Models\TicketStatus;
|
||||||
@@ -56,7 +57,7 @@ class CreateProjectTicket
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$actor,
|
$actor,
|
||||||
'created',
|
TicketWorklogEntryType::Created,
|
||||||
"Ticket was created in {$ticketStatus->name}.",
|
"Ticket was created in {$ticketStatus->name}.",
|
||||||
[
|
[
|
||||||
'from' => null,
|
'from' => null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Tickets;
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -14,7 +15,7 @@ class CreateTicketWorklogEntry
|
|||||||
public function handle(
|
public function handle(
|
||||||
Ticket $ticket,
|
Ticket $ticket,
|
||||||
?User $actor,
|
?User $actor,
|
||||||
string $entryType,
|
TicketWorklogEntryType $entryType,
|
||||||
string $summary,
|
string $summary,
|
||||||
?array $changes = null,
|
?array $changes = null,
|
||||||
): TicketWorklogEntry {
|
): TicketWorklogEntry {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Models\Ticket;
|
||||||
|
use App\Models\TicketFieldDefinition;
|
||||||
|
use App\Models\TicketFieldValue;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class SyncProjectTicketFieldValues
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, TicketFieldDefinition> $fieldDefinitions
|
||||||
|
* @param Collection<int, TicketFieldValue> $existingFieldValues
|
||||||
|
* @param array<int, array{
|
||||||
|
* ticket_field_definition_id: int,
|
||||||
|
* value_number?: float|int|string|null,
|
||||||
|
* value_user_id?: int|string|null
|
||||||
|
* }> $fieldValueAttributes
|
||||||
|
* @return array{
|
||||||
|
* changes: array<int, array<string, mixed>>,
|
||||||
|
* summary_labels: array<int, string>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
Ticket $ticket,
|
||||||
|
Collection $fieldDefinitions,
|
||||||
|
Collection $existingFieldValues,
|
||||||
|
array $fieldValueAttributes,
|
||||||
|
): array {
|
||||||
|
$changes = [];
|
||||||
|
$summaryLabels = [];
|
||||||
|
|
||||||
|
foreach ($fieldValueAttributes as $fieldValueAttribute) {
|
||||||
|
$fieldDefinition = $fieldDefinitions->get((int) $fieldValueAttribute['ticket_field_definition_id']);
|
||||||
|
|
||||||
|
if ($fieldDefinition === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingFieldValue = $existingFieldValues->get($fieldDefinition->id);
|
||||||
|
|
||||||
|
if ($fieldDefinition->field_type === 'user') {
|
||||||
|
$this->syncUserFieldValue(
|
||||||
|
$changes,
|
||||||
|
$existingFieldValue,
|
||||||
|
$fieldDefinition,
|
||||||
|
$fieldValueAttribute,
|
||||||
|
$summaryLabels,
|
||||||
|
$ticket,
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->syncNumberFieldValue(
|
||||||
|
$changes,
|
||||||
|
$existingFieldValue,
|
||||||
|
$fieldDefinition,
|
||||||
|
$fieldValueAttribute,
|
||||||
|
$summaryLabels,
|
||||||
|
$ticket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'changes' => $changes,
|
||||||
|
'summary_labels' => $summaryLabels,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $changes
|
||||||
|
* @param array{value_user_id?: int|string|null} $fieldValueAttribute
|
||||||
|
* @param array<int, string> $summaryLabels
|
||||||
|
*/
|
||||||
|
private function syncUserFieldValue(
|
||||||
|
array &$changes,
|
||||||
|
?TicketFieldValue $existingFieldValue,
|
||||||
|
TicketFieldDefinition $fieldDefinition,
|
||||||
|
array $fieldValueAttribute,
|
||||||
|
array &$summaryLabels,
|
||||||
|
Ticket $ticket,
|
||||||
|
): void {
|
||||||
|
$newUserId = $fieldValueAttribute['value_user_id'] ?? null;
|
||||||
|
$newUserId = $newUserId === null || $newUserId === '' ? null : (int) $newUserId;
|
||||||
|
|
||||||
|
if ($existingFieldValue?->value_user_id === $newUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldUserName = $existingFieldValue?->userValue?->name;
|
||||||
|
$newUserName = $newUserId === null ? null : User::query()->find($newUserId)?->name;
|
||||||
|
|
||||||
|
$changes[$fieldDefinition->id] = [
|
||||||
|
'field' => $fieldDefinition->label,
|
||||||
|
'from' => $oldUserName,
|
||||||
|
'to' => $newUserName,
|
||||||
|
];
|
||||||
|
$summaryLabels[] = "{$fieldDefinition->label} field";
|
||||||
|
|
||||||
|
if ($newUserId === null) {
|
||||||
|
$existingFieldValue?->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->fieldValues()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value_number' => null,
|
||||||
|
'value_user_id' => $newUserId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $changes
|
||||||
|
* @param array{value_number?: float|int|string|null} $fieldValueAttribute
|
||||||
|
* @param array<int, string> $summaryLabels
|
||||||
|
*/
|
||||||
|
private function syncNumberFieldValue(
|
||||||
|
array &$changes,
|
||||||
|
?TicketFieldValue $existingFieldValue,
|
||||||
|
TicketFieldDefinition $fieldDefinition,
|
||||||
|
array $fieldValueAttribute,
|
||||||
|
array &$summaryLabels,
|
||||||
|
Ticket $ticket,
|
||||||
|
): void {
|
||||||
|
$newNumber = $fieldValueAttribute['value_number'] ?? null;
|
||||||
|
$newNumber = $newNumber === null || $newNumber === '' ? null : (float) $newNumber;
|
||||||
|
$oldNumber = $existingFieldValue?->value_number;
|
||||||
|
|
||||||
|
if ($oldNumber === $newNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes[$fieldDefinition->id] = [
|
||||||
|
'field' => $fieldDefinition->label,
|
||||||
|
'from' => $this->formatNumber($oldNumber),
|
||||||
|
'to' => $this->formatNumber($newNumber),
|
||||||
|
];
|
||||||
|
$summaryLabels[] = "{$fieldDefinition->label} field";
|
||||||
|
|
||||||
|
if ($newNumber === null) {
|
||||||
|
$existingFieldValue?->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket->fieldValues()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'ticket_field_definition_id' => $fieldDefinition->id,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'value_number' => $newNumber,
|
||||||
|
'value_user_id' => null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatNumber(?float $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Tickets;
|
namespace App\Actions\Tickets;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketFieldDefinition;
|
use App\Models\TicketFieldDefinition;
|
||||||
use App\Models\TicketFieldValue;
|
use App\Models\TicketFieldValue;
|
||||||
@@ -13,6 +14,7 @@ class UpdateProjectTicket
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||||
|
private readonly SyncProjectTicketFieldValues $syncProjectTicketFieldValues,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,36 +181,19 @@ class UpdateProjectTicket
|
|||||||
$ticket->save();
|
$ticket->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($fieldValueAttributes as $fieldValueAttribute) {
|
$fieldValueChangeSet = $this->syncProjectTicketFieldValues->handle(
|
||||||
$fieldDefinition = $fieldDefinitions->get((int) $fieldValueAttribute['ticket_field_definition_id']);
|
$ticket,
|
||||||
|
$fieldDefinitions,
|
||||||
|
$existingFieldValues,
|
||||||
|
$fieldValueAttributes,
|
||||||
|
);
|
||||||
|
|
||||||
if ($fieldDefinition === null) {
|
if ($fieldValueChangeSet['changes'] !== []) {
|
||||||
continue;
|
$changes['field_values'] = $fieldValueChangeSet['changes'];
|
||||||
}
|
$summaryLabels = [
|
||||||
|
...$summaryLabels,
|
||||||
$existingFieldValue = $existingFieldValues->get($fieldDefinition->id);
|
...$fieldValueChangeSet['summary_labels'],
|
||||||
|
];
|
||||||
if ($fieldDefinition->field_type === 'user') {
|
|
||||||
$this->syncUserFieldValue(
|
|
||||||
$changes,
|
|
||||||
$existingFieldValue,
|
|
||||||
$fieldDefinition,
|
|
||||||
$fieldValueAttribute,
|
|
||||||
$summaryLabels,
|
|
||||||
$ticket,
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->syncNumberFieldValue(
|
|
||||||
$changes,
|
|
||||||
$existingFieldValue,
|
|
||||||
$fieldDefinition,
|
|
||||||
$fieldValueAttribute,
|
|
||||||
$summaryLabels,
|
|
||||||
$ticket,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($summaryLabels === []) {
|
if ($summaryLabels === []) {
|
||||||
@@ -218,7 +203,7 @@ class UpdateProjectTicket
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket->fresh(),
|
$ticket->fresh(),
|
||||||
$actor,
|
$actor,
|
||||||
'updated',
|
TicketWorklogEntryType::Updated,
|
||||||
$this->buildSummary($summaryLabels),
|
$this->buildSummary($summaryLabels),
|
||||||
$changes,
|
$changes,
|
||||||
);
|
);
|
||||||
@@ -273,98 +258,6 @@ class UpdateProjectTicket
|
|||||||
$summaryLabels[] = $summaryLabel;
|
$summaryLabels[] = $summaryLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $changes
|
|
||||||
* @param array{value_user_id?: int|string|null} $fieldValueAttribute
|
|
||||||
* @param array<int, string> $summaryLabels
|
|
||||||
*/
|
|
||||||
private function syncUserFieldValue(
|
|
||||||
array &$changes,
|
|
||||||
?TicketFieldValue $existingFieldValue,
|
|
||||||
TicketFieldDefinition $fieldDefinition,
|
|
||||||
array $fieldValueAttribute,
|
|
||||||
array &$summaryLabels,
|
|
||||||
Ticket $ticket,
|
|
||||||
): void {
|
|
||||||
$newUserId = $fieldValueAttribute['value_user_id'] ?? null;
|
|
||||||
$newUserId = $newUserId === null || $newUserId === '' ? null : (int) $newUserId;
|
|
||||||
|
|
||||||
if ($existingFieldValue?->value_user_id === $newUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$oldUserName = $existingFieldValue?->userValue?->name;
|
|
||||||
$newUserName = $newUserId === null ? null : User::query()->find($newUserId)?->name;
|
|
||||||
|
|
||||||
$changes['field_values'][$fieldDefinition->id] = [
|
|
||||||
'field' => $fieldDefinition->label,
|
|
||||||
'from' => $oldUserName,
|
|
||||||
'to' => $newUserName,
|
|
||||||
];
|
|
||||||
$summaryLabels[] = "{$fieldDefinition->label} field";
|
|
||||||
|
|
||||||
if ($newUserId === null) {
|
|
||||||
$existingFieldValue?->delete();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticket->fieldValues()->updateOrCreate(
|
|
||||||
[
|
|
||||||
'ticket_field_definition_id' => $fieldDefinition->id,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'value_number' => null,
|
|
||||||
'value_user_id' => $newUserId,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $changes
|
|
||||||
* @param array{value_number?: float|int|string|null} $fieldValueAttribute
|
|
||||||
* @param array<int, string> $summaryLabels
|
|
||||||
*/
|
|
||||||
private function syncNumberFieldValue(
|
|
||||||
array &$changes,
|
|
||||||
?TicketFieldValue $existingFieldValue,
|
|
||||||
TicketFieldDefinition $fieldDefinition,
|
|
||||||
array $fieldValueAttribute,
|
|
||||||
array &$summaryLabels,
|
|
||||||
Ticket $ticket,
|
|
||||||
): void {
|
|
||||||
$newNumber = $fieldValueAttribute['value_number'] ?? null;
|
|
||||||
$newNumber = $newNumber === null || $newNumber === '' ? null : (float) $newNumber;
|
|
||||||
$oldNumber = $existingFieldValue?->value_number;
|
|
||||||
|
|
||||||
if ($oldNumber === $newNumber) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$changes['field_values'][$fieldDefinition->id] = [
|
|
||||||
'field' => $fieldDefinition->label,
|
|
||||||
'from' => $this->formatNumber($oldNumber),
|
|
||||||
'to' => $this->formatNumber($newNumber),
|
|
||||||
];
|
|
||||||
$summaryLabels[] = "{$fieldDefinition->label} field";
|
|
||||||
|
|
||||||
if ($newNumber === null) {
|
|
||||||
$existingFieldValue?->delete();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticket->fieldValues()->updateOrCreate(
|
|
||||||
[
|
|
||||||
'ticket_field_definition_id' => $fieldDefinition->id,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'value_number' => $newNumber,
|
|
||||||
'value_user_id' => null,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $summaryLabels
|
* @param array<int, string> $summaryLabels
|
||||||
*/
|
*/
|
||||||
@@ -386,13 +279,4 @@ class UpdateProjectTicket
|
|||||||
|
|
||||||
return 'Updated '.$uniqueLabels->implode(', ').', and '.$lastLabel.'.';
|
return 'Updated '.$uniqueLabels->implode(', ').', and '.$lastLabel.'.';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatNumber(?float $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum TicketWorklogEntryType: string
|
||||||
|
{
|
||||||
|
case Created = 'created';
|
||||||
|
case LinkRemoved = 'link_removed';
|
||||||
|
case LinkUpdated = 'link_updated';
|
||||||
|
case StatusChanged = 'status_changed';
|
||||||
|
case Updated = 'updated';
|
||||||
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
|
||||||
use App\Actions\Tickets\CreateProjectTicket;
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Http\Requests\StoreProjectTicketRequest;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Ticket;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class ProjectBacklogController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CreateProjectTicket $createProjectTicket,
|
|
||||||
private readonly PermissionService $permissionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(Request $request, Project $project): Response
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
abort_unless(
|
|
||||||
$this->permissionService->allows($user, Permission::ProjectRead, $project),
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Inertia::render('projects/Backlog', [
|
|
||||||
'backlogTicketStatuses' => $project->ticketStatuses()
|
|
||||||
->whereNull('board_column_id')
|
|
||||||
->get(),
|
|
||||||
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
|
||||||
'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
|
||||||
'project' => $project,
|
|
||||||
'status' => session('status'),
|
|
||||||
'ticketStatusOptions' => $project->ticketStatuses()
|
|
||||||
->with('boardColumn')
|
|
||||||
->get(),
|
|
||||||
'ticketPriorities' => $project->ticketPriorities()->get(),
|
|
||||||
'ticketTypes' => $project->ticketTypes()->get(),
|
|
||||||
'tickets' => Ticket::query()
|
|
||||||
->with([
|
|
||||||
'ticketPriority',
|
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
|
||||||
'ticketStatus.boardColumn',
|
|
||||||
'ticketType',
|
|
||||||
])
|
|
||||||
->where('project_id', $project->id)
|
|
||||||
->whereHas('ticketStatus', fn ($query) => $query->whereNull('board_column_id'))
|
|
||||||
->orderByDesc('sequence_number')
|
|
||||||
->get(),
|
|
||||||
'users' => User::query()
|
|
||||||
->orderBy('name')
|
|
||||||
->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
$validated = $request->validated();
|
|
||||||
|
|
||||||
abort_unless(
|
|
||||||
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
|
|
||||||
$ticket = $this->createProjectTicket->handle($project, $validated, $user);
|
|
||||||
|
|
||||||
return to_route('projects.backlog.index', $project)
|
|
||||||
->with('status', "{$ticket->ticket_key} was added to the backlog.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Projects\BuildProjectBoardPageProps;
|
||||||
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Http\Requests\MoveProjectTicketRequest;
|
use App\Http\Requests\MoveProjectTicketRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -18,6 +20,7 @@ use Inertia\Response;
|
|||||||
class ProjectBoardController extends Controller
|
class ProjectBoardController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly BuildProjectBoardPageProps $buildProjectBoardPageProps,
|
||||||
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
) {}
|
) {}
|
||||||
@@ -31,23 +34,12 @@ class ProjectBoardController extends Controller
|
|||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Inertia::render('projects/Board', [
|
return Inertia::render('projects/Board', $this->buildProjectBoardPageProps->handle(
|
||||||
'boardColumns' => $project->boardColumns()
|
$project,
|
||||||
->with([
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
'ticketStatuses',
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
'tickets' => fn ($query) => $query
|
session('status'),
|
||||||
->with([
|
));
|
||||||
'ticketPriority',
|
|
||||||
'ticketStatus.allowedTargetStatuses.boardColumn',
|
|
||||||
'ticketStatus.boardColumn',
|
|
||||||
'ticketType',
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
->get(),
|
|
||||||
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
|
||||||
'project' => $project,
|
|
||||||
'status' => session('status'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function move(
|
public function move(
|
||||||
@@ -90,7 +82,7 @@ class ProjectBoardController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket->fresh(),
|
$ticket->fresh(),
|
||||||
$user,
|
$user,
|
||||||
'status_changed',
|
TicketWorklogEntryType::StatusChanged,
|
||||||
"Moved from {$originLabel} to {$destinationLabel}.",
|
"Moved from {$originLabel} to {$destinationLabel}.",
|
||||||
[
|
[
|
||||||
'from' => [
|
'from' => [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Projects\BuildProjectBacklogPageProps;
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Http\Requests\DeleteProjectRequest;
|
use App\Http\Requests\DeleteProjectRequest;
|
||||||
@@ -20,6 +21,7 @@ use Inertia\Response;
|
|||||||
class ProjectController extends Controller
|
class ProjectController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly BuildProjectBacklogPageProps $buildProjectBacklogPageProps,
|
||||||
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
) {}
|
) {}
|
||||||
@@ -85,6 +87,23 @@ class ProjectController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function backlog(Request $request, Project $project): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$this->permissionService->allows($user, Permission::ProjectRead, $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Inertia::render('projects/Backlog', $this->buildProjectBacklogPageProps->handle(
|
||||||
|
$project,
|
||||||
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
session('status'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
public function general(Request $request, Project $project): Response
|
public function general(Request $request, Project $project): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('projects/settings/General', [
|
return Inertia::render('projects/settings/General', [
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class ProjectRepositorySettingsController extends Controller
|
|||||||
$error = null;
|
$error = null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$repositories = [];
|
$repositories = [];
|
||||||
$error = 'KanbanGit could not load repositories from this Git connection right now.';
|
$error = ((string) config('app.name')).' could not load repositories from this Git connection right now.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -73,10 +73,8 @@ class ProjectTicketBranchController extends Controller
|
|||||||
$this->gitProviderManager
|
$this->gitProviderManager
|
||||||
->client($userGitConnection->provider)
|
->client($userGitConnection->provider)
|
||||||
->listBranches(
|
->listBranches(
|
||||||
$userGitConnection->base_url,
|
|
||||||
$userGitConnection->api_token,
|
$userGitConnection->api_token,
|
||||||
$repositoryConnection->owner_name,
|
$repositoryConnection->toGitRepository(),
|
||||||
$repositoryConnection->repository_name,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
->contains(
|
->contains(
|
||||||
@@ -85,7 +83,7 @@ class ProjectTicketBranchController extends Controller
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'is_valid' => false,
|
'is_valid' => false,
|
||||||
'message' => 'KanbanGit could not validate this branch name right now.',
|
'message' => ((string) config('app.name')).' could not validate this branch name right now.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,10 +145,8 @@ class ProjectTicketBranchController extends Controller
|
|||||||
try {
|
try {
|
||||||
$branchAlreadyExists = collect(
|
$branchAlreadyExists = collect(
|
||||||
$gitProvider->listBranches(
|
$gitProvider->listBranches(
|
||||||
$userGitConnection->base_url,
|
|
||||||
$userGitConnection->api_token,
|
$userGitConnection->api_token,
|
||||||
$repositoryConnection->owner_name,
|
$repositoryConnection->toGitRepository(),
|
||||||
$repositoryConnection->repository_name,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
->contains(
|
->contains(
|
||||||
@@ -158,7 +154,7 @@ class ProjectTicketBranchController extends Controller
|
|||||||
);
|
);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'branch_name' => 'KanbanGit could not validate this branch name right now.',
|
'branch_name' => ((string) config('app.name')).' could not validate this branch name right now.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +182,7 @@ class ProjectTicketBranchController extends Controller
|
|||||||
);
|
);
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'branch_name' => 'KanbanGit could not create this branch right now.',
|
'branch_name' => ((string) config('app.name')).' could not create this branch right now.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ use App\Actions\Git\BuildSuggestedTicketBranchName;
|
|||||||
use App\Actions\Git\ResolveTicketBranchMatches;
|
use App\Actions\Git\ResolveTicketBranchMatches;
|
||||||
use App\Actions\Git\ResolveTicketPullRequestMatches;
|
use App\Actions\Git\ResolveTicketPullRequestMatches;
|
||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
|
use App\Actions\Tickets\BuildProjectTicketUpdateAttributes;
|
||||||
|
use App\Actions\Tickets\CreateProjectTicket;
|
||||||
use App\Actions\Tickets\UpdateProjectTicket;
|
use App\Actions\Tickets\UpdateProjectTicket;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Http\Requests\StoreProjectTicketRequest;
|
||||||
use App\Http\Requests\UpdateProjectTicketRequest;
|
use App\Http\Requests\UpdateProjectTicketRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -22,13 +25,68 @@ use Inertia\Response;
|
|||||||
class ProjectTicketController extends Controller
|
class ProjectTicketController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly BuildProjectTicketUpdateAttributes $buildProjectTicketUpdateAttributes,
|
||||||
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
|
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
|
||||||
|
private readonly CreateProjectTicket $createProjectTicket,
|
||||||
private readonly PermissionService $permissionService,
|
private readonly PermissionService $permissionService,
|
||||||
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
|
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
|
||||||
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
|
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
|
||||||
private readonly UpdateProjectTicket $updateProjectTicket,
|
private readonly UpdateProjectTicket $updateProjectTicket,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function create(Request $request, Project $project): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = Arr::get($request->query(), 'source') === 'board'
|
||||||
|
? 'board'
|
||||||
|
: 'backlog';
|
||||||
|
$defaultTicketStatus = $source === 'board'
|
||||||
|
? $project->ticketStatuses()->whereNotNull('board_column_id')->first()
|
||||||
|
: $project->ticketStatuses()->whereNull('board_column_id')->first();
|
||||||
|
|
||||||
|
$defaultTicketStatus ??= $project->ticketStatuses()->first();
|
||||||
|
|
||||||
|
return Inertia::render('projects/TicketCreate', [
|
||||||
|
'defaultTicketStatusId' => $defaultTicketStatus?->id,
|
||||||
|
'project' => $project,
|
||||||
|
'source' => $source,
|
||||||
|
'status' => session('status'),
|
||||||
|
'ticketPriorities' => $project->ticketPriorities()->get(),
|
||||||
|
'ticketStatusOptions' => $project->ticketStatuses()
|
||||||
|
->with('boardColumn')
|
||||||
|
->get(),
|
||||||
|
'ticketTypes' => $project->ticketTypes()->get(),
|
||||||
|
'users' => User::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreProjectTicketRequest $request, Project $project): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
abort_unless(
|
||||||
|
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
$ticket = $this->createProjectTicket->handle(
|
||||||
|
$project,
|
||||||
|
$request->validated(),
|
||||||
|
$user,
|
||||||
|
);
|
||||||
|
|
||||||
|
return to_route('projects.tickets.show', [$project, $ticket])
|
||||||
|
->with('status', "{$ticket->ticket_key} was created.");
|
||||||
|
}
|
||||||
|
|
||||||
public function show(Request $request, Project $project, Ticket $ticket): Response
|
public function show(Request $request, Project $project, Ticket $ticket): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -40,7 +98,6 @@ class ProjectTicketController extends Controller
|
|||||||
|
|
||||||
$canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project);
|
$canEditTicket = $this->permissionService->allows($user, Permission::ProjectWrite, $project);
|
||||||
$canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project);
|
$canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project);
|
||||||
$excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)];
|
|
||||||
$displayTicket = $ticket->load([
|
$displayTicket = $ticket->load([
|
||||||
'assignee',
|
'assignee',
|
||||||
'author',
|
'author',
|
||||||
@@ -65,7 +122,7 @@ class ProjectTicketController extends Controller
|
|||||||
'linkedTickets' => $this->linkedTickets($ticket),
|
'linkedTickets' => $this->linkedTickets($ticket),
|
||||||
'parentTicketOptions' => Ticket::query()
|
'parentTicketOptions' => Ticket::query()
|
||||||
->where('project_id', $project->id)
|
->where('project_id', $project->id)
|
||||||
->whereNotIn('id', $excludedParentIds)
|
->whereKeyNot($ticket->id)
|
||||||
->whereHas('ticketType', fn ($query) => $query->where('is_parent', true))
|
->whereHas('ticketType', fn ($query) => $query->where('is_parent', true))
|
||||||
->orderByDesc('sequence_number')
|
->orderByDesc('sequence_number')
|
||||||
->get()
|
->get()
|
||||||
@@ -122,25 +179,7 @@ class ProjectTicketController extends Controller
|
|||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$fieldValues = Arr::get($validated, 'field_values', []);
|
$fieldValues = Arr::get($validated, 'field_values', []);
|
||||||
$ticketAttributes = [
|
$ticketAttributes = $this->buildProjectTicketUpdateAttributes->handle($ticket, $validated);
|
||||||
'acceptance_criteria' => Arr::get($validated, 'acceptance_criteria'),
|
|
||||||
'assignee_user_id' => Arr::exists($validated, 'assignee_user_id')
|
|
||||||
? Arr::get($validated, 'assignee_user_id')
|
|
||||||
: $ticket->assignee_user_id,
|
|
||||||
'author_user_id' => Arr::exists($validated, 'author_user_id')
|
|
||||||
? Arr::get($validated, 'author_user_id')
|
|
||||||
: $ticket->author_user_id,
|
|
||||||
'blocked_comment' => Arr::get($validated, 'blocked_comment'),
|
|
||||||
'description' => Arr::get($validated, 'description'),
|
|
||||||
'is_blocked' => Arr::get($validated, 'is_blocked', false),
|
|
||||||
'parent_ticket_id' => Arr::get($validated, 'parent_ticket_id'),
|
|
||||||
'story_points' => Arr::exists($validated, 'story_points')
|
|
||||||
? Arr::get($validated, 'story_points')
|
|
||||||
: $ticket->story_points,
|
|
||||||
'ticket_priority_id' => Arr::get($validated, 'ticket_priority_id'),
|
|
||||||
'ticket_type_id' => Arr::get($validated, 'ticket_type_id'),
|
|
||||||
'title' => Arr::get($validated, 'title'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$wasUpdated = $this->updateProjectTicket->handle(
|
$wasUpdated = $this->updateProjectTicket->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
@@ -181,20 +220,6 @@ class ProjectTicketController extends Controller
|
|||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, int>
|
|
||||||
*/
|
|
||||||
private function descendantIds(Ticket $ticket): array
|
|
||||||
{
|
|
||||||
return $ticket->childTickets()
|
|
||||||
->get()
|
|
||||||
->flatMap(function (Ticket $childTicket): array {
|
|
||||||
return [$childTicket->id, ...$this->descendantIds($childTicket)];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{
|
* @return array<int, array{
|
||||||
* created_at: string|null,
|
* created_at: string|null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Http\Requests\StoreTicketLinkRequest;
|
use App\Http\Requests\StoreTicketLinkRequest;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -59,7 +60,7 @@ class ProjectTicketLinkController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
'link_updated',
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
|
"Linked {$linkedTicket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
@@ -70,7 +71,7 @@ class ProjectTicketLinkController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
'link_updated',
|
TicketWorklogEntryType::LinkUpdated,
|
||||||
"Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
|
"Linked {$ticket->ticket_key} as {$ticketLink->ticketLinkType->label}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
@@ -117,7 +118,7 @@ class ProjectTicketLinkController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket,
|
$ticket,
|
||||||
$user,
|
$user,
|
||||||
'link_removed',
|
TicketWorklogEntryType::LinkRemoved,
|
||||||
"Removed {$label} link to {$linkedTicket->ticket_key}.",
|
"Removed {$label} link to {$linkedTicket->ticket_key}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||||
@@ -128,7 +129,7 @@ class ProjectTicketLinkController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$linkedTicket,
|
$linkedTicket,
|
||||||
$user,
|
$user,
|
||||||
'link_removed',
|
TicketWorklogEntryType::LinkRemoved,
|
||||||
"Removed {$label} link to {$ticket->ticket_key}.",
|
"Removed {$label} link to {$ticket->ticket_key}.",
|
||||||
[
|
[
|
||||||
'linked_ticket_key' => $ticket->ticket_key,
|
'linked_ticket_key' => $ticket->ticket_key,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Actions\Permissions\PermissionService;
|
use App\Actions\Permissions\PermissionService;
|
||||||
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
use App\Actions\Tickets\CreateTicketWorklogEntry;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Http\Requests\DestroyProjectTicketTypeRequest;
|
use App\Http\Requests\DestroyProjectTicketTypeRequest;
|
||||||
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
use App\Http\Requests\StoreProjectNamedSettingRequest;
|
||||||
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
use App\Http\Requests\StoreProjectTicketTypeRequest;
|
||||||
@@ -158,7 +159,7 @@ class ProjectTicketMetadataSettingsController extends Controller
|
|||||||
$this->createTicketWorklogEntry->handle(
|
$this->createTicketWorklogEntry->handle(
|
||||||
$ticket->fresh(),
|
$ticket->fresh(),
|
||||||
$request->user(),
|
$request->user(),
|
||||||
'updated',
|
TicketWorklogEntryType::Updated,
|
||||||
"Updated type because {$deletedName} type was deleted.",
|
"Updated type because {$deletedName} type was deleted.",
|
||||||
[
|
[
|
||||||
'ticket_type' => [
|
'ticket_type' => [
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class GitConnectionController extends Controller
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return back()
|
return back()
|
||||||
->withErrors([
|
->withErrors([
|
||||||
'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.',
|
'api_token' => ((string) config('app.name')).' could not authenticate against Gitea with this base URL and token.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ class GitConnectionController extends Controller
|
|||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
return back()
|
return back()
|
||||||
->withErrors([
|
->withErrors([
|
||||||
'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.',
|
'api_token' => ((string) config('app.name')).' could not authenticate against Gitea with this base URL and token.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ class StoreProjectTicketRequest extends FormRequest
|
|||||||
'ticket_status_id' => [
|
'ticket_status_id' => [
|
||||||
'required',
|
'required',
|
||||||
'integer',
|
'integer',
|
||||||
Rule::exists('ticket_statuses', 'id')
|
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id),
|
||||||
->where(fn ($query) => $query
|
|
||||||
->where('project_id', $project->id)
|
|
||||||
->whereNull('board_column_id')),
|
|
||||||
],
|
],
|
||||||
'ticket_type_id' => [
|
'ticket_type_id' => [
|
||||||
'required',
|
'required',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderKey;
|
use App\Actions\Git\GitProviderKey;
|
||||||
use Database\Factories\RepositoryConnectionFactory;
|
use Database\Factories\RepositoryConnectionFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -38,4 +39,18 @@ class RepositoryConnection extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Project::class);
|
return $this->belongsTo(Project::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toGitRepository(): GitRepository
|
||||||
|
{
|
||||||
|
return new GitRepository(
|
||||||
|
baseUrl: $this->base_url,
|
||||||
|
providerRepositoryId: $this->provider_repository_id,
|
||||||
|
owner: $this->owner_name,
|
||||||
|
name: $this->repository_name,
|
||||||
|
fullName: $this->repository_full_name,
|
||||||
|
htmlUrl: $this->repository_url,
|
||||||
|
defaultBranch: null,
|
||||||
|
isPrivate: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use Database\Factories\TicketWorklogEntryFactory;
|
use Database\Factories\TicketWorklogEntryFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -27,6 +28,7 @@ class TicketWorklogEntry extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'changes' => 'array',
|
'changes' => 'array',
|
||||||
|
'entry_type' => TicketWorklogEntryType::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin));
|
Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin));
|
||||||
Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate));
|
Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate));
|
||||||
Gate::define('create-team', fn (User $user): bool => $permissionService->allows($user, Permission::TeamCreate));
|
Gate::define('create-team', fn (User $user): bool => $permissionService->allows($user, Permission::TeamCreate));
|
||||||
|
Gate::define('admin-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectAdmin, $project));
|
||||||
|
Gate::define('view-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectRead, $project));
|
||||||
|
Gate::define('write-project', fn (User $user, Project $project): bool => $permissionService->allows($user, Permission::ProjectWrite, $project));
|
||||||
Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user));
|
Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user));
|
||||||
Gate::define('view-teams', fn (User $user): bool => Gate::forUser($user)->any(['create-team', 'manage-teams']));
|
Gate::define('view-teams', fn (User $user): bool => Gate::forUser($user)->any(['create-team', 'manage-teams']));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -25,7 +26,7 @@ class TicketWorklogEntryFactory extends Factory
|
|||||||
'from' => 'Backlog',
|
'from' => 'Backlog',
|
||||||
'to' => 'In Progress',
|
'to' => 'In Progress',
|
||||||
],
|
],
|
||||||
'entry_type' => 'status_changed',
|
'entry_type' => TicketWorklogEntryType::StatusChanged,
|
||||||
'summary' => 'Moved from Backlog to In Progress.',
|
'summary' => 'Moved from Backlog to In Progress.',
|
||||||
'ticket_id' => Ticket::factory(),
|
'ticket_id' => Ticket::factory(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -15,3 +15,23 @@
|
|||||||
- ziel ist einheitlicherere worklog struktur
|
- ziel ist einheitlicherere worklog struktur
|
||||||
- falls sinnvoll, sonst verwerfen
|
- falls sinnvoll, sonst verwerfen
|
||||||
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
|
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
|
||||||
|
|
||||||
|
## Umsetzung 2026-04-15
|
||||||
|
|
||||||
|
### Umgesetzt
|
||||||
|
|
||||||
|
- (mehr über middlewares steuern)
|
||||||
|
- mehrere actions/quereys in services/actions auslagern
|
||||||
|
- Git listBranches replace baseUrl, owner, rperoName with GItReprository
|
||||||
|
- Git prs replace baseUrl, owner, rperoName with GItReprository
|
||||||
|
- replace Kanbangit with env App Name
|
||||||
|
- change Project Backlog Controller → Project Controller
|
||||||
|
- remove store ticket from backlog -> create ticket create view.
|
||||||
|
- always show create on board and backlog -> rediurect to ticket create
|
||||||
|
- ggf worklog überarbeiten? → type als worklog enum
|
||||||
|
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
|
||||||
|
|
||||||
|
### Nicht umgesetzt
|
||||||
|
|
||||||
|
- repository_connections check base Url maybe unnessecary? - `base_url` bleibt nötig, um Self-hosted-Git-Instanzen und passende User-Credentials eindeutig zuzuordnen.
|
||||||
|
- handle backlog as a normal column - Backlog bleibt bewusst ein Status ohne `board_column_id`; `/backlog` und `/board` sind nur getrennte Views.
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link } from '@inertiajs/vue3';
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
import ProjectBacklogController from '@/actions/App/Http/Controllers/ProjectBacklogController';
|
|
||||||
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.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 {
|
import {
|
||||||
@@ -13,14 +11,11 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} 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 as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { index as backlogIndex } from '@/routes/projects/backlog';
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
import { index, show } from '@/routes/projects/index';
|
import { index, show } from '@/routes/projects/index';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
|
||||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
|
||||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
@@ -42,11 +37,6 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasTicketMetadata =
|
|
||||||
props.ticketTypes.length > 0 &&
|
|
||||||
props.ticketPriorities.length > 0 &&
|
|
||||||
props.backlogTicketStatuses.length > 0;
|
|
||||||
|
|
||||||
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => {
|
||||||
if (ticketStatus === null) {
|
if (ticketStatus === null) {
|
||||||
return 'Unknown status';
|
return 'Unknown status';
|
||||||
@@ -92,6 +82,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button v-if="canCreateTickets" as-child>
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
createTicket(
|
||||||
|
{ project: project.id },
|
||||||
|
{ query: { source: 'backlog' } },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Create ticket
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="boardIndex(project.id)">Open board</Link>
|
<Link :href="boardIndex(project.id)">Open board</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,197 +112,6 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
{{ status }}
|
{{ status }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card v-if="canCreateTickets && hasTicketMetadata">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Create ticket</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
New tickets receive the next immutable project key and
|
|
||||||
enter the selected backlog status.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<Form
|
|
||||||
v-bind="ProjectBacklogController.store.form(project.id)"
|
|
||||||
class="grid gap-6"
|
|
||||||
v-slot="{ errors, processing }"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="title">Title</Label>
|
|
||||||
<Input
|
|
||||||
id="title"
|
|
||||||
name="title"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="Implement repository sync"
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.title" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<div class="flex-4/5 grid gap-2 md:gap-6">
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="description">Description</Label>
|
|
||||||
<textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
rows="4"
|
|
||||||
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]"
|
|
||||||
placeholder="Describe the expected behavior and relevant context."
|
|
||||||
/>
|
|
||||||
<InputError :message="errors.description" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="acceptance_criteria">
|
|
||||||
Acceptance criteria
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
id="acceptance_criteria"
|
|
||||||
name="acceptance_criteria"
|
|
||||||
rows="4"
|
|
||||||
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]"
|
|
||||||
placeholder="List the conditions that must be true before this ticket is done."
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.acceptance_criteria"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid h-fit flex-1 gap-2 md:ml-4 md:gap-6"
|
|
||||||
>
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_type_id">Type</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_type_id"
|
|
||||||
name="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]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketType in ticketTypes"
|
|
||||||
:key="ticketType.id"
|
|
||||||
:value="ticketType.id"
|
|
||||||
>
|
|
||||||
{{ ticketType.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="errors.ticket_type_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_priority_id">
|
|
||||||
Priority
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_priority_id"
|
|
||||||
name="ticket_priority_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]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketPriority in ticketPriorities"
|
|
||||||
:key="ticketPriority.id"
|
|
||||||
:value="ticketPriority.id"
|
|
||||||
>
|
|
||||||
{{ ticketPriority.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="errors.ticket_priority_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="ticket_status_id">
|
|
||||||
Status
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="ticket_status_id"
|
|
||||||
name="ticket_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="ticketStatus in backlogTicketStatuses"
|
|
||||||
:key="ticketStatus.id"
|
|
||||||
:value="ticketStatus.id"
|
|
||||||
>
|
|
||||||
{{ ticketStatus.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="errors.ticket_status_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
|
||||||
<Label for="assignee_user_id">
|
|
||||||
Assignee
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="assignee_user_id"
|
|
||||||
name="assignee_user_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]"
|
|
||||||
>
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
<option
|
|
||||||
v-for="user in users"
|
|
||||||
:key="user.id"
|
|
||||||
:value="user.id"
|
|
||||||
>
|
|
||||||
{{ user.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<InputError
|
|
||||||
:message="errors.assignee_user_id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid gap-2"
|
|
||||||
v-if="project.show_story_points"
|
|
||||||
>
|
|
||||||
<Label for="story_points">
|
|
||||||
Estimated story points
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="story_points"
|
|
||||||
name="story_points"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
/>
|
|
||||||
<InputError
|
|
||||||
:message="errors.story_points"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button type="submit" :disabled="processing">
|
|
||||||
Create ticket
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-else-if="canCreateTickets">
|
|
||||||
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Ticket creation needs at least one type and one
|
|
||||||
priority, plus one backlog status.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button as-child variant="outline">
|
|
||||||
<Link :href="metadataSettings(project.id)">
|
|
||||||
Open ticket settings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="gap-0 overflow-hidden">
|
<Card class="gap-0 overflow-hidden">
|
||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Backlog tickets</CardTitle>
|
<CardTitle>Backlog tickets</CardTitle>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
|
|||||||
import { index as projectIndex, show } from '@/routes/projects/index';
|
import { index as projectIndex, show } from '@/routes/projects/index';
|
||||||
import { board as boardSettings } from '@/routes/projects/settings';
|
import { board as boardSettings } from '@/routes/projects/settings';
|
||||||
import { index as boardIndex } from '@/routes/projects/board';
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
import { show as showTicket } from '@/routes/projects/tickets';
|
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
|
||||||
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
|
||||||
import type { TicketModel } from '@/types/models/ticket-model';
|
import type { TicketModel } from '@/types/models/ticket-model';
|
||||||
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
@@ -83,6 +83,19 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button v-if="canCreateTickets" as-child>
|
||||||
|
<Link
|
||||||
|
:href="
|
||||||
|
createTicket(
|
||||||
|
{ project: project.id },
|
||||||
|
{ query: { source: 'board' } },
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Create ticket
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button as-child variant="outline">
|
<Button as-child variant="outline">
|
||||||
<Link :href="backlogIndex(project.id)"
|
<Link :href="backlogIndex(project.id)"
|
||||||
>Open backlog</Link
|
>Open backlog</Link
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
|
import { Form, useForm, Head, Link, usePage } from '@inertiajs/vue3';
|
||||||
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
|
||||||
@@ -38,6 +38,8 @@ import type { RepositoryConnectionModel } from '@/types/models/repository-connec
|
|||||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectTicketPageProps>();
|
const props = defineProps<ProjectTicketPageProps>();
|
||||||
|
const page = usePage();
|
||||||
|
const appName = String(page.props.name);
|
||||||
|
|
||||||
const visibleRepositoryBranches = computed(() =>
|
const visibleRepositoryBranches = computed(() =>
|
||||||
props.repositoryBranches.filter(
|
props.repositoryBranches.filter(
|
||||||
@@ -270,7 +272,7 @@ watch(
|
|||||||
branchNameValidation.value = {
|
branchNameValidation.value = {
|
||||||
is_valid: false,
|
is_valid: false,
|
||||||
message:
|
message:
|
||||||
'KanbanGit could not validate this branch name right now.',
|
`${appName} could not validate this branch name right now.`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Form, Head, Link } from '@inertiajs/vue3';
|
||||||
|
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||||
|
import Heading from '@/components/Heading.vue';
|
||||||
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||||
|
import { index as boardIndex } from '@/routes/projects/board';
|
||||||
|
import { index, show } from '@/routes/projects/index';
|
||||||
|
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||||
|
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
|
||||||
|
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
|
||||||
|
|
||||||
|
const props = defineProps<ProjectTicketCreatePageProps>();
|
||||||
|
|
||||||
|
const returnHref =
|
||||||
|
props.source === 'board'
|
||||||
|
? boardIndex(props.project.id)
|
||||||
|
: backlogIndex(props.project.id);
|
||||||
|
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog';
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Projects',
|
||||||
|
href: index(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.project.name,
|
||||||
|
href: show(props.project.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: props.source === 'board' ? 'Board' : 'Backlog',
|
||||||
|
href: returnHref,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Create ticket',
|
||||||
|
href: ProjectTicketController.create({
|
||||||
|
project: props.project.id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasTicketMetadata =
|
||||||
|
props.ticketTypes.length > 0 &&
|
||||||
|
props.ticketPriorities.length > 0 &&
|
||||||
|
props.ticketStatusOptions.length > 0;
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<Head :title="`Create ${project.name} ticket`" />
|
||||||
|
|
||||||
|
<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="Create ticket"
|
||||||
|
:description="`Add a ticket to ${project.name}.`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="returnHref">{{ returnLabel }}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="show(project.id)">Back to project</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card v-if="hasTicketMetadata">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ticket details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The ticket key is assigned after creation.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Form
|
||||||
|
v-bind="ProjectTicketController.store.form(project.id)"
|
||||||
|
class="grid gap-6"
|
||||||
|
v-slot="{ errors, processing }"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="Implement repository sync"
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="4"
|
||||||
|
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]"
|
||||||
|
placeholder="Describe the expected behavior and relevant context."
|
||||||
|
/>
|
||||||
|
<InputError :message="errors.description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="acceptance_criteria">
|
||||||
|
Acceptance criteria
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="acceptance_criteria"
|
||||||
|
name="acceptance_criteria"
|
||||||
|
rows="4"
|
||||||
|
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]"
|
||||||
|
placeholder="List the conditions that must be true before this ticket is done."
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="errors.acceptance_criteria"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid h-fit gap-6">
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="ticket_type_id">Type</Label>
|
||||||
|
<select
|
||||||
|
id="ticket_type_id"
|
||||||
|
name="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]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="ticketType in ticketTypes"
|
||||||
|
:key="ticketType.id"
|
||||||
|
:value="ticketType.id"
|
||||||
|
>
|
||||||
|
{{ ticketType.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.ticket_type_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="ticket_priority_id">
|
||||||
|
Priority
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="ticket_priority_id"
|
||||||
|
name="ticket_priority_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]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="ticketPriority in ticketPriorities"
|
||||||
|
:key="ticketPriority.id"
|
||||||
|
:value="ticketPriority.id"
|
||||||
|
>
|
||||||
|
{{ ticketPriority.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.ticket_priority_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="ticket_status_id">Status</Label>
|
||||||
|
<select
|
||||||
|
id="ticket_status_id"
|
||||||
|
name="ticket_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 transition-[color,box-shadow] focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="ticketStatus in ticketStatusOptions"
|
||||||
|
:key="ticketStatus.id"
|
||||||
|
:value="ticketStatus.id"
|
||||||
|
:selected="
|
||||||
|
ticketStatus.id ===
|
||||||
|
defaultTicketStatusId
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ ticketStatusLabel(ticketStatus) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.ticket_status_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="assignee_user_id">
|
||||||
|
Assignee
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="assignee_user_id"
|
||||||
|
name="assignee_user_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]"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
<option
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
>
|
||||||
|
{{ user.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<InputError
|
||||||
|
:message="errors.assignee_user_id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="project.show_story_points"
|
||||||
|
class="grid gap-2"
|
||||||
|
>
|
||||||
|
<Label for="story_points">
|
||||||
|
Estimated story points
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="story_points"
|
||||||
|
name="story_points"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
:message="errors.story_points"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" :disabled="processing">
|
||||||
|
Create ticket
|
||||||
|
</Button>
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="returnHref">
|
||||||
|
{{ returnLabel }}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else>
|
||||||
|
<CardContent class="flex flex-col gap-4 p-6 text-sm">
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Ticket creation needs at least one type, one priority,
|
||||||
|
and one status.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<Link :href="metadataSettings(project.id)">
|
||||||
|
Open ticket settings
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
@@ -31,6 +31,8 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
const props = defineProps<ProjectRepositorySettingsPageProps>();
|
const props = defineProps<ProjectRepositorySettingsPageProps>();
|
||||||
|
const page = usePage();
|
||||||
|
const appName = String(page.props.name);
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
@@ -285,7 +287,7 @@ const submitUpdate = (): void => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Without a matching personal connection, KanbanGit only
|
Without a matching personal connection, {{ appName }} only
|
||||||
shows the repository name and links you to your user
|
shows the repository name and links you to your user
|
||||||
settings.
|
settings.
|
||||||
</p>
|
</p>
|
||||||
@@ -612,7 +614,7 @@ const submitUpdate = (): void => {
|
|||||||
>
|
>
|
||||||
This project link is visible, but you need a
|
This project link is visible, but you need a
|
||||||
matching Git connection in your user
|
matching Git connection in your user
|
||||||
settings before KanbanGit can open or query
|
settings before {{ appName }} can open or query
|
||||||
this repository for you.
|
this repository for you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -648,7 +650,7 @@ const submitUpdate = (): void => {
|
|||||||
<CardHeader class="border-b">
|
<CardHeader class="border-b">
|
||||||
<CardTitle>Detected personal sources</CardTitle>
|
<CardTitle>Detected personal sources</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
KanbanGit loads repositories from your personal
|
{{ appName }} loads repositories from your personal
|
||||||
Git connections and offers them for project
|
Git connections and offers them for project
|
||||||
linking.
|
linking.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head } from '@inertiajs/vue3';
|
import { Form, Head, usePage } from '@inertiajs/vue3';
|
||||||
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
import GitConnectionController from '@/actions/App/Http/Controllers/Settings/GitConnectionController';
|
||||||
import Heading from '@/components/Heading.vue';
|
import Heading from '@/components/Heading.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
@@ -21,6 +21,9 @@ import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
|
|||||||
|
|
||||||
defineProps<SettingsGitConnectionsPageProps>();
|
defineProps<SettingsGitConnectionsPageProps>();
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const appName = String(page.props.name);
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const breadcrumbItems: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Git connections',
|
title: 'Git connections',
|
||||||
@@ -43,7 +46,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
|
|||||||
/>
|
/>
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
Each user needs their own application key. Without a
|
Each user needs their own application key. Without a
|
||||||
matching personal connection, KanbanGit can only show
|
matching personal connection, {{ appName }} can only show
|
||||||
repository names and will link you back to this page.
|
repository names and will link you back to this page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import type { UserModel } from '@/types/models/user-model';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
|
export type TicketWorklogEntryType =
|
||||||
|
| 'created'
|
||||||
|
| 'link_removed'
|
||||||
|
| 'link_updated'
|
||||||
|
| 'status_changed'
|
||||||
|
| 'updated';
|
||||||
|
|
||||||
export type TicketWorklogEntryModel = {
|
export type TicketWorklogEntryModel = {
|
||||||
id: number;
|
id: number;
|
||||||
actor: UserModel | null;
|
actor: UserModel | null;
|
||||||
actor_id: number | null;
|
actor_id: number | null;
|
||||||
changes: Record<string, unknown> | null;
|
changes: Record<string, unknown> | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
entry_type: string;
|
entry_type: TicketWorklogEntryType;
|
||||||
summary: string;
|
summary: string;
|
||||||
ticket_id: number;
|
ticket_id: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export type ProjectGeneralSettingsPageProps = {
|
|||||||
|
|
||||||
export type ProjectBoardPageProps = {
|
export type ProjectBoardPageProps = {
|
||||||
boardColumns: BoardColumnModel[];
|
boardColumns: BoardColumnModel[];
|
||||||
|
canCreateTickets: boolean;
|
||||||
canEditTickets: boolean;
|
canEditTickets: boolean;
|
||||||
project: ProjectModel;
|
project: ProjectModel;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
@@ -114,6 +115,17 @@ export type ProjectBacklogPageProps = {
|
|||||||
users: UserModel[];
|
users: UserModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectTicketCreatePageProps = {
|
||||||
|
defaultTicketStatusId: number | null;
|
||||||
|
project: ProjectModel;
|
||||||
|
source: 'backlog' | 'board';
|
||||||
|
status?: string | null;
|
||||||
|
ticketPriorities: TicketPriorityModel[];
|
||||||
|
ticketStatusOptions: TicketStatusModel[];
|
||||||
|
ticketTypes: TicketTypeModel[];
|
||||||
|
users: UserModel[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectTicketPageProps = {
|
export type ProjectTicketPageProps = {
|
||||||
canEditTicket: boolean;
|
canEditTicket: boolean;
|
||||||
canManageProject: boolean;
|
canManageProject: boolean;
|
||||||
|
|||||||
+58
-8
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\UserManagementController;
|
use App\Http\Controllers\Admin\UserManagementController;
|
||||||
use App\Http\Controllers\ProjectBacklogController;
|
|
||||||
use App\Http\Controllers\ProjectBoardController;
|
use App\Http\Controllers\ProjectBoardController;
|
||||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
@@ -21,96 +20,147 @@ Route::inertia('/', 'Welcome', [
|
|||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
||||||
|
|
||||||
Route::resource('projects', ProjectController::class)
|
Route::get('projects', [ProjectController::class, 'index'])
|
||||||
->only(['index', 'create', 'store', 'show']);
|
->name('projects.index');
|
||||||
|
Route::get('projects/create', [ProjectController::class, 'create'])
|
||||||
|
->middleware('can:create-project')
|
||||||
|
->name('projects.create');
|
||||||
|
Route::post('projects', [ProjectController::class, 'store'])
|
||||||
|
->middleware('can:create-project')
|
||||||
|
->name('projects.store');
|
||||||
|
Route::get('projects/{project}', [ProjectController::class, 'show'])
|
||||||
|
->middleware('can:view-project,project')
|
||||||
|
->name('projects.show');
|
||||||
|
|
||||||
Route::delete('projects/{project}', [ProjectController::class, 'destroy'])
|
Route::delete('projects/{project}', [ProjectController::class, 'destroy'])
|
||||||
->middleware('can:access-admin')
|
->middleware('can:access-admin')
|
||||||
->name('projects.destroy');
|
->name('projects.destroy');
|
||||||
|
|
||||||
Route::get('projects/{project}/access', [ProjectController::class, 'access'])
|
Route::get('projects/{project}/access', [ProjectController::class, 'access'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.access');
|
->name('projects.access');
|
||||||
Route::patch('projects/{project}/permissions/users/{user}', [ProjectController::class, 'updateUserPermissions'])
|
Route::patch('projects/{project}/permissions/users/{user}', [ProjectController::class, 'updateUserPermissions'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.permissions.users.update');
|
->name('projects.permissions.users.update');
|
||||||
Route::patch('projects/{project}/permissions/teams/{team}', [ProjectController::class, 'updateTeamPermissions'])
|
Route::patch('projects/{project}/permissions/teams/{team}', [ProjectController::class, 'updateTeamPermissions'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.permissions.teams.update');
|
->name('projects.permissions.teams.update');
|
||||||
Route::get('projects/{project}/backlog', [ProjectBacklogController::class, 'index'])
|
|
||||||
->name('projects.backlog.index');
|
|
||||||
Route::post('projects/{project}/backlog', [ProjectBacklogController::class, 'store'])
|
|
||||||
->name('projects.backlog.store');
|
|
||||||
|
|
||||||
Route::scopeBindings()->group(function (): void {
|
Route::scopeBindings()->group(function (): void {
|
||||||
Route::get('projects/{project}/board', [ProjectBoardController::class, 'index'])
|
Route::get('projects/{project}/board', [ProjectBoardController::class, 'index'])
|
||||||
|
->middleware('can:view-project,project')
|
||||||
->name('projects.board.index');
|
->name('projects.board.index');
|
||||||
|
Route::get('projects/{project}/backlog', [ProjectController::class, 'backlog'])
|
||||||
|
->middleware('can:view-project,project')
|
||||||
|
->name('projects.backlog.index');
|
||||||
|
Route::get('projects/{project}/tickets/create', [ProjectTicketController::class, 'create'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
|
->name('projects.tickets.create');
|
||||||
|
Route::post('projects/{project}/tickets', [ProjectTicketController::class, 'store'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
|
->name('projects.tickets.store');
|
||||||
Route::get('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'show'])
|
Route::get('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'show'])
|
||||||
|
->middleware('can:view-project,project')
|
||||||
->name('projects.tickets.show');
|
->name('projects.tickets.show');
|
||||||
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
|
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->name('projects.tickets.update');
|
->name('projects.tickets.update');
|
||||||
Route::get('projects/{project}/tickets/{ticket}/branches/validate', [ProjectTicketBranchController::class, 'validate'])
|
Route::get('projects/{project}/tickets/{ticket}/branches/validate', [ProjectTicketBranchController::class, 'validate'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->name('projects.tickets.branches.validate');
|
->name('projects.tickets.branches.validate');
|
||||||
Route::post('projects/{project}/tickets/{ticket}/branches', [ProjectTicketBranchController::class, 'store'])
|
Route::post('projects/{project}/tickets/{ticket}/branches', [ProjectTicketBranchController::class, 'store'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->name('projects.tickets.branches.store');
|
->name('projects.tickets.branches.store');
|
||||||
Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store'])
|
Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->name('projects.tickets.links.store');
|
->name('projects.tickets.links.store');
|
||||||
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
|
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->withoutScopedBindings()
|
->withoutScopedBindings()
|
||||||
->name('projects.tickets.links.destroy');
|
->name('projects.tickets.links.destroy');
|
||||||
Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move'])
|
Route::patch('projects/{project}/tickets/{ticket}/status', [ProjectBoardController::class, 'move'])
|
||||||
|
->middleware('can:write-project,project')
|
||||||
->name('projects.tickets.status.update');
|
->name('projects.tickets.status.update');
|
||||||
Route::get('projects/{project}/settings/general', [ProjectController::class, 'general'])
|
Route::get('projects/{project}/settings/general', [ProjectController::class, 'general'])
|
||||||
->middleware('can:access-admin')
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.general');
|
->name('projects.settings.general');
|
||||||
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
|
Route::get('projects/{project}/settings/board', [ProjectBoardSettingsController::class, 'show'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board');
|
->name('projects.settings.board');
|
||||||
Route::post('projects/{project}/settings/board/columns', [ProjectBoardSettingsController::class, 'store'])
|
Route::post('projects/{project}/settings/board/columns', [ProjectBoardSettingsController::class, 'store'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.columns.store');
|
->name('projects.settings.board.columns.store');
|
||||||
Route::patch('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'update'])
|
Route::patch('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'update'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.columns.update');
|
->name('projects.settings.board.columns.update');
|
||||||
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
|
Route::delete('projects/{project}/settings/board/columns/{boardColumn}', [ProjectBoardSettingsController::class, 'destroy'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.columns.destroy');
|
->name('projects.settings.board.columns.destroy');
|
||||||
Route::post('projects/{project}/settings/board/statuses', [ProjectBoardSettingsController::class, 'storeStatus'])
|
Route::post('projects/{project}/settings/board/statuses', [ProjectBoardSettingsController::class, 'storeStatus'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.statuses.store');
|
->name('projects.settings.board.statuses.store');
|
||||||
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'updateStatus'])
|
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'updateStatus'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.statuses.update');
|
->name('projects.settings.board.statuses.update');
|
||||||
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
|
Route::delete('projects/{project}/settings/board/statuses/{ticketStatus}', [ProjectBoardSettingsController::class, 'destroyStatus'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.statuses.destroy');
|
->name('projects.settings.board.statuses.destroy');
|
||||||
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}/transitions', [ProjectBoardSettingsController::class, 'updateStatusTransitions'])
|
Route::patch('projects/{project}/settings/board/statuses/{ticketStatus}/transitions', [ProjectBoardSettingsController::class, 'updateStatusTransitions'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.board.statuses.transitions.update');
|
->name('projects.settings.board.statuses.transitions.update');
|
||||||
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
|
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata');
|
->name('projects.settings.metadata');
|
||||||
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
|
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.repositories');
|
->name('projects.settings.repositories');
|
||||||
Route::post('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'store'])
|
Route::post('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'store'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.repositories.store');
|
->name('projects.settings.repositories.store');
|
||||||
Route::patch('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'update'])
|
Route::patch('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'update'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.repositories.update');
|
->name('projects.settings.repositories.update');
|
||||||
Route::delete('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'destroy'])
|
Route::delete('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'destroy'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.repositories.destroy');
|
->name('projects.settings.repositories.destroy');
|
||||||
Route::post('projects/{project}/settings/metadata/types', [ProjectTicketMetadataSettingsController::class, 'storeType'])
|
Route::post('projects/{project}/settings/metadata/types', [ProjectTicketMetadataSettingsController::class, 'storeType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.types.store');
|
->name('projects.settings.metadata.types.store');
|
||||||
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
|
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.types.update');
|
->name('projects.settings.metadata.types.update');
|
||||||
Route::patch('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'update'])
|
Route::patch('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'update'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.update');
|
->name('projects.settings.metadata.update');
|
||||||
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
|
Route::delete('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'destroyType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.types.destroy');
|
->name('projects.settings.metadata.types.destroy');
|
||||||
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])
|
Route::post('projects/{project}/settings/metadata/priorities', [ProjectTicketMetadataSettingsController::class, 'storePriority'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.priorities.store');
|
->name('projects.settings.metadata.priorities.store');
|
||||||
Route::patch('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'updatePriority'])
|
Route::patch('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'updatePriority'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.priorities.update');
|
->name('projects.settings.metadata.priorities.update');
|
||||||
Route::delete('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'destroyPriority'])
|
Route::delete('projects/{project}/settings/metadata/priorities/{ticketPriority}', [ProjectTicketMetadataSettingsController::class, 'destroyPriority'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.priorities.destroy');
|
->name('projects.settings.metadata.priorities.destroy');
|
||||||
Route::post('projects/{project}/settings/metadata/fields', [ProjectTicketMetadataSettingsController::class, 'storeFieldDefinition'])
|
Route::post('projects/{project}/settings/metadata/fields', [ProjectTicketMetadataSettingsController::class, 'storeFieldDefinition'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.fields.store');
|
->name('projects.settings.metadata.fields.store');
|
||||||
Route::patch('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'updateFieldDefinition'])
|
Route::patch('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'updateFieldDefinition'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.fields.update');
|
->name('projects.settings.metadata.fields.update');
|
||||||
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
|
Route::delete('projects/{project}/settings/metadata/fields/{ticketFieldDefinition}', [ProjectTicketMetadataSettingsController::class, 'destroyFieldDefinition'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.fields.destroy');
|
->name('projects.settings.metadata.fields.destroy');
|
||||||
Route::post('projects/{project}/settings/metadata/link-types', [ProjectTicketMetadataSettingsController::class, 'storeLinkType'])
|
Route::post('projects/{project}/settings/metadata/link-types', [ProjectTicketMetadataSettingsController::class, 'storeLinkType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.link-types.store');
|
->name('projects.settings.metadata.link-types.store');
|
||||||
Route::patch('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'updateLinkType'])
|
Route::patch('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'updateLinkType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.link-types.update');
|
->name('projects.settings.metadata.link-types.update');
|
||||||
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
|
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
|
||||||
|
->middleware('can:admin-project,project')
|
||||||
->name('projects.settings.metadata.link-types.destroy');
|
->name('projects.settings.metadata.link-types.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class GiteaProviderTest extends TestCase
|
|||||||
|
|
||||||
$this->assertCount(1, $repositories);
|
$this->assertCount(1, $repositories);
|
||||||
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
|
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
|
||||||
|
$this->assertSame('https://git.example.test/api/v1', $repositories[0]->baseUrl);
|
||||||
$this->assertSame('501', $repositories[0]->providerRepositoryId);
|
$this->assertSame('501', $repositories[0]->providerRepositoryId);
|
||||||
$this->assertSame('nico/kanbangit', $repositories[0]->fullName);
|
$this->assertSame('nico/kanbangit', $repositories[0]->fullName);
|
||||||
$this->assertSame('main', $repositories[0]->defaultBranch);
|
$this->assertSame('main', $repositories[0]->defaultBranch);
|
||||||
@@ -113,6 +114,7 @@ class GiteaProviderTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->assertInstanceOf(GitRepository::class, $repository);
|
$this->assertInstanceOf(GitRepository::class, $repository);
|
||||||
|
$this->assertSame('https://git.example.test', $repository->baseUrl);
|
||||||
$this->assertSame('501', $repository->providerRepositoryId);
|
$this->assertSame('501', $repository->providerRepositoryId);
|
||||||
$this->assertSame('core-team', $repository->owner);
|
$this->assertSame('core-team', $repository->owner);
|
||||||
$this->assertSame('kanbangit', $repository->name);
|
$this->assertSame('kanbangit', $repository->name);
|
||||||
@@ -138,10 +140,17 @@ class GiteaProviderTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$branches = app(GiteaProviderClient::class)->listBranches(
|
$branches = app(GiteaProviderClient::class)->listBranches(
|
||||||
'https://git.example.test',
|
|
||||||
'branch-token',
|
'branch-token',
|
||||||
'core-team',
|
new GitRepository(
|
||||||
'kanbangit',
|
baseUrl: 'https://git.example.test',
|
||||||
|
providerRepositoryId: '501',
|
||||||
|
owner: 'core-team',
|
||||||
|
name: 'kanbangit',
|
||||||
|
fullName: 'core-team/kanbangit',
|
||||||
|
htmlUrl: 'https://git.example.test/core-team/kanbangit',
|
||||||
|
defaultBranch: 'main',
|
||||||
|
isPrivate: false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertCount(2, $branches);
|
$this->assertCount(2, $branches);
|
||||||
@@ -210,10 +219,17 @@ class GiteaProviderTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$pullRequests = app(GiteaProviderClient::class)->listPullRequests(
|
$pullRequests = app(GiteaProviderClient::class)->listPullRequests(
|
||||||
'https://git.example.test',
|
|
||||||
'pull-token',
|
'pull-token',
|
||||||
'core-team',
|
new GitRepository(
|
||||||
'kanbangit',
|
baseUrl: 'https://git.example.test',
|
||||||
|
providerRepositoryId: '501',
|
||||||
|
owner: 'core-team',
|
||||||
|
name: 'kanbangit',
|
||||||
|
fullName: 'core-team/kanbangit',
|
||||||
|
htmlUrl: 'https://git.example.test/core-team/kanbangit',
|
||||||
|
defaultBranch: 'main',
|
||||||
|
isPrivate: false,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertCount(1, $pullRequests);
|
$this->assertCount(1, $pullRequests);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Models;
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Actions\Git\Data\GitRepository;
|
||||||
use App\Actions\Git\GitProviderKey;
|
use App\Actions\Git\GitProviderKey;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -33,4 +34,27 @@ class RepositoryConnectionTest extends TestCase
|
|||||||
$this->assertTrue($repositoryConnection->project()->exists());
|
$this->assertTrue($repositoryConnection->project()->exists());
|
||||||
$this->assertTrue($repositoryConnection->project->is($repositoryConnection->fresh()->project));
|
$this->assertTrue($repositoryConnection->project->is($repositoryConnection->fresh()->project));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure repository connections can be used as Git provider repository data.
|
||||||
|
*/
|
||||||
|
public function test_repository_connections_convert_to_git_repository_data(): void
|
||||||
|
{
|
||||||
|
$repositoryConnection = RepositoryConnection::factory()->create([
|
||||||
|
'base_url' => 'https://git.example.test',
|
||||||
|
'owner_name' => 'core-team',
|
||||||
|
'provider_repository_id' => '501',
|
||||||
|
'repository_full_name' => 'core-team/kanbangit',
|
||||||
|
'repository_name' => 'kanbangit',
|
||||||
|
'repository_url' => 'https://git.example.test/core-team/kanbangit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$repository = $repositoryConnection->toGitRepository();
|
||||||
|
|
||||||
|
$this->assertInstanceOf(GitRepository::class, $repository);
|
||||||
|
$this->assertSame('https://git.example.test', $repository->baseUrl);
|
||||||
|
$this->assertSame('core-team', $repository->owner);
|
||||||
|
$this->assertSame('kanbangit', $repository->name);
|
||||||
|
$this->assertSame('core-team/kanbangit', $repository->fullName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Models;
|
namespace Tests\Feature\Models;
|
||||||
|
|
||||||
|
use App\Enums\TicketWorklogEntryType;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use Database\Seeders\BoardColumnSeeder;
|
use Database\Seeders\BoardColumnSeeder;
|
||||||
use Database\Seeders\ProjectSeeder;
|
use Database\Seeders\ProjectSeeder;
|
||||||
@@ -25,7 +26,7 @@ class TicketWorklogEntryTest extends TestCase
|
|||||||
|
|
||||||
$this->assertNotNull($entry->ticket);
|
$this->assertNotNull($entry->ticket);
|
||||||
$this->assertNotNull($entry->actor);
|
$this->assertNotNull($entry->actor);
|
||||||
$this->assertSame('status_changed', $entry->entry_type);
|
$this->assertSame(TicketWorklogEntryType::StatusChanged, $entry->entry_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+22
-18
@@ -13,7 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProjectBacklogControllerTest extends TestCase
|
class ProjectBacklogTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
@@ -64,9 +64,9 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure creating tickets requires the project create permission.
|
* Ensure creating tickets requires the project write permission.
|
||||||
*/
|
*/
|
||||||
public function test_creating_tickets_requires_project_create_permission(): void
|
public function test_creating_tickets_requires_project_write_permission(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$project = Project::factory()->create();
|
$project = Project::factory()->create();
|
||||||
@@ -81,7 +81,7 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
$this->syncProjectPermissions($project, $user, ['view']);
|
$this->syncProjectPermissions($project, $user, ['view']);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->post(route('projects.backlog.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'title' => 'New ticket',
|
'title' => 'New ticket',
|
||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => 'Criteria',
|
||||||
@@ -93,9 +93,9 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure project-local keys increment and backlog accepts duplicate titles.
|
* Ensure project-local keys increment and ticket creation accepts duplicate titles.
|
||||||
*/
|
*/
|
||||||
public function test_users_with_create_permission_can_add_duplicate_titled_tickets_to_backlog(): void
|
public function test_users_with_write_permission_can_add_duplicate_titled_tickets(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$assignee = User::factory()->create();
|
$assignee = User::factory()->create();
|
||||||
@@ -127,14 +127,14 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->post(route('projects.backlog.store', $project), $payload)
|
->post(route('projects.tickets.store', $project), $payload)
|
||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.tickets.show', [$project, 1]))
|
||||||
->assertSessionHas('status', 'AUTH-1 was added to the backlog.');
|
->assertSessionHas('status', 'AUTH-1 was created.');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->post(route('projects.backlog.store', $project), $payload)
|
->post(route('projects.tickets.store', $project), $payload)
|
||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.tickets.show', [$project, 2]))
|
||||||
->assertSessionHas('status', 'AUTH-2 was added to the backlog.');
|
->assertSessionHas('status', 'AUTH-2 was created.');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tickets', [
|
$this->assertDatabaseHas('tickets', [
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
@@ -164,9 +164,9 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure ticket creation only accepts backlog statuses.
|
* Ensure ticket creation can target board column statuses.
|
||||||
*/
|
*/
|
||||||
public function test_creating_tickets_rejects_board_column_statuses(): void
|
public function test_creating_tickets_accepts_board_column_statuses(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$project = Project::factory()->create();
|
$project = Project::factory()->create();
|
||||||
@@ -189,7 +189,7 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->from(route('projects.backlog.index', $project))
|
->from(route('projects.backlog.index', $project))
|
||||||
->post(route('projects.backlog.store', $project), [
|
->post(route('projects.tickets.store', $project), [
|
||||||
'title' => 'New ticket',
|
'title' => 'New ticket',
|
||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'acceptance_criteria' => 'Criteria',
|
'acceptance_criteria' => 'Criteria',
|
||||||
@@ -197,9 +197,13 @@ class ProjectBacklogControllerTest extends TestCase
|
|||||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||||
'ticket_status_id' => $boardStatus->id,
|
'ticket_status_id' => $boardStatus->id,
|
||||||
])
|
])
|
||||||
->assertRedirect(route('projects.backlog.index', $project))
|
->assertRedirect(route('projects.tickets.show', [$project, 1]))
|
||||||
->assertSessionHasErrors('ticket_status_id');
|
->assertSessionHas('status', "{$project->key}-1 was created.");
|
||||||
|
|
||||||
$this->assertDatabaseCount('tickets', 0);
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'ticket_status_id' => $boardStatus->id,
|
||||||
|
'title' => 'New ticket',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ class ProjectBoardControllerTest extends TestCase
|
|||||||
->has('boardColumns.1.tickets', 1)
|
->has('boardColumns.1.tickets', 1)
|
||||||
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
|
||||||
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
|
||||||
|
->where('canCreateTickets', false)
|
||||||
->where('canEditTickets', false),
|
->where('canEditTickets', false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,9 +312,9 @@ class ProjectControllerTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure non-admin users cannot open general project settings even with project permissions.
|
* Ensure project admins can open general project settings.
|
||||||
*/
|
*/
|
||||||
public function test_non_admin_users_cannot_view_general_project_settings(): void
|
public function test_project_admins_can_view_general_project_settings(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$project = Project::factory()->create([
|
$project = Project::factory()->create([
|
||||||
@@ -325,7 +325,11 @@ class ProjectControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(route('projects.settings.general', $project))
|
->get(route('projects.settings.general', $project))
|
||||||
->assertForbidden();
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/settings/General')
|
||||||
|
->where('project.id', $project->id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Feature\Projects;
|
namespace Tests\Feature\Projects;
|
||||||
|
|
||||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||||
|
use App\Models\BoardColumn;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\RepositoryConnection;
|
use App\Models\RepositoryConnection;
|
||||||
use App\Models\Ticket;
|
use App\Models\Ticket;
|
||||||
@@ -11,6 +12,7 @@ use App\Models\TicketFieldValue;
|
|||||||
use App\Models\TicketLink;
|
use App\Models\TicketLink;
|
||||||
use App\Models\TicketLinkType;
|
use App\Models\TicketLinkType;
|
||||||
use App\Models\TicketPriority;
|
use App\Models\TicketPriority;
|
||||||
|
use App\Models\TicketStatus;
|
||||||
use App\Models\TicketType;
|
use App\Models\TicketType;
|
||||||
use App\Models\TicketWorklogEntry;
|
use App\Models\TicketWorklogEntry;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -274,6 +276,97 @@ class ProjectTicketControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the create page defaults to a board status when opened from the board.
|
||||||
|
*/
|
||||||
|
public function test_users_with_write_permission_can_view_the_ticket_create_page(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$boardColumn = BoardColumn::factory()->for($project)->create([
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
$boardStatus = TicketStatus::factory()
|
||||||
|
->for($project)
|
||||||
|
->for($boardColumn)
|
||||||
|
->create([
|
||||||
|
'name' => 'Todo',
|
||||||
|
'position' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->get(route('projects.tickets.create', [
|
||||||
|
'project' => $project,
|
||||||
|
'source' => 'board',
|
||||||
|
]))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('projects/TicketCreate')
|
||||||
|
->where('defaultTicketStatusId', $boardStatus->id)
|
||||||
|
->where('project.id', $project->id)
|
||||||
|
->where('source', 'board')
|
||||||
|
->where('ticketStatusOptions.1.id', $boardStatus->id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure creating tickets from the dedicated endpoint redirects to the ticket detail page.
|
||||||
|
*/
|
||||||
|
public function test_users_with_write_permission_can_create_tickets(): void
|
||||||
|
{
|
||||||
|
$editor = User::factory()->create();
|
||||||
|
$assignee = User::factory()->create();
|
||||||
|
$project = Project::factory()->create([
|
||||||
|
'key' => 'CORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||||
|
|
||||||
|
$priority = $project->ticketPriorities()->firstOrFail();
|
||||||
|
$backlogStatus = $project->ticketStatuses()
|
||||||
|
->whereNull('board_column_id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||||
|
|
||||||
|
$this->actingAs($editor)
|
||||||
|
->post(route('projects.tickets.store', $project), [
|
||||||
|
'acceptance_criteria' => 'The ticket exists.',
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
|
'description' => 'Created from the dedicated page.',
|
||||||
|
'story_points' => 5,
|
||||||
|
'ticket_priority_id' => $priority->id,
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||||
|
'title' => 'Dedicated create page',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('projects.tickets.show', [$project, 1]))
|
||||||
|
->assertSessionHas('status', 'CORE-1 was created.');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('tickets', [
|
||||||
|
'assignee_user_id' => $assignee->id,
|
||||||
|
'author_user_id' => $editor->id,
|
||||||
|
'project_id' => $project->id,
|
||||||
|
'story_points' => 5,
|
||||||
|
'ticket_key' => 'CORE-1',
|
||||||
|
'ticket_status_id' => $backlogStatus->id,
|
||||||
|
'title' => 'Dedicated create page',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||||
|
'actor_id' => $editor->id,
|
||||||
|
'entry_type' => 'created',
|
||||||
|
'summary' => 'Ticket was created in Backlog.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure updating ticket details requires the project edit permission.
|
* Ensure updating ticket details requires the project edit permission.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user