Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions app/Application/Concerns/SortsLists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

namespace Modules\TasksProjects\Application\Concerns;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;

/**
* Ordering a list the services already hold in memory.
*
* The sort happens in PHP rather than in the query on purpose. A task's
* priority ranks LOW, NORMAL, HIGH, URGENT, which is not its alphabetical
* order, and the three supported databases disagree about where a NULL lands:
* MySQL and SQLite put it first on an ascending sort, PostgreSQL puts it last.
* Sorting here gives one answer everywhere without a CASE expression or a
* `NULLS LAST` clause that SQLite would refuse. It costs nothing extra,
* because every list endpoint already loads the whole collection and cuts the
* page from it in `Controller::paginate`.
*
* Two rules hold whatever the caller asked for: a row with no value sorts last
* in both directions, so an undated task never leads the page, and ties break
* on the id in the direction of the sort, so the order is total and a page
* boundary never drops or repeats a row.
*/
trait SortsLists
{
/**
* @template TModel of Model
*
* @param Collection<int, TModel> $items
* @param callable(TModel): (int|string|null) $value the comparable value of one row
* @param string $order `asc` or `desc`; anything else reads as `asc`
* @return Collection<int, TModel>
*/
protected function sortList(Collection $items, callable $value, string $order): Collection
{
$descending = $order === 'desc';

return $items
->sort(function (Model $left, Model $right) use ($value, $descending): int {
/** @var callable(Model): (int|string|null) $value */
$first = $value($left);
$second = $value($right);

if ($first === null || $second === null) {
return $first === $second
? $this->compareIds($left, $right, $descending)
: ($first === null ? 1 : -1);
}

$comparison = is_int($first) && is_int($second)
? $first <=> $second
: $this->compareText((string) $first, (string) $second);

if ($comparison === 0) {
return $this->compareIds($left, $right, $descending);
}

return $descending ? -$comparison : $comparison;
})
->values();
}

/**
* The key and the direction to sort by, given what the caller asked for.
*
* A caller who names no column gets the list's own opening order, which is
* newest first for projects and by number for tasks. A caller who does
* name one gets it ascending unless they say otherwise, because that is
* the direction "sort by name" means to the person asking. The form
* request has already rejected an unknown key, so the fallback here is
* only ever reached by an internal caller.
*
* @param array<string, mixed> $filters
* @param array<string, callable> $supported sort key => value reader
* @return array{0: string, 1: string}
*/
protected function sortFor(array $filters, array $supported, string $defaultKey, string $defaultOrder): array
{
$key = $filters['sort_by'] ?? null;
$order = $filters['sort_order'] ?? null;

if (! is_string($key) || ! isset($supported[$key])) {
return [$defaultKey, $order === 'asc' || $order === 'desc' ? $order : $defaultOrder];
}

return [$key, $order === 'desc' ? 'desc' : 'asc'];
}

/**
* Names read the way a person reads them, so "apple" sits beside "Apple"
* rather than in a separate uppercase block the way a byte comparison
* would put it. Equal-but-for-case values fall through to the id.
*/
private function compareText(string $first, string $second): int
{
return strcasecmp($first, $second) <=> 0;
}

private function compareIds(Model $left, Model $right, bool $descending): int
{
$comparison = (int) $left->getKey() <=> (int) $right->getKey();

return $descending ? -$comparison : $comparison;
}
}
43 changes: 41 additions & 2 deletions app/Application/ProjectService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
use Modules\TasksProjects\Application\Concerns\SortsLists;
use Modules\TasksProjects\Application\Exceptions\ProjectInUse;
use Modules\TasksProjects\Models\Project;
use Modules\TasksProjects\Models\ProjectMember;
Expand All @@ -18,6 +19,21 @@
/** Project CRUD, archiving and the totals the project detail screen shows. */
final class ProjectService
{
use SortsLists;

/**
* The columns the list may be ordered by. The request rule reads this, so
* a new key is added here and nowhere else.
*
* @var list<string>
*/
public const SORT_KEYS = ['name', 'status', 'due_date', 'created_at', 'default_rate'];

/** Newest first, the way the host's own lists open. */
public const DEFAULT_SORT_KEY = 'created_at';

public const DEFAULT_SORT_ORDER = 'desc';

/** @var list<string> */
private const FIELDS = [
'customer_id', 'name', 'identifier', 'description', 'colour', 'status',
Expand All @@ -27,7 +43,7 @@ final class ProjectService
public function __construct(private readonly CompanyDataReader $companyData) {}

/**
* @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters
* @param array{status?: string, customer_id?: int, user_id?: int, search?: string, sort_by?: string, sort_order?: string} $filters
* @return Collection<int, Project>
*/
public function listFor(int $companyId, array $filters = []): Collection
Expand Down Expand Up @@ -57,7 +73,30 @@ public function listFor(int $companyId, array $filters = []): Collection
});
}

return $query->orderBy('name')->orderBy('id')->get();
$sorts = $this->sorts();
[$key, $order] = $this->sortFor($filters, $sorts, self::DEFAULT_SORT_KEY, self::DEFAULT_SORT_ORDER);

return $this->sortList($query->get(), $sorts[$key], $order);
}

/**
* How each sortable column compares, as a value the sorter can order.
*
* Dates become timestamps rather than Carbon instances so the comparison
* is a plain integer one, and a missing date or rate answers null, which
* the sorter always puts last.
*
* @return array<string, callable(Project): (int|string|null)>
*/
private function sorts(): array
{
return [
'name' => static fn (Project $project): string => (string) $project->name,
'status' => static fn (Project $project): string => (string) $project->status,
'due_date' => static fn (Project $project): ?int => $project->due_date?->getTimestamp(),
'created_at' => static fn (Project $project): ?int => $project->created_at?->getTimestamp(),
'default_rate' => static fn (Project $project): ?int => $project->default_rate,
];
}

public function findForCompany(int $companyId, int $id): Project
Expand Down
50 changes: 48 additions & 2 deletions app/Application/TaskService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\TasksProjects\Application\Concerns\DetectsUniqueViolations;
use Modules\TasksProjects\Application\Concerns\SortsLists;
use Modules\TasksProjects\Application\Exceptions\EntriesAlreadyInvoiced;
use Modules\TasksProjects\Models\Project;
use Modules\TasksProjects\Models\Task;
Expand All @@ -20,6 +21,20 @@
final class TaskService
{
use DetectsUniqueViolations;
use SortsLists;

/**
* The columns the list may be ordered by. The request rule reads this, so
* a new key is added here and nowhere else.
*
* @var list<string>
*/
public const SORT_KEYS = ['number', 'name', 'priority', 'due_date', 'created_at'];

/** The per-company sequence, which is the order people refer to tasks in. */
public const DEFAULT_SORT_KEY = 'number';

public const DEFAULT_SORT_ORDER = 'asc';

/** @var list<string> */
private const FIELDS = [
Expand All @@ -35,7 +50,7 @@ public function __construct(
) {}

/**
* @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string} $filters
* @param array{project_id?: int, assignee_id?: int, task_status_id?: int, customer_id?: int, due_before?: string, due_after?: string, search?: string, sort_by?: string, sort_order?: string} $filters
* @return Collection<int, Task>
*/
public function listFor(int $companyId, array $filters = []): Collection
Expand All @@ -60,7 +75,38 @@ public function listFor(int $companyId, array $filters = []): Collection
$query->where('name', 'like', '%'.$filters['search'].'%');
}

return $query->orderBy('number')->get();
$sorts = $this->sorts();
[$key, $order] = $this->sortFor($filters, $sorts, self::DEFAULT_SORT_KEY, self::DEFAULT_SORT_ORDER);

return $this->sortList($query->get(), $sorts[$key], $order);
}

/**
* How each sortable column compares, as a value the sorter can order.
*
* Priority sorts by its rank rather than by its name, so URGENT sits above
* HIGH instead of below it, and a task with no priority set answers null,
* which the sorter always puts last.
*
* @return array<string, callable(Task): (int|string|null)>
*/
private function sorts(): array
{
return [
'number' => static fn (Task $task): int => (int) $task->number,
'name' => static fn (Task $task): string => (string) $task->name,
'priority' => static fn (Task $task): ?int => self::priorityRank($task),
'due_date' => static fn (Task $task): ?int => $task->due_date?->getTimestamp(),
'created_at' => static fn (Task $task): ?int => $task->created_at?->getTimestamp(),
];
}

/** LOW, NORMAL, HIGH, URGENT as 1 to 4; an unset or unknown value as null. */
private static function priorityRank(Task $task): ?int
{
$rank = array_search($task->priority, Task::PRIORITIES, true);

return $rank === false ? null : $rank + 1;
}

public function findForCompany(int $companyId, int $id): Task
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/ProjectsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public function index(ListProjectsRequest $request): AnonymousResourceCollection
'customer_id' => isset($filters['customer_id']) ? (int) $filters['customer_id'] : null,
'user_id' => isset($filters['member_id']) ? (int) $filters['member_id'] : null,
'search' => $filters['search'] ?? null,
'sort_by' => $filters['sort_by'] ?? null,
'sort_order' => $filters['sort_order'] ?? null,
], static fn (mixed $value): bool => $value !== null));

return ProjectResource::collection($this->paginate($projects, $request));
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/TasksController.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public function index(ListTasksRequest $request): AnonymousResourceCollection
'due_before' => $filters['due_before'] ?? null,
'due_after' => $filters['due_after'] ?? null,
'search' => $filters['search'] ?? null,
'sort_by' => $filters['sort_by'] ?? null,
'sort_order' => $filters['sort_order'] ?? null,
], static fn (mixed $value): bool => $value !== null));

return TaskResource::collection($this->paginate($tasks, $request));
Expand Down
3 changes: 3 additions & 0 deletions app/Http/Requests/ListProjectsRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Modules\TasksProjects\Http\Requests;

use Modules\TasksProjects\Application\ProjectService;
use Modules\TasksProjects\Models\Project;

final class ListProjectsRequest extends ModuleRequest
Expand All @@ -16,6 +17,8 @@ public function rules(): array
'customer_id' => ['sometimes', 'integer', 'min:1'],
'member_id' => ['sometimes', 'integer', 'min:1'],
'search' => ['sometimes', 'string', 'max:255'],
'sort_by' => ['sometimes', 'string', 'in:'.implode(',', ProjectService::SORT_KEYS)],
'sort_order' => ['sometimes', 'string', 'in:asc,desc'],
];
}
}
4 changes: 4 additions & 0 deletions app/Http/Requests/ListTasksRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Modules\TasksProjects\Http\Requests;

use Modules\TasksProjects\Application\TaskService;

final class ListTasksRequest extends ModuleRequest
{
/** @return array<string, list<string>> */
Expand All @@ -17,6 +19,8 @@ public function rules(): array
'due_before' => ['sometimes', 'date'],
'due_after' => ['sometimes', 'date'],
'search' => ['sometimes', 'string', 'max:255'],
'sort_by' => ['sometimes', 'string', 'in:'.implode(',', TaskService::SORT_KEYS)],
'sort_order' => ['sometimes', 'string', 'in:asc,desc'],
];
}
}
Loading
Loading