From 18ad24a654325e7610f334bc66be07ba419165dc Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 22 Aug 2026 14:12:06 -0400 Subject: [PATCH 1/4] feat(tickets): add desktop filter controls to tickets2 --- app/Models/Ticket.php | 9 - .../Actions/BuildTicketListAction.php | 25 +- .../Controllers/Api/TicketApiController.php | 1 + app/Platform/Controllers/TicketController.php | 1 + app/Platform/Data/TicketListPagePropsData.php | 3 + .../Data/TicketListStateCountsData.php | 4 +- app/Platform/Enums/TicketListFilterKind.php | 5 + app/Platform/Enums/TicketListScope.php | 7 +- app/Platform/Enums/TicketListStatusFilter.php | 5 +- app/Platform/Services/TicketListService.php | 227 +++++++++- ...add_state_count_index_to_tickets_table.php | 27 ++ lang/en_US.json | 18 +- .../+index/TicketIndexRoot.test.tsx | 426 ++++++++++++++---- .../components/+index/TicketIndexRoot.tsx | 72 ++- .../TicketListFilterChips.test.tsx | 162 +++++++ .../TicketListFilterChips.tsx | 117 +++++ .../components/TicketListFilterChips/index.ts | 1 + .../TicketListFilterControl.test.tsx | 271 +++++++++++ .../TicketListFilterControl.tsx | 87 ++++ .../TicketListFilterValueList.tsx | 141 ++++++ .../TicketListFilterControl/index.ts | 1 + .../TicketListResetFiltersButton.test.tsx | 42 ++ .../TicketListResetFiltersButton.tsx | 30 ++ .../TicketListResetFiltersButton/index.ts | 1 + .../TicketListTable/TicketListTable.test.tsx | 1 + .../TicketStateGlyph/TicketStateGlyph.tsx | 18 +- .../tickets/hooks/ticketListQueryOptions.ts | 46 ++ .../usePreloadedTicketListQueryClient.ts | 32 +- .../hooks/useTicketListFilterLabels.ts | 79 ++++ .../hooks/useTicketListFilterProperties.ts | 62 +++ .../hooks/useTicketListPaginatedQuery.ts | 32 +- .../hooks/useTicketListPrefetchPagination.ts | 35 +- .../tickets/hooks/useTicketListState.ts | 40 ++ .../tickets/hooks/useTicketListTableRoot.ts | 43 +- .../hooks/useTicketListTableSync.test.ts | 92 ++-- .../tickets/hooks/useTicketListTableSync.ts | 34 +- resources/js/features/tickets/models/index.ts | 2 + ...icket-list-filter-property-option.model.ts | 7 + .../ticket-list-filter-property.model.ts | 8 + .../tickets/test/openPropertySubmenu.ts | 20 + .../utils/buildTicketListFilterParams.ts | 13 + .../buildTicketListPassthroughParams.test.ts | 50 -- .../utils/buildTicketListPassthroughParams.ts | 27 -- .../getActiveTicketListFilterProperties.ts | 26 ++ .../getAreTicketListFiltersNonDefault.test.ts | 56 +++ .../getAreTicketListFiltersNonDefault.ts | 21 + .../tickets/utils/getTicketListFilterValue.ts | 15 + .../utils/normalizeTicketListFilterValue.ts | 9 + ...olveInitialTicketListColumnFilters.test.ts | 67 +++ .../resolveInitialTicketListColumnFilters.ts | 45 ++ .../serializeTicketListSearchParams.test.ts | 107 +++++ .../utils/serializeTicketListSearchParams.ts | 69 +++ .../utils/setTicketListColumnFilterValue.ts | 22 + .../tickets/utils/ticketStateGlyphs.ts | 17 + .../factories/createTicketListStateCounts.ts | 4 +- resources/js/types/generated.d.ts | 4 +- .../Actions/BuildTicketListActionTest.php | 29 ++ .../Api/TicketApiControllerTest.php | 9 +- .../Controllers/TicketControllerIndexTest.php | 1 + .../Services/TicketListServiceTest.php | 3 +- 60 files changed, 2494 insertions(+), 334 deletions(-) create mode 100644 database/migrations/2026_08_22_000000_add_state_count_index_to_tickets_table.php create mode 100644 resources/js/features/tickets/components/TicketListFilterChips/TicketListFilterChips.test.tsx create mode 100644 resources/js/features/tickets/components/TicketListFilterChips/TicketListFilterChips.tsx create mode 100644 resources/js/features/tickets/components/TicketListFilterChips/index.ts create mode 100644 resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx create mode 100644 resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx create mode 100644 resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterValueList.tsx create mode 100644 resources/js/features/tickets/components/TicketListFilterControl/index.ts create mode 100644 resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.test.tsx create mode 100644 resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.tsx create mode 100644 resources/js/features/tickets/components/TicketListResetFiltersButton/index.ts create mode 100644 resources/js/features/tickets/hooks/ticketListQueryOptions.ts create mode 100644 resources/js/features/tickets/hooks/useTicketListFilterLabels.ts create mode 100644 resources/js/features/tickets/hooks/useTicketListFilterProperties.ts create mode 100644 resources/js/features/tickets/hooks/useTicketListState.ts create mode 100644 resources/js/features/tickets/models/index.ts create mode 100644 resources/js/features/tickets/models/ticket-list-filter-property-option.model.ts create mode 100644 resources/js/features/tickets/models/ticket-list-filter-property.model.ts create mode 100644 resources/js/features/tickets/test/openPropertySubmenu.ts create mode 100644 resources/js/features/tickets/utils/buildTicketListFilterParams.ts delete mode 100644 resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts delete mode 100644 resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts create mode 100644 resources/js/features/tickets/utils/getActiveTicketListFilterProperties.ts create mode 100644 resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.test.ts create mode 100644 resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.ts create mode 100644 resources/js/features/tickets/utils/getTicketListFilterValue.ts create mode 100644 resources/js/features/tickets/utils/normalizeTicketListFilterValue.ts create mode 100644 resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.test.ts create mode 100644 resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.ts create mode 100644 resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts create mode 100644 resources/js/features/tickets/utils/serializeTicketListSearchParams.ts create mode 100644 resources/js/features/tickets/utils/setTicketListColumnFilterValue.ts create mode 100644 resources/js/features/tickets/utils/ticketStateGlyphs.ts diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index ec258d75f4..49eb6214aa 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -163,15 +163,6 @@ public function scopeAwaitingDeveloper(Builder $query): Builder return $query->where('state', TicketState::Open); } - /** - * @param Builder $query - * @return Builder - */ - public function scopeResolved(Builder $query): Builder - { - return $query->whereIn('state', [TicketState::Resolved, TicketState::Closed]); - } - /** * @param Builder $query * @return Builder diff --git a/app/Platform/Actions/BuildTicketListAction.php b/app/Platform/Actions/BuildTicketListAction.php index acae70739d..196fd83069 100644 --- a/app/Platform/Actions/BuildTicketListAction.php +++ b/app/Platform/Actions/BuildTicketListAction.php @@ -29,7 +29,7 @@ class BuildTicketListAction private const PER_PAGE = 50; /** - * @return array{paginatedTickets: PaginatedData, stateCounts: TicketListStateCountsData} + * @return array{paginatedTickets: PaginatedData, stateCounts: TicketListStateCountsData, facetCounts: array>} */ public function execute( TicketListScope $scope, @@ -41,10 +41,20 @@ public function execute( $filterOptions = $service->getFilterOptions($request, $scope->defaultStatusFilter()); $comparisonUser = $scope->comparisonUser($target); - $base = $scope->baseQuery($target)->withLiveTicketable(); - $stateCounts = $service->getStateCounts($filterOptions, clone $base, $comparisonUser); + $scopedTickets = $scope->baseQuery($target); + $renderableTickets = (clone $scopedTickets)->withLiveTicketable(); + + $stateCounts = $service->getStateCounts($filterOptions, clone $scopedTickets, $comparisonUser); $total = TicketListStatusFilter::from($filterOptions['status'])->filteredTotal($stateCounts); + + $facetCounts = $service->getFacetCounts( + $filterOptions, + clone $scopedTickets, + $scope->filterKinds(), + $comparisonUser, + $total, + ); $lastPage = max(1, (int) ceil($total / self::PER_PAGE)); $page = $request->getPage(); @@ -52,7 +62,7 @@ public function execute( $page = 1; } - $query = $service->applyFilters(clone $base, $filterOptions, $comparisonUser); + $query = $service->applyFilters(clone $renderableTickets, $filterOptions, $comparisonUser); $this->applySort($query, $request->getSort()); $this->applyTicketListEntryEagerLoads($query); @@ -64,14 +74,19 @@ public function execute( options: ['path' => $request->url(), 'query' => $request->query()], ); + $unfilteredTotal = $service->hasNonStatusFilters($filterOptions) + ? (clone $scopedTickets)->count() + : $stateCounts['all']; + $paginatedTickets = PaginatedData::fromLengthAwarePaginator( $paginator, - unfilteredTotal: (clone $base)->count(), + unfilteredTotal: $unfilteredTotal, ); return [ 'paginatedTickets' => $paginatedTickets, 'stateCounts' => TicketListStateCountsData::fromCounts($stateCounts), + 'facetCounts' => $facetCounts, ]; } diff --git a/app/Platform/Controllers/Api/TicketApiController.php b/app/Platform/Controllers/Api/TicketApiController.php index 87a2938eab..8f614942a2 100644 --- a/app/Platform/Controllers/Api/TicketApiController.php +++ b/app/Platform/Controllers/Api/TicketApiController.php @@ -27,6 +27,7 @@ public function index(TicketListApiRequest $request): JsonResponse return response()->json([ 'paginatedTickets' => $result['paginatedTickets'], 'stateCounts' => $result['stateCounts'], + 'facetCounts' => $result['facetCounts'], ]); } diff --git a/app/Platform/Controllers/TicketController.php b/app/Platform/Controllers/TicketController.php index e1a61623a0..86041d8512 100644 --- a/app/Platform/Controllers/TicketController.php +++ b/app/Platform/Controllers/TicketController.php @@ -40,6 +40,7 @@ public function index(TicketListRequest $request): InertiaResponse paginatedTickets: $result['paginatedTickets'], stateCounts: $result['stateCounts'], availableFilters: $action->getAvailableFilters(TicketListScope::All), + facetCounts: $result['facetCounts'], ); return Inertia::render('tickets', $props); diff --git a/app/Platform/Data/TicketListPagePropsData.php b/app/Platform/Data/TicketListPagePropsData.php index 7d95a11daf..862edff413 100644 --- a/app/Platform/Data/TicketListPagePropsData.php +++ b/app/Platform/Data/TicketListPagePropsData.php @@ -16,6 +16,7 @@ class TicketListPagePropsData extends Data { /** * @param TicketListFilterData[] $availableFilters + * @param array> $facetCounts */ public function __construct( public TicketListScope $scope, @@ -24,6 +25,8 @@ public function __construct( public TicketListStateCountsData $stateCounts, #[LiteralTypeScriptType('App.Platform.Data.TicketListFilter[]')] public array $availableFilters, + #[LiteralTypeScriptType('Record>')] + public array $facetCounts, public ?GameData $game = null, public ?AchievementData $achievement = null, public ?UserData $user = null, diff --git a/app/Platform/Data/TicketListStateCountsData.php b/app/Platform/Data/TicketListStateCountsData.php index 3b076a8fe9..ee7dc866ad 100644 --- a/app/Platform/Data/TicketListStateCountsData.php +++ b/app/Platform/Data/TicketListStateCountsData.php @@ -14,13 +14,14 @@ public function __construct( public int $unresolved, public int $request, public int $resolved, + public int $closed, public int $quarantined, public int $all, ) { } /** - * @param array{unresolved: int, request: int, resolved: int, quarantined: int, all: int} $counts + * @param array{unresolved: int, request: int, resolved: int, closed: int, quarantined: int, all: int} $counts */ public static function fromCounts(array $counts): self { @@ -28,6 +29,7 @@ public static function fromCounts(array $counts): self unresolved: $counts['unresolved'], request: $counts['request'], resolved: $counts['resolved'], + closed: $counts['closed'], quarantined: $counts['quarantined'], all: $counts['all'], ); diff --git a/app/Platform/Enums/TicketListFilterKind.php b/app/Platform/Enums/TicketListFilterKind.php index b3615577c1..b94f49c8a3 100644 --- a/app/Platform/Enums/TicketListFilterKind.php +++ b/app/Platform/Enums/TicketListFilterKind.php @@ -42,6 +42,11 @@ public function values(?int $systemId = null): array }; } + public function noFilterValue(): int|string + { + return $this === self::Type ? 0 : 'all'; + } + /** * @return array */ diff --git a/app/Platform/Enums/TicketListScope.php b/app/Platform/Enums/TicketListScope.php index b00bdb6a45..f7e999c461 100644 --- a/app/Platform/Enums/TicketListScope.php +++ b/app/Platform/Enums/TicketListScope.php @@ -4,6 +4,7 @@ namespace App\Platform\Enums; +use App\Community\Enums\TicketState; use App\Models\Achievement; use App\Models\Game; use App\Models\Ticket; @@ -53,7 +54,8 @@ public function baseQuery(Game|Achievement|User|null $target): Builder self::AwaitingReporter => Ticket::query() ->where('reporter_id', $target->id), self::ResolvedBy => Ticket::query() - ->where('resolver_id', $target->id), + ->where('resolver_id', $target->id) + ->whereIn('state', [TicketState::Resolved, TicketState::Closed]), }; } @@ -98,7 +100,8 @@ public function filterKinds(): array public function defaultStatusFilter(): TicketListStatusFilter { return match ($this) { - self::ResolvedBy => TicketListStatusFilter::Resolved, + self::ResolvedBy => TicketListStatusFilter::All, + self::AwaitingReporter => TicketListStatusFilter::Request, default => TicketListStatusFilter::Unresolved, }; diff --git a/app/Platform/Enums/TicketListStatusFilter.php b/app/Platform/Enums/TicketListStatusFilter.php index 87794e966f..571b5d3465 100644 --- a/app/Platform/Enums/TicketListStatusFilter.php +++ b/app/Platform/Enums/TicketListStatusFilter.php @@ -13,10 +13,11 @@ enum TicketListStatusFilter: string case Unresolved = 'unresolved'; case Request = 'request'; case Resolved = 'resolved'; + case Closed = 'closed'; case Quarantined = 'quarantined'; /** - * @return 'all'|'unresolved'|'request'|'resolved'|'quarantined' + * @return 'all'|'unresolved'|'request'|'resolved'|'closed'|'quarantined' */ public function stateCountsBucket(): string { @@ -24,7 +25,7 @@ public function stateCountsBucket(): string } /** - * @param array{unresolved: int, request: int, resolved: int, quarantined: int, all: int} $stateCounts + * @param array{unresolved: int, request: int, resolved: int, closed: int, quarantined: int, all: int} $stateCounts */ public function filteredTotal(array $stateCounts): int { diff --git a/app/Platform/Services/TicketListService.php b/app/Platform/Services/TicketListService.php index df4afbcb23..eef7c6d041 100644 --- a/app/Platform/Services/TicketListService.php +++ b/app/Platform/Services/TicketListService.php @@ -28,9 +28,17 @@ class TicketListService public int $pageNumber = 0; public int $totalPages = 0; + private const MAX_FACET_COUNT_ROWS = 10_000; + + /** @var array|null */ + private ?array $emulatorNamesById = null; + + /** @var array */ + private array $countsForCurrentBaseQuery = []; + public static function shouldShowResolverColumn(array $filterOptions): bool { - return in_array($filterOptions['status'] ?? 'unresolved', ['all', 'resolved'], true); + return in_array($filterOptions['status'] ?? 'unresolved', ['all', 'resolved', 'closed'], true); } /** @@ -64,6 +72,19 @@ public function getFilterOptions(Request $request, TicketListStatusFilter $defau ]; } + public function hasNonStatusFilters(array $filterOptions): bool + { + foreach (TicketListFilterKind::cases() as $kind) { + $noFilterValue = $kind->noFilterValue(); + + if (($filterOptions[$kind->value] ?? $noFilterValue) !== $noFilterValue) { + return true; + } + } + + return false; + } + public function getSelectFilters( bool $showStatus = true, bool $showPublishedStatus = true, @@ -240,7 +261,11 @@ public function applyFilters(Builder $tickets, array $filterOptions, ?User $comp break; case TicketListStatusFilter::Resolved: - $tickets->resolved(); + $tickets->where('state', TicketState::Resolved); + break; + + case TicketListStatusFilter::Closed: + $tickets->where('state', TicketState::Closed); break; case TicketListStatusFilter::Quarantined: @@ -353,6 +378,194 @@ public function applyFilters(Builder $tickets, array $filterOptions, ?User $comp return $tickets; } + /** + * How many tickets each filter option matches. + * + * @param Builder $tickets + * @param list $kinds + * @return array> + */ + public function getFacetCounts( + array $filterOptions, + Builder $tickets, + array $kinds, + ?User $comparisonUser = null, + ?int $filteredTotal = null, + ): array { + $this->countsForCurrentBaseQuery = []; + + $widestFacetRowCount = $this->hasNonStatusFilters($filterOptions) + ? $this->countForFilters($tickets, $this->withoutFacetFilters($filterOptions), $comparisonUser) + : $filteredTotal; + + if ($widestFacetRowCount !== null && $widestFacetRowCount > self::MAX_FACET_COUNT_ROWS) { + return []; + } + + $counts = []; + foreach ($kinds as $kind) { + $facetCounts = match ($kind) { + TicketListFilterKind::Type => $this->countGroupedFacet( + $tickets, + $filterOptions, + $comparisonUser, + $kind, + 'tickets.type', + fn (?string $value) => match ($value) { + TicketType::TriggeredAtWrongTime->value => (string) TicketType::TriggeredAtWrongTime->toLegacyInteger(), + TicketType::DidNotTrigger->value => (string) TicketType::DidNotTrigger->toLegacyInteger(), + default => null, + }, + ), + TicketListFilterKind::Mode => $this->countGroupedFacet( + $tickets, + $filterOptions, + $comparisonUser, + $kind, + 'tickets.hardcore', + fn (?string $value) => match ($value) { + null => 'unspecified', + '1' => 'hardcore', + default => 'softcore', + }, + ), + TicketListFilterKind::Emulator => $this->countGroupedFacet( + $tickets, + $filterOptions, + $comparisonUser, + $kind, + 'tickets.emulator_id', + fn (?string $value) => $value === null + ? 'unknown' + : ($this->emulatorNamesById()[(int) $value] ?? null), + ), + TicketListFilterKind::PublishedStatus, + TicketListFilterKind::DeveloperType => $this->countFacetOptions( + $tickets, + $filterOptions, + $comparisonUser, + $kind, + $filteredTotal, + ), + TicketListFilterKind::Developer, + TicketListFilterKind::Reporter => null, + }; + + if ($facetCounts !== null) { + $counts[$kind->value] = $facetCounts; + } + } + + return $counts; + } + + /** + * @param Builder $tickets + * @param callable(?string): ?string $toFilterValue + * @return array + */ + private function countGroupedFacet( + Builder $tickets, + array $filterOptions, + ?User $comparisonUser, + TicketListFilterKind $kind, + string $column, + callable $toFilterValue, + ): array { + $rows = $this->applyFilters(clone $tickets, $this->withoutFilter($filterOptions, $kind), $comparisonUser) + ->reorder() + ->select($column . ' as facet_value', DB::raw('count(*) as aggregate')) + ->groupBy('facet_value') + ->get(); + + $noFilterValue = (string) $kind->noFilterValue(); + $counts = [$noFilterValue => 0]; + + foreach ($rows as $row) { + $aggregate = (int) $row->aggregate; + $counts[$noFilterValue] += $aggregate; + + $value = $toFilterValue($row->facet_value === null ? null : (string) $row->facet_value); + if ($value !== null) { + $counts[$value] = ($counts[$value] ?? 0) + $aggregate; + } + } + + return $counts; + } + + /** + * @param Builder $tickets + * @return array + */ + private function countFacetOptions( + Builder $tickets, + array $filterOptions, + ?User $comparisonUser, + TicketListFilterKind $kind, + ?int $filteredTotal, + ): array { + $noFilterValue = $kind->noFilterValue(); + $lifted = $this->withoutFilter($filterOptions, $kind); + + $isAlreadyUnfiltered = ($filterOptions[$kind->value] ?? $noFilterValue) === $noFilterValue; + + $counts = []; + foreach ($kind->values() as $value) { + $counts[$value] = $value === (string) $noFilterValue && $isAlreadyUnfiltered && $filteredTotal !== null + ? $filteredTotal + : $this->countForFilters( + $tickets, + array_merge($lifted, [$kind->value => $value]), + $comparisonUser, + ); + } + + return $counts; + } + + /** + * @param Builder $tickets + */ + private function countForFilters(Builder $tickets, array $filterOptions, ?User $comparisonUser): int + { + $key = serialize([$filterOptions, $comparisonUser?->id]); + + return $this->countsForCurrentBaseQuery[$key] ??= $this->applyFilters( + clone $tickets, + $filterOptions, + $comparisonUser, + )->count(); + } + + /** + * @return array + */ + private function emulatorNamesById(): array + { + return $this->emulatorNamesById ??= Emulator::pluck('name', 'id')->all(); + } + + /** + * @return array + */ + private function withoutFilter(array $filterOptions, TicketListFilterKind $kind): array + { + return array_merge($filterOptions, [$kind->value => $kind->noFilterValue()]); + } + + /** + * @return array + */ + private function withoutFacetFilters(array $filterOptions): array + { + foreach (TicketListFilterKind::cases() as $kind) { + $filterOptions = $this->withoutFilter($filterOptions, $kind); + } + + return $filterOptions; + } + /** * Returns a count of tickets per status bucket under every filter except * status itself. The counts describe what each status choice would show @@ -360,14 +573,12 @@ public function applyFilters(Builder $tickets, array $filterOptions, ?User $comp * * @param Builder $tickets * @param User|null $comparisonUser the user the developer and reporter filters compare against - * @return array{unresolved: int, request: int, resolved: int, quarantined: int, all: int} + * @return array{unresolved: int, request: int, resolved: int, closed: int, quarantined: int, all: int} */ public function getStateCounts(array $filterOptions, ?Builder $tickets = null, ?User $comparisonUser = null): array { $countQuery = $tickets === null ? Ticket::query() : clone $tickets; - $countQuery->withLiveTicketable(); - $countQuery = $this->applyFilters($countQuery, array_merge($filterOptions, ['status' => TicketListStatusFilter::All->value]), $comparisonUser); $countsByState = $countQuery @@ -381,15 +592,17 @@ public function getStateCounts(array $filterOptions, ?Builder $tickets = null, ? $request = $countFor(TicketState::Request); $unresolved = $countFor(TicketState::Open) + $request; - $resolved = $countFor(TicketState::Resolved) + $countFor(TicketState::Closed); + $resolved = $countFor(TicketState::Resolved); + $closed = $countFor(TicketState::Closed); $quarantined = $countFor(TicketState::Quarantined); return [ 'unresolved' => $unresolved, 'request' => $request, 'resolved' => $resolved, + 'closed' => $closed, 'quarantined' => $quarantined, - 'all' => $unresolved + $resolved + $quarantined, + 'all' => $unresolved + $resolved + $closed + $quarantined, ]; } } diff --git a/database/migrations/2026_08_22_000000_add_state_count_index_to_tickets_table.php b/database/migrations/2026_08_22_000000_add_state_count_index_to_tickets_table.php new file mode 100644 index 0000000000..a5714ead37 --- /dev/null +++ b/database/migrations/2026_08_22_000000_add_state_count_index_to_tickets_table.php @@ -0,0 +1,27 @@ +index(['deleted_at', 'state']); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table) { + $table->dropIndex(['deleted_at', 'state']); + }); + } +}; diff --git a/lang/en_US.json b/lang/en_US.json index 19f85e8d08..e64c433b38 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -1545,5 +1545,21 @@ "ID": "ID", "Issue with": "Issue with", "Reporter": "Reporter", - "Age": "Age" + "Age": "Age", + "Filter": "Filter", + "Remove {{label}} filter": "Remove {{label}} filter", + "Self": "Self", + "Others": "Others", + "Junior": "Junior", + "Inactive": "Inactive", + "Unspecified": "Unspecified", + "All": "All", + "Developer type": "Developer type", + "Search options...": "Search options...", + "{{val, number}} tickets": "{{val, number}} tickets", + "{{visible, number}} of {{total, number}} tickets": "{{visible, number}} of {{total, number}} tickets", + "Issue type": "Issue type", + "Change {{label}} filter": "Change {{label}} filter", + "{{label}} is <1>{{value}}": "{{label}} is <1>{{value}}", + "Publish status": "Publish status" } diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx index 8a4bb6cdc9..6bf8592911 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx @@ -3,37 +3,65 @@ import axios from 'axios'; import { render, screen, waitFor } from '@/test'; import { + createGame, createPaginatedData, + createSystem, createTicketListEntry, createTicketListStateCounts, createUser, createZiggyProps, } from '@/test/factories'; +import { openPropertySubmenu } from '../../test/openPropertySubmenu'; import { TicketIndexRoot } from './TicketIndexRoot'; +type TicketIndexRenderProps = Partial & { + ziggy?: ReturnType; +}; + +function renderTicketIndexRoot(pageProps: TicketIndexRenderProps = {}) { + return render(, { + pageProps: { + scope: 'all', + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 1, + }), + stateCounts: createTicketListStateCounts(), + availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], + facetCounts: {}, + ziggy: createZiggyProps({ query: {} }), + ...pageProps, + }, + }); +} + +type TicketListResponse = Pick< + App.Platform.Data.TicketListPageProps, + 'paginatedTickets' | 'stateCounts' | 'facetCounts' +>; + +function createTicketListResponse( + paginatedTickets: TicketListResponse['paginatedTickets'], + overrides: Partial> = {}, +): TicketListResponse { + return { + paginatedTickets, + stateCounts: createTicketListStateCounts(), + facetCounts: {}, + ...overrides, + }; +} + describe('Component: TicketIndexRoot', () => { - it('renders without crashing', () => { + it('shows the ticket manager list', () => { // ARRANGE - const { container } = render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData([createTicketListEntry()], { - currentPage: 1, - lastPage: 1, - perPage: 50, - total: 1, - }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: {} }), - }, - }); + renderTicketIndexRoot(); // ASSERT - expect(container).toBeTruthy(); expect(screen.getByTestId('ticket-list')).toBeVisible(); - expect(screen.getByRole('heading', { level: 1, name: 'Ticket Manager' })).toBeVisible(); }); @@ -44,25 +72,28 @@ describe('Component: TicketIndexRoot', () => { id: 1001, state: 'open', ticketableTitle: 'First Blood', + game: createGame({ title: 'Sonic the Hedgehog', system: createSystem() }), author: createUser({ displayName: 'Dev' }), reporter: createUser({ displayName: 'Scott' }), }), - createTicketListEntry({ id: 1002, ticketableTitle: 'Second Wind', state: 'quarantined' }), + createTicketListEntry({ + id: 1002, + state: 'quarantined', + ticketableTitle: 'Second Wind', + game: createGame({ title: 'Streets of Rage', system: createSystem() }), + author: createUser({ displayName: 'Kiterra' }), + reporter: createUser({ displayName: 'Salsa' }), + }), ]; - render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData(tickets, { - currentPage: 1, - lastPage: 1, - perPage: 50, - total: tickets.length, - }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: {} }), - }, + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData(tickets, { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: tickets.length, + unfilteredTotal: 300, + }), }); // ASSERT @@ -96,58 +127,283 @@ describe('Component: TicketIndexRoot', () => { expect(screen.getByRole('link', { name: /scott/i })).toBeVisible(); expect(screen.getAllByRole('img', { name: 'Quarantined' })[0]).toBeVisible(); + + expect(screen.getByText('2 of 300 tickets')).toBeVisible(); }); it('given the page has no tickets, shows the empty state instead of a table', () => { // ARRANGE - render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData([], { + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([], { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 0, + }), + }); + + // ASSERT + expect(screen.getByText('No tickets match these filters.')).toBeVisible(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + + it('given the scope exposes filters, offers them all behind one filter button', async () => { + // ARRANGE + renderTicketIndexRoot({ + stateCounts: createTicketListStateCounts({ unresolved: 7, resolved: 3 }), + availableFilters: [ + { kind: 'type', values: ['0', '1', '2'] }, + { kind: 'mode', values: ['all', 'hardcore', 'softcore'] }, + ], + }); + + // ACT + await userEvent.click(screen.getByTestId('add-filter')); + + // ASSERT + expect(screen.getByTestId('filter-property-status')).toBeVisible(); + expect(screen.getByTestId('filter-property-type')).toBeVisible(); + expect(screen.getByTestId('filter-property-mode')).toBeVisible(); + }); + + it('given the server counted a facet, every one of its options carries a count', async () => { + // ARRANGE + renderTicketIndexRoot({ + availableFilters: [{ kind: 'emulator', values: ['all', 'RetroArch', 'unknown'] }], + facetCounts: { emulator: { all: 100, RetroArch: 40 } }, + }); + + // ACT + await openPropertySubmenu(1); + + // ASSERT + expect(screen.getByRole('menuitem', { name: /^RetroArch/ })).toHaveTextContent('40'); + expect(screen.getByRole('menuitem', { name: /^all/i })).toHaveTextContent('100'); + expect(screen.getByRole('menuitem', { name: /^Unknown/ })).toHaveTextContent('0'); + }); + + it('omits counts when the server does not provide them', async () => { + // ARRANGE + renderTicketIndexRoot({ + availableFilters: [{ kind: 'developerType', values: ['all', 'active', 'junior'] }], + }); + + // ACT + await openPropertySubmenu(1); + + // ASSERT + expect(screen.getByRole('menuitem', { name: 'Active' })).toHaveTextContent(/^Active$/); + }); + + it('given the default status narrows the list, shows it as a chip with the current value', () => { + // ARRANGE + renderTicketIndexRoot(); + + // ASSERT + expect(screen.getByTestId('chip-status')).toHaveTextContent('Open'); + expect(screen.queryByTestId('chip-type')).not.toBeInTheDocument(); + }); + + it('given the filters have their current default values, the reset button is hidden and only appears once a non-default value is set', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { currentPage: 1, lastPage: 1, perPage: 50, - total: 0, + total: 1, }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: {} }), - }, + ), }); + renderTicketIndexRoot(); + + expect(screen.queryByTestId('reset-all-filters')).not.toBeInTheDocument(); + + // ACT + await openPropertySubmenu(0); + await userEvent.click(screen.getByRole('menuitem', { name: /resolved/i })); + // ASSERT - expect(screen.getByText('No tickets match these filters.')).toBeVisible(); - expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.getByTestId('reset-all-filters')).toBeVisible(); }); - it('given the user advances to the next page, fetches the next page from the API and syncs the URL', async () => { + it('given the user adds a status filter, refetches with it and syncs the URL', async () => { // ARRANGE const pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ - data: { - paginatedTickets: createPaginatedData([createTicketListEntry({ id: 2001 })], { - currentPage: 2, + data: createTicketListResponse( + createPaginatedData([createTicketListEntry({ id: 4001 })], { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 1, + }), + ), + }); + + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 1, + }), + }); + + // ACT + await openPropertySubmenu(0); + await userEvent.click(screen.getByRole('menuitem', { name: /quarantined/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'filter[status]': 'quarantined', 'filter[type]': '0', 'page[number]': 1 }, + ]); + }); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Ticket #4001' })).toBeVisible(); + }); + + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + expect.stringContaining('filter%5Bstatus%5D=quarantined'), + ); + + expect(pushStateSpy).not.toHaveBeenCalledWith( + { inertia: true }, + '', + expect.stringContaining('filter%5Btype%5D'), + ); + }); + + it('given the user clears a chip, returns that filter to its default value', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 1, + }), + ), + }); + + renderTicketIndexRoot(); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Remove Status filter' })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'filter[status]': 'all', 'filter[type]': '0', 'page[number]': 1 }, + ]); + }); + + expect(screen.queryByTestId('chip-status')).not.toBeInTheDocument(); + }); + + it('given the user is not on the first page and sets a filter value, returns the user to the first page', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { + currentPage: 1, lastPage: 3, perPage: 50, total: 150, }), - }, + ), + }); + + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 2, + lastPage: 3, + perPage: 50, + total: 150, + }), + ziggy: createZiggyProps({ query: { 'page[number]': '2' } }), }); - render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + // ACT + await openPropertySubmenu(0); + await userEvent.click(screen.getByRole('menuitem', { name: /resolved/i })); + + // ASSERT + await waitFor(() => { + expect(getSpy).toHaveBeenCalledWith([ + 'api.ticket.index', + { scope: 'all', 'filter[status]': 'resolved', 'filter[type]': '0', 'page[number]': 1 }, + ]); + }); + }); + + it('given the query returns fresh state counts, the status value list shows them', async () => { + // ARRANGE + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { currentPage: 1, + lastPage: 1, + perPage: 50, + total: 1, + }), + { stateCounts: createTicketListStateCounts({ unresolved: 99, resolved: 5 }) }, + ), + }); + + renderTicketIndexRoot({ + stateCounts: createTicketListStateCounts({ unresolved: 7, resolved: 3 }), + }); + + // ACT + await openPropertySubmenu(0); + await userEvent.click(screen.getByRole('menuitem', { name: /resolved/i })); + await userEvent.click(screen.getByTestId('chip-status-value')); + + // ASSERT + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /open/i })).toHaveTextContent('99'); + }); + }); + + it('given the user advances to the next page, fetches the next page from the API and syncs the URL', async () => { + // ARRANGE + const pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry({ id: 2001 })], { + currentPage: 2, lastPage: 3, perPage: 50, total: 150, }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: {} }), - }, + ), + }); + + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), }); // ACT @@ -157,14 +413,14 @@ describe('Component: TicketIndexRoot', () => { await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'page[number]': 2 }, + { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 2 }, ]); }); await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'page[number]': 3 }, + { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 3 }, ]); }); @@ -184,29 +440,23 @@ describe('Component: TicketIndexRoot', () => { vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ - data: { - paginatedTickets: createPaginatedData([createTicketListEntry({ id: 3001 })], { + data: createTicketListResponse( + createPaginatedData([createTicketListEntry({ id: 3001 })], { currentPage: 3, lastPage: 3, perPage: 50, total: 150, }), - }, + ), }); - render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { - currentPage: 1, - lastPage: 3, - perPage: 50, - total: 150, - }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: {} }), - }, + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry({ id: 1001 })], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), }); // ACT @@ -219,7 +469,7 @@ describe('Component: TicketIndexRoot', () => { () => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'page[number]': 3 }, + { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 3 }, ]); }, { timeout: 2000 }, @@ -235,29 +485,24 @@ describe('Component: TicketIndexRoot', () => { vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ - data: { - paginatedTickets: createPaginatedData([createTicketListEntry()], { + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { currentPage: 2, lastPage: 3, perPage: 50, total: 150, }), - }, + ), }); - render(, { - pageProps: { - scope: 'all', - paginatedTickets: createPaginatedData([createTicketListEntry()], { - currentPage: 1, - lastPage: 3, - perPage: 50, - total: 150, - }), - stateCounts: createTicketListStateCounts(), - availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], - ziggy: createZiggyProps({ query: { filter: { status: 'resolved' }, sort: 'state' } }), - }, + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 1, + lastPage: 3, + perPage: 50, + total: 150, + }), + ziggy: createZiggyProps({ query: { filter: { status: 'resolved' }, sort: 'state' } }), }); // ACT @@ -271,6 +516,7 @@ describe('Component: TicketIndexRoot', () => { scope: 'all', sort: 'state', 'filter[status]': 'resolved', + 'filter[type]': '0', 'page[number]': 2, }, ]); diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx index 5bd40587b3..895ea88f09 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx @@ -1,29 +1,67 @@ import { HydrationBoundary } from '@tanstack/react-query'; +import type { ColumnFiltersState } from '@tanstack/react-table'; import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { DataTablePaginationControls } from '@/common/components/DataTablePaginationControls'; import { usePageProps } from '@/common/hooks/usePageProps'; import { useTicketListColumnDefinitions } from '../../hooks/useTicketListColumnDefinitions'; +import { useTicketListFilterProperties } from '../../hooks/useTicketListFilterProperties'; import { useTicketListTableRoot } from '../../hooks/useTicketListTableRoot'; +import { getActiveTicketListFilterProperties } from '../../utils/getActiveTicketListFilterProperties'; +import { getAreTicketListFiltersNonDefault } from '../../utils/getAreTicketListFiltersNonDefault'; import { TICKET_LIST_COLUMN_IDS } from '../../utils/ticketListColumnIds'; import { TicketListEmptyState } from '../TicketListEmptyState'; +import { TicketListFilterChips } from '../TicketListFilterChips'; +import { TicketListFilterControl } from '../TicketListFilterControl'; import { TicketListHeading } from '../TicketListHeading'; +import { TicketListResetFiltersButton } from '../TicketListResetFiltersButton'; import { TicketListTable } from '../TicketListTable'; // temporary - selectable columns will arrive in a future commit const columnVisibility = Object.fromEntries(TICKET_LIST_COLUMN_IDS.map((id) => [id, true])); +const SERVER_DEFAULT_STATUS_FILTER: ColumnFiltersState = [{ id: 'status', value: ['unresolved'] }]; + export const TicketIndexRoot: FC = () => { - const { paginatedTickets, scope } = usePageProps(); + const { availableFilters, facetCounts, paginatedTickets, scope, stateCounts } = + usePageProps(); + const { t } = useTranslation(); const columnDefinitions = useTicketListColumnDefinitions(); + const serverDefaultColumnFilters: ColumnFiltersState = [ + ...SERVER_DEFAULT_STATUS_FILTER, + ...availableFilters.map((filter) => ({ id: filter.kind, value: [filter.values[0]] })), + ]; + const { hydrationState, ticketListTableProps } = useTicketListTableRoot({ + serverDefaultColumnFilters, + facetCounts, paginatedTickets, scope, + stateCounts, }); + const filterProperties = useTicketListFilterProperties( + availableFilters, + ticketListTableProps.stateCounts, + ticketListTableProps.facetCounts, + ); + + const hasFilterChips = + getActiveTicketListFilterProperties(filterProperties, ticketListTableProps.columnFilters) + .length > 0; + + const hasNonDefaultFilters = getAreTicketListFiltersNonDefault( + ticketListTableProps.columnFilters, + serverDefaultColumnFilters, + ); + + const visibleTotal = ticketListTableProps.paginatedTickets.total; + const unfilteredTotal = ticketListTableProps.paginatedTickets.unfilteredTotal; + return (
{ > +
+ + + + + {hasNonDefaultFilters ? ( + + ) : null} + +

+ {unfilteredTotal && unfilteredTotal !== visibleTotal + ? t('{{visible, number}} of {{total, number}} tickets', { + visible: visibleTotal, + total: unfilteredTotal, + count: unfilteredTotal, + }) + : t('{{val, number}} tickets', { count: visibleTotal, val: visibleTotal })} +

+
+ { + beforeEach(() => { + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + }); + + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given the filters do not actually narrow the full list, renders nothing', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.queryByTestId('chip-status')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chip-type')).not.toBeInTheDocument(); + }); + + it('given a filter that does narrow the list, renders a chip that reads as an accessible phrase', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByTestId('chip-status')).toHaveTextContent('Status is Resolved'); + }); + + it('given an unknown value, falls back to showing the raw unknown value', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByTestId('chip-status')).toHaveTextContent('nonsense'); + }); + + it('given the user clears a chip, returns its filter value to its nonfiltered state', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Remove Status filter' })); + + // ASSERT + const [updater] = setColumnFilters.mock.calls[0]; + expect(updater([{ id: 'status', value: ['resolved'] }])).toEqual([ + { id: 'status', value: ['all'] }, + ]); + }); + + it('given the user opens a chip, shows the possible filter values and lets the user select one', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Change Status filter' })); + await userEvent.keyboard('{Escape}'); + await userEvent.click(screen.getByRole('button', { name: 'Change Status filter' })); + await userEvent.click(screen.getByRole('menuitem', { name: /open/i })); + + // ASSERT + const [updater] = setColumnFilters.mock.calls[0]; + expect(updater([{ id: 'status', value: ['resolved'] }])).toEqual([ + { id: 'status', value: ['unresolved'] }, + ]); + }); + + it('given several filters can narrow the list of results, displays one chip for each filter', () => { + // ARRANGE + render( + , + ); + + // ASSERT + expect(screen.getByTestId('chip-status')).toBeVisible(); + expect(screen.getByTestId('chip-type')).toHaveTextContent('Did not trigger'); + }); +}); diff --git a/resources/js/features/tickets/components/TicketListFilterChips/TicketListFilterChips.tsx b/resources/js/features/tickets/components/TicketListFilterChips/TicketListFilterChips.tsx new file mode 100644 index 0000000000..13c9e46379 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterChips/TicketListFilterChips.tsx @@ -0,0 +1,117 @@ +import type { ColumnFiltersState, Updater } from '@tanstack/react-table'; +import { type FC, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { RxCross2 } from 'react-icons/rx'; + +import { + BaseDropdownMenu, + BaseDropdownMenuContent, + BaseDropdownMenuTrigger, +} from '@/common/components/+vendor/BaseDropdownMenu'; +import { cn } from '@/common/utils/cn'; + +import type { TicketListFilterProperty } from '../../models'; +import { getActiveTicketListFilterProperties } from '../../utils/getActiveTicketListFilterProperties'; +import { setTicketListColumnFilterValue } from '../../utils/setTicketListColumnFilterValue'; +import { TicketListFilterValueList } from '../TicketListFilterControl/TicketListFilterValueList'; + +interface TicketListFilterChipsProps { + columnFilters: ColumnFiltersState; + properties: TicketListFilterProperty[]; + setColumnFilters: (updaterOrValue: Updater) => void; +} + +export const TicketListFilterChips: FC = ({ + columnFilters, + properties, + setColumnFilters, +}) => { + const { t } = useTranslation(); + + const [openPropertyId, setOpenPropertyId] = useState(null); + + const activeProperties = getActiveTicketListFilterProperties(properties, columnFilters); + + if (!activeProperties.length) { + return null; + } + + const handleValueChange = (property: TicketListFilterProperty, value: string) => { + setColumnFilters((previousFilters) => + setTicketListColumnFilterValue(previousFilters, property.id, value), + ); + + setOpenPropertyId(null); + }; + + const segmentClassNames = + 'flex h-full items-center px-2 whitespace-nowrap hover:bg-neutral-800 light:hover:bg-neutral-100'; + + return ( + <> + {activeProperties.map(({ property, value }) => { + const selectedOption = property.options.find((option) => option.value === value); + + return ( +
+ setOpenPropertyId(nextIsOpen ? property.id : null)} + > + + + ), + }} + /> + + + + handleValueChange(property, nextValue)} + /> + + + + +
+ ); + })} + + ); +}; diff --git a/resources/js/features/tickets/components/TicketListFilterChips/index.ts b/resources/js/features/tickets/components/TicketListFilterChips/index.ts new file mode 100644 index 0000000000..6e90b6bc9b --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterChips/index.ts @@ -0,0 +1 @@ +export * from './TicketListFilterChips'; diff --git a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx new file mode 100644 index 0000000000..2b62c98221 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx @@ -0,0 +1,271 @@ +import userEvent from '@testing-library/user-event'; + +import { render, screen } from '@/test'; + +import type { TicketListFilterProperty } from '../../models'; +import { openPropertySubmenu } from '../../test/openPropertySubmenu'; +import { TicketListFilterControl } from './TicketListFilterControl'; + +const statusProperty: TicketListFilterProperty = { + id: 'status', + label: 'Status', + noFilterValue: 'all', + options: [ + { value: 'unresolved', label: 'Open', count: 487, glyphState: 'open' }, + { value: 'resolved', label: 'Resolved', count: 0, glyphState: 'resolved' }, + { value: 'closed', label: 'Closed', count: 12, glyphState: 'closed' }, + { value: 'all', label: 'All', count: 607 }, + ], +}; + +const emulatorProperty: TicketListFilterProperty = { + id: 'emulator', + label: 'Emulator', + noFilterValue: 'all', + options: [ + { value: 'all', label: 'All' }, + ...['Bizhawk', 'Dolphin', 'FCEUX', 'Gens', 'Mesen', 'PCSX2', 'RALibRetro', 'RetroArch'].map( + (name) => ({ value: name, label: name }), + ), + ], +}; + +const typeProperty: TicketListFilterProperty = { + id: 'type', + label: 'Type', + noFilterValue: '0', + options: [ + { value: '0', label: 'All' }, + { value: '2', label: 'Did not trigger' }, + ], +}; + +describe('Component: TicketListFilterControl', () => { + beforeEach(() => { + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + }); + + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + expect(screen.getByTestId('add-filter')).toBeVisible(); + }); + + it('keeps an accessible name when its text is hidden', () => { + // ARRANGE + render( + , + ); + + // ASSERT + const triggerEl = screen.getByTestId('add-filter'); + expect(triggerEl).toHaveTextContent(''); + expect(triggerEl).toHaveAccessibleName('Filter'); + }); + + it('given the user opens the menu, lists each possible property', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.click(screen.getByTestId('add-filter')); + + // ASSERT + expect(screen.getByTestId('filter-property-status')).toHaveTextContent('Status'); + expect(screen.getByTestId('filter-property-type')).toHaveTextContent('Type'); + }); + + it('given the user opens a property submenu, shows its values alongside associated counts', async () => { + // ARRANGE + render( + , + ); + + // ACT + await openPropertySubmenu(0); + + // ASSERT + expect(screen.getByRole('menuitem', { name: /open/i })).toHaveTextContent('487'); + expect(screen.getByRole('menuitem', { name: /resolved/i })).toHaveTextContent('0'); + }); + + it('given the user picks a value, sets the filter', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await openPropertySubmenu(1); + await userEvent.click(screen.getByRole('menuitem', { name: 'Did not trigger' })); + + // ASSERT + const [updater] = setColumnFilters.mock.calls[0]; + expect(updater([{ id: 'status', value: ['unresolved'] }])).toEqual([ + { id: 'status', value: ['unresolved'] }, + { id: 'type', value: ['2'] }, + ]); + }); + + it('given a status option, shows the same glyph the ticket rows use', async () => { + // ARRANGE + render( + , + ); + + // ACT + await openPropertySubmenu(0); + + // ASSERT + expect(screen.getByTestId('glyph-unresolved')).toBeVisible(); + expect(screen.getByTestId('glyph-resolved')).toBeVisible(); + expect(screen.queryByTestId('glyph-all')).not.toBeInTheDocument(); + }); + + it('given a property has a value, marks it, and marks the no-filter option when it has none', async () => { + // ARRANGE + const { rerender } = render( + , + ); + + // ACT + await openPropertySubmenu(0); + expect(screen.getByTestId('checked-resolved')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + rerender( + , + ); + await openPropertySubmenu(0); + + // ASSERT + expect(screen.getByTestId('checked-all')).toBeVisible(); + }); + + it('given a property with relatively few options, omits the search box due to the entire list already being visible', async () => { + // ARRANGE + render( + , + ); + + // ACT + await openPropertySubmenu(0); + + // ASSERT + expect(screen.queryByPlaceholderText('Search options...')).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /open/i })).toBeVisible(); + }); + + it('given a property with lots of possible values, displays a functional search box', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await openPropertySubmenu(0); + await userEvent.type(screen.getByPlaceholderText('Search options...'), 'retro'); + + // ASSERT + expect(screen.getByRole('option', { name: 'RetroArch' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Dolphin' })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', { name: 'RetroArch' })); + + const [updater] = setColumnFilters.mock.calls[0]; + expect(updater([])).toEqual([{ id: 'emulator', value: ['RetroArch'] }]); + }); + + it('given the search matches nothing, shows an empty state', async () => { + // ARRANGE + render( + , + ); + + // ACT + await openPropertySubmenu(0); + await userEvent.type(screen.getByPlaceholderText('Search options...'), 'zzz'); + + // ASSERT + expect(screen.getByText('No results found.')).toBeVisible(); + }); + + it('supports keyboard selection without a search field', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await openPropertySubmenu(0); + await userEvent.keyboard('{ArrowDown}{Enter}'); + + // ASSERT + const [updater] = setColumnFilters.mock.calls[0]; + expect(updater([])).toEqual([{ id: 'status', value: ['resolved'] }]); + }); +}); diff --git a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx new file mode 100644 index 0000000000..e9cfa97488 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx @@ -0,0 +1,87 @@ +import type { ColumnFiltersState, Updater } from '@tanstack/react-table'; +import { type FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RxPlusCircled } from 'react-icons/rx'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { + BaseDropdownMenu, + BaseDropdownMenuContent, + BaseDropdownMenuSub, + BaseDropdownMenuSubContent, + BaseDropdownMenuSubTrigger, + BaseDropdownMenuTrigger, +} from '@/common/components/+vendor/BaseDropdownMenu'; +import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; +import { cn } from '@/common/utils/cn'; + +import type { TicketListFilterProperty } from '../../models'; +import { getTicketListFilterValue } from '../../utils/getTicketListFilterValue'; +import { setTicketListColumnFilterValue } from '../../utils/setTicketListColumnFilterValue'; +import { TicketListFilterValueList } from './TicketListFilterValueList'; + +interface TicketListFilterControlProps { + columnFilters: ColumnFiltersState; + properties: TicketListFilterProperty[]; + setColumnFilters: (updaterOrValue: Updater) => void; + + isLabelHidden?: boolean; +} + +export const TicketListFilterControl: FC = ({ + columnFilters, + properties, + setColumnFilters, + isLabelHidden = false, +}) => { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const handleValueSelect = (property: TicketListFilterProperty, value: string) => { + setColumnFilters((previousFilters) => + setTicketListColumnFilterValue(previousFilters, property.id, value), + ); + + setIsOpen(false); + }; + + return ( + + + + + {isLabelHidden ? null : t('Filter')} + + + + + {properties.map((property) => ( + + + {property.label} + + + + handleValueSelect(property, value)} + /> + + + ))} + + + ); +}; diff --git a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterValueList.tsx b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterValueList.tsx new file mode 100644 index 0000000000..d1e456ab47 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterValueList.tsx @@ -0,0 +1,141 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { HiOutlineCheck } from 'react-icons/hi'; + +import { + BaseCommand, + BaseCommandEmpty, + BaseCommandGroup, + BaseCommandInput, + BaseCommandItem, + BaseCommandList, +} from '@/common/components/+vendor/BaseCommand'; +import { BaseDropdownMenuItem } from '@/common/components/+vendor/BaseDropdownMenu'; +import { useFormatNumber } from '@/common/hooks/useFormatNumber'; +import { cn } from '@/common/utils/cn'; + +import type { TicketListFilterProperty, TicketListFilterPropertyOption } from '../../models'; +import { TICKET_STATE_GLYPHS } from '../../utils/ticketStateGlyphs'; + +interface TicketListFilterValueListProps { + onSelect: (value: string) => void; + property: TicketListFilterProperty; + selectedValue: string; +} + +const MAX_OPTIONS_WITHOUT_SEARCH = 8; + +export const TicketListFilterValueList: FC = ({ + onSelect, + property, + selectedValue, +}) => { + const { t } = useTranslation(); + + const hasGlyphSlot = property.options.some((option) => option.glyphState); + + if (property.options.length <= MAX_OPTIONS_WITHOUT_SEARCH) { + return ( + <> + {property.options.map((option) => ( + onSelect(option.value)} + className="gap-0" + > + + + ))} + + ); + } + + return ( + // Keep the parent menu's typeahead from handling search input. + event.stopPropagation()}> + + + + {t('No results found.')} + + + {property.options.map((option) => ( + onSelect(option.value)} + className="items-center" + > + + + ))} + + + + ); +}; + +interface TicketListFilterValueRowProps { + hasGlyphSlot: boolean; + isSelected: boolean; + option: TicketListFilterPropertyOption; +} + +const TicketListFilterValueRow: FC = ({ + hasGlyphSlot, + isSelected, + option, +}) => { + const { formatNumber } = useFormatNumber(); + + const glyph = option.glyphState ? TICKET_STATE_GLYPHS[option.glyphState] : null; + + return ( + + + {isSelected ? ( + + ) : null} + + + {hasGlyphSlot ? ( + + {glyph ? ( + + ) : null} + + ) : null} + + {option.label} + + {option.count !== undefined ? ( + + {formatNumber(option.count)} + + ) : null} + + ); +}; diff --git a/resources/js/features/tickets/components/TicketListFilterControl/index.ts b/resources/js/features/tickets/components/TicketListFilterControl/index.ts new file mode 100644 index 0000000000..a991479119 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListFilterControl/index.ts @@ -0,0 +1 @@ +export * from './TicketListFilterControl'; diff --git a/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.test.tsx b/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.test.tsx new file mode 100644 index 0000000000..3538226d9a --- /dev/null +++ b/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.test.tsx @@ -0,0 +1,42 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from '@/test'; + +import { TicketListResetFiltersButton } from './TicketListResetFiltersButton'; + +const serverDefaultColumnFilters: ColumnFiltersState = [{ id: 'status', value: ['unresolved'] }]; + +describe('Component: TicketListResetFiltersButton', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + expect(screen.getByRole('button', { name: /reset/i })).toBeVisible(); + }); + + it('given the user clicks the button, restores the default filter values', async () => { + // ARRANGE + const setColumnFilters = vi.fn(); + + render( + , + ); + + // ACT + await userEvent.click(screen.getByRole('button', { name: /reset/i })); + + // ASSERT + expect(setColumnFilters).toHaveBeenCalledWith(serverDefaultColumnFilters); + }); +}); diff --git a/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.tsx b/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.tsx new file mode 100644 index 0000000000..325fa0e00e --- /dev/null +++ b/resources/js/features/tickets/components/TicketListResetFiltersButton/TicketListResetFiltersButton.tsx @@ -0,0 +1,30 @@ +import type { ColumnFiltersState, Updater } from '@tanstack/react-table'; +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RxCross2 } from 'react-icons/rx'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; + +interface TicketListResetFiltersButtonProps { + serverDefaultColumnFilters: ColumnFiltersState; + setColumnFilters: (updaterOrValue: Updater) => void; +} + +export const TicketListResetFiltersButton: FC = ({ + serverDefaultColumnFilters, + setColumnFilters, +}) => { + const { t } = useTranslation(); + + return ( + setColumnFilters(serverDefaultColumnFilters)} + className="px-2 text-link lg:px-3" + data-testid="reset-all-filters" + > + {t('Reset')} + + ); +}; diff --git a/resources/js/features/tickets/components/TicketListResetFiltersButton/index.ts b/resources/js/features/tickets/components/TicketListResetFiltersButton/index.ts new file mode 100644 index 0000000000..2bae6dec20 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListResetFiltersButton/index.ts @@ -0,0 +1 @@ +export * from './TicketListResetFiltersButton'; diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx index d71866add8..3acb7f7260 100644 --- a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx +++ b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx @@ -177,6 +177,7 @@ describe('Component: TicketListTable', () => { it('given a reporter and a developer, both user cells link to the user page', () => { // ARRANGE const ticket = createTicketListEntry({ + ticketableTitle: 'Ring Collector', author: createUser({ displayName: 'Dev' }), reporter: createUser({ displayName: 'Scott' }), }); diff --git a/resources/js/features/tickets/components/TicketStateGlyph/TicketStateGlyph.tsx b/resources/js/features/tickets/components/TicketStateGlyph/TicketStateGlyph.tsx index 1a4fdc3b5f..31bf862687 100644 --- a/resources/js/features/tickets/components/TicketStateGlyph/TicketStateGlyph.tsx +++ b/resources/js/features/tickets/components/TicketStateGlyph/TicketStateGlyph.tsx @@ -1,7 +1,5 @@ import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import type { IconType } from 'react-icons'; -import { LuCircle, LuCircleCheck, LuCircleDot, LuCircleSlash, LuCircleX } from 'react-icons/lu'; import { BaseTooltip, @@ -11,6 +9,7 @@ import { import { cn } from '@/common/utils/cn'; import { getTicketStateLabel } from '../../utils/getTicketStateLabel'; +import { TICKET_STATE_GLYPHS } from '../../utils/ticketStateGlyphs'; interface TicketStateGlyphProps { state: App.Community.Enums.TicketState; @@ -18,24 +17,11 @@ interface TicketStateGlyphProps { className?: string; } -/** - * Each state has a designated shape and color. This is a deliberate a11y choice to - * ensure the glyphs remain distinct for the ~10% of users who are colorblind. - */ -const stateGlyphs: Record = - { - open: { Icon: LuCircle, className: 'text-neutral-400 light:text-neutral-600' }, - request: { Icon: LuCircleDot, className: 'text-sky-300' }, - resolved: { Icon: LuCircleCheck, className: 'text-green-500' }, - closed: { Icon: LuCircleX, className: 'text-neutral-500' }, - quarantined: { Icon: LuCircleSlash, className: 'text-rose-500' }, - }; - export const TicketStateGlyph: FC = ({ state, className }) => { const { t } = useTranslation(); const label = getTicketStateLabel(state, t); - const { Icon, className: stateClassName } = stateGlyphs[state]; + const { Icon, className: stateClassName } = TICKET_STATE_GLYPHS[state]; return ( diff --git a/resources/js/features/tickets/hooks/ticketListQueryOptions.ts b/resources/js/features/tickets/hooks/ticketListQueryOptions.ts new file mode 100644 index 0000000000..5f3c53e60b --- /dev/null +++ b/resources/js/features/tickets/hooks/ticketListQueryOptions.ts @@ -0,0 +1,46 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; +import axios from 'axios'; +import { route } from 'ziggy-js'; + +import { buildTicketListFilterParams } from '../utils/buildTicketListFilterParams'; + +const ONE_MINUTE = 1 * 60 * 1000; + +export interface TicketListQueryData { + paginatedTickets: App.Data.PaginatedData; + stateCounts: App.Platform.Data.TicketListStateCounts; + facetCounts: Record>; +} + +export interface TicketListQueryOptionsInput { + columnFilters: ColumnFiltersState; + pageNumber: number; + scope: App.Platform.Enums.TicketListScope; + sortParam: string | null; +} + +export function buildTicketListQueryOptions({ + columnFilters, + pageNumber, + scope, + sortParam, +}: TicketListQueryOptionsInput) { + return { + queryKey: ['ticket-list', scope, sortParam, columnFilters, pageNumber], + + queryFn: async (): Promise => { + const response = await axios.get( + route('api.ticket.index', { + scope, + ...(sortParam ? { sort: sortParam } : {}), + ...buildTicketListFilterParams(columnFilters), + 'page[number]': pageNumber, + }), + ); + + return response.data; + }, + + staleTime: ONE_MINUTE, + }; +} diff --git a/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts b/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts index 5f1910af6b..99870dc60e 100644 --- a/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts +++ b/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts @@ -1,18 +1,26 @@ import { QueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; -interface UsePreloadedTicketListQueryClientProps { - pageNumber: number; - paginatedTickets: App.Data.PaginatedData; - passthroughParams: Record; - scope: App.Platform.Enums.TicketListScope; -} +import type { TicketListQueryData, TicketListQueryOptionsInput } from './ticketListQueryOptions'; +import { buildTicketListQueryOptions } from './ticketListQueryOptions'; + +/** + * We need to populate tanstack-query with an initial value during the + * server render, otherwise it will immediately fetch data we already + * have as soon as client-side hydration hits. + * + * This hook, combined with , lets us avoid this + * erroneous extra fetch for data we already have. + */ + +interface UsePreloadedTicketListQueryClientProps + extends TicketListQueryOptionsInput, TicketListQueryData {} export function usePreloadedTicketListQueryClient({ - pageNumber, + facetCounts, paginatedTickets, - passthroughParams, - scope, + stateCounts, + ...queryOptionsInput }: UsePreloadedTicketListQueryClientProps) { const [queryClient] = useState(() => new QueryClient()); @@ -22,10 +30,10 @@ export function usePreloadedTicketListQueryClient({ * From the user's perspective, it'll appear that they can never page, filter, sort, etc. */ useMemo(() => { - // This seed must use the exact key and payload shape the paginated - // query reads, otherwise the client refetches data it already has. - queryClient.setQueryData(['ticket-list', scope, passthroughParams, pageNumber], { + queryClient.setQueryData(buildTicketListQueryOptions(queryOptionsInput).queryKey, { paginatedTickets, + stateCounts, + facetCounts, }); /* eslint-disable react-compiler/react-compiler -- exhaustive-deps is intentionally constrained */ diff --git a/resources/js/features/tickets/hooks/useTicketListFilterLabels.ts b/resources/js/features/tickets/hooks/useTicketListFilterLabels.ts new file mode 100644 index 0000000000..0bda6dab56 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListFilterLabels.ts @@ -0,0 +1,79 @@ +import { useTranslation } from 'react-i18next'; + +import type { TranslatedString } from '@/types/i18next'; + +type FilterKind = App.Platform.Enums.TicketListFilterKind; + +/** + * Maps the filter kinds and option values the server sends to translated labels. + */ +export function useTicketListFilterLabels() { + const { t } = useTranslation(); + + const kindLabels: Record = { + type: t('Issue type'), + publishedStatus: t('Publish status'), + mode: t('Mode'), + developerType: t('Developer type'), + developer: t('Developer'), + reporter: t('Reporter'), + emulator: t('Emulator'), + }; + + const selfOrOthersLabels = { + all: t('All'), + self: t('Self'), + others: t('Others'), + }; + + const valueLabels: Record> = { + type: { + '0': t('All'), + '1': t('Triggered at the wrong time'), + '2': t('Did not trigger'), + }, + publishedStatus: { + all: t('All'), + published: t('Published'), + unpublished: t('Unpublished'), + }, + mode: { + all: t('All'), + hardcore: t('Hardcore'), + softcore: t('Casual'), + unspecified: t('Unspecified'), + }, + developerType: { + all: t('All'), + active: t('Active'), + junior: t('Junior'), + inactive: t('Inactive'), + }, + developer: selfOrOthersLabels, + reporter: selfOrOthersLabels, + emulator: { + all: t('All'), + unknown: t('Unknown'), + }, + }; + + const statusLabels: Record = { + unresolved: t('Open'), + request: t('Request'), + resolved: t('Resolved'), + closed: t('Closed'), + quarantined: t('Quarantined'), + all: t('All'), + }; + + const getFilterKindLabel = (kind: FilterKind): TranslatedString => kindLabels[kind]; + + const getFilterValueLabel = (kind: FilterKind, value: string): string => + valueLabels[kind][value] ?? value; + + const getStatusValueLabel = ( + value: App.Platform.Enums.TicketListStatusFilter, + ): TranslatedString => statusLabels[value]; + + return { getFilterKindLabel, getFilterValueLabel, getStatusValueLabel }; +} diff --git a/resources/js/features/tickets/hooks/useTicketListFilterProperties.ts b/resources/js/features/tickets/hooks/useTicketListFilterProperties.ts new file mode 100644 index 0000000000..62543e18d8 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListFilterProperties.ts @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; + +import type { TicketListFilterProperty } from '../models'; +import { useTicketListFilterLabels } from './useTicketListFilterLabels'; + +const STATUS_GLYPH_STATES: Record< + App.Platform.Enums.TicketListStatusFilter, + App.Community.Enums.TicketState | undefined +> = { + unresolved: 'open', + request: 'request', + resolved: 'resolved', + closed: 'closed', + quarantined: 'quarantined', + all: undefined, +}; + +const STATUS_VALUES = Object.keys( + STATUS_GLYPH_STATES, +) as App.Platform.Enums.TicketListStatusFilter[]; + +export function useTicketListFilterProperties( + availableFilters: App.Platform.Data.TicketListFilter[], + stateCounts: App.Platform.Data.TicketListStateCounts, + facetCounts: Record>, +): TicketListFilterProperty[] { + const { t } = useTranslation(); + + const { getFilterKindLabel, getFilterValueLabel, getStatusValueLabel } = + useTicketListFilterLabels(); + + const statusProperty: TicketListFilterProperty = { + id: 'status', + label: t('Status'), + noFilterValue: 'all', + + options: STATUS_VALUES.map((value) => ({ + value, + label: getStatusValueLabel(value), + count: stateCounts[value], + glyphState: STATUS_GLYPH_STATES[value], + })), + }; + + return [ + statusProperty, + ...availableFilters.map((filter) => { + const countsByValue = facetCounts[filter.kind]; + + return { + id: filter.kind, + label: getFilterKindLabel(filter.kind), + noFilterValue: filter.values[0], + options: filter.values.map((value) => ({ + value, + label: getFilterValueLabel(filter.kind, value), + count: countsByValue ? (countsByValue[value] ?? 0) : undefined, + })), + }; + }), + ]; +} diff --git a/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts index d8995a6f82..6ccd917757 100644 --- a/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts +++ b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts @@ -1,43 +1,21 @@ import type { QueryClient } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import axios from 'axios'; -import { route } from 'ziggy-js'; -const ONE_MINUTE = 1 * 60 * 1000; - -interface UseTicketListPaginatedQueryProps { - pageNumber: number; - passthroughParams: Record; - scope: App.Platform.Enums.TicketListScope; +import type { TicketListQueryOptionsInput } from './ticketListQueryOptions'; +import { buildTicketListQueryOptions } from './ticketListQueryOptions'; +interface UseTicketListPaginatedQueryProps extends TicketListQueryOptionsInput { queryClient?: QueryClient; } export function useTicketListPaginatedQuery({ - pageNumber, - passthroughParams, - scope, queryClient, + ...queryOptionsInput }: UseTicketListPaginatedQueryProps) { return useQuery( { - queryKey: ['ticket-list', scope, passthroughParams, pageNumber], - - queryFn: async () => { - const response = await axios.get<{ - paginatedTickets: App.Data.PaginatedData; - }>( - route('api.ticket.index', { - scope, - ...passthroughParams, - 'page[number]': pageNumber, - }), - ); - - return response.data; - }, + ...buildTicketListQueryOptions(queryOptionsInput), - staleTime: ONE_MINUTE, placeholderData: keepPreviousData, refetchOnWindowFocus: false, diff --git a/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts b/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts index 70e9664746..618380031f 100644 --- a/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts +++ b/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts @@ -1,13 +1,13 @@ import type { QueryClient } from '@tanstack/react-query'; -import axios from 'axios'; -import { route } from 'ziggy-js'; -const ONE_MINUTE = 1 * 60 * 1000; +import type { TicketListQueryOptionsInput } from './ticketListQueryOptions'; +import { buildTicketListQueryOptions } from './ticketListQueryOptions'; -interface UseTicketListPrefetchPaginationProps { - passthroughParams: Record; +interface UseTicketListPrefetchPaginationProps extends Omit< + TicketListQueryOptionsInput, + 'pageNumber' +> { queryClient: QueryClient; - scope: App.Platform.Enums.TicketListScope; } /** @@ -15,30 +15,11 @@ interface UseTicketListPrefetchPaginationProps { * wind up clicking the button. Queries are cheap, so prefetch the destination page. */ export function useTicketListPrefetchPagination({ - passthroughParams, queryClient, - scope, + ...queryOptionsInput }: UseTicketListPrefetchPaginationProps) { const prefetchPage = (pageNumber: number) => { - queryClient.prefetchQuery({ - queryKey: ['ticket-list', scope, passthroughParams, pageNumber], - - queryFn: async () => { - const response = await axios.get<{ - paginatedTickets: App.Data.PaginatedData; - }>( - route('api.ticket.index', { - scope, - ...passthroughParams, - 'page[number]': pageNumber, - }), - ); - - return response.data; - }, - - staleTime: ONE_MINUTE, - }); + queryClient.prefetchQuery(buildTicketListQueryOptions({ ...queryOptionsInput, pageNumber })); }; return { prefetchPage }; diff --git a/resources/js/features/tickets/hooks/useTicketListState.ts b/resources/js/features/tickets/hooks/useTicketListState.ts new file mode 100644 index 0000000000..51a4123b1e --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListState.ts @@ -0,0 +1,40 @@ +import type { ColumnFiltersState, Updater } from '@tanstack/react-table'; +import { useState } from 'react'; + +import { usePageProps } from '@/common/hooks/usePageProps'; + +import { resolveInitialTicketListColumnFilters } from '../utils/resolveInitialTicketListColumnFilters'; + +/** + * 🔴 You should only use this hook once in the entire component tree. + * It is a factory. The state is not global. + * Every invocation will create entirely new state values. + */ + +export function useTicketListState( + paginatedTickets: App.Data.PaginatedData, + serverDefaultColumnFilters: ColumnFiltersState, +) { + const { + ziggy: { query }, + } = usePageProps(); + + const [initialColumnFilters] = useState(() => + resolveInitialTicketListColumnFilters(query, serverDefaultColumnFilters), + ); + + const [columnFilters, setColumnFilters] = useState(initialColumnFilters); + const [pageNumber, setPageNumber] = useState(paginatedTickets.currentPage); + + const setColumnFiltersAndResetPage = (updaterOrValue: Updater) => { + setPageNumber(1); + setColumnFilters(updaterOrValue); + }; + + return { + columnFilters, + pageNumber, + setColumnFilters: setColumnFiltersAndResetPage, + setPageNumber, + }; +} diff --git a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts index 89ab19ec24..623f5ee889 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts @@ -1,58 +1,79 @@ import { dehydrate } from '@tanstack/react-query'; +import type { ColumnFiltersState } from '@tanstack/react-table'; import { useState } from 'react'; import { usePageProps } from '@/common/hooks/usePageProps'; -import { buildTicketListPassthroughParams } from '../utils/buildTicketListPassthroughParams'; import { usePreloadedTicketListQueryClient } from './usePreloadedTicketListQueryClient'; import { useTicketListPaginatedQuery } from './useTicketListPaginatedQuery'; import { useTicketListPrefetchPagination } from './useTicketListPrefetchPagination'; +import { useTicketListState } from './useTicketListState'; import { useTicketListTableSync } from './useTicketListTableSync'; interface UseTicketListTableRootOptions { + serverDefaultColumnFilters: ColumnFiltersState; + facetCounts: Record>; paginatedTickets: App.Data.PaginatedData; scope: App.Platform.Enums.TicketListScope; + stateCounts: App.Platform.Data.TicketListStateCounts; } -export function useTicketListTableRoot({ paginatedTickets, scope }: UseTicketListTableRootOptions) { +export function useTicketListTableRoot({ + serverDefaultColumnFilters, + facetCounts, + paginatedTickets, + scope, + stateCounts, +}: UseTicketListTableRootOptions) { const { ziggy: { query }, } = usePageProps(); - // temporary safeguard - const [passthroughParams] = useState(() => buildTicketListPassthroughParams(query)); + const [sortParam] = useState(() => + typeof query.sort === 'string' && query.sort.length > 0 ? query.sort : null, + ); - const [pageNumber, setPageNumber] = useState(paginatedTickets.currentPage); + const { columnFilters, pageNumber, setColumnFilters, setPageNumber } = useTicketListState( + paginatedTickets, + serverDefaultColumnFilters, + ); const { queryClientWithInitialData } = usePreloadedTicketListQueryClient({ + columnFilters, + facetCounts, pageNumber, paginatedTickets, - passthroughParams, scope, + sortParam, + stateCounts, }); - useTicketListTableSync(pageNumber); + useTicketListTableSync({ columnFilters, serverDefaultColumnFilters, pageNumber }); const ticketListQuery = useTicketListPaginatedQuery({ + columnFilters, pageNumber, - passthroughParams, scope, + sortParam, queryClient: queryClientWithInitialData, }); const { prefetchPage } = useTicketListPrefetchPagination({ - passthroughParams, + columnFilters, scope, + sortParam, queryClient: queryClientWithInitialData, }); return { hydrationState: dehydrate(queryClientWithInitialData), ticketListTableProps: { - isFetching: ticketListQuery.isFetching, - paginatedTickets: ticketListQuery.data?.paginatedTickets ?? paginatedTickets, + columnFilters, prefetchPage, + setColumnFilters, setPageNumber, + isFetching: ticketListQuery.isFetching, + ...(ticketListQuery.data ?? { paginatedTickets, stateCounts, facetCounts }), }, }; } diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts index 4c13b11aa7..cc65b41405 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts @@ -1,7 +1,25 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + import { renderHook } from '@/test'; import { useTicketListTableSync } from './useTicketListTableSync'; +const serverDefaultColumnFilters: ColumnFiltersState = [{ id: 'status', value: ['unresolved'] }]; + +type TicketListTableSyncProps = Parameters[0]; + +const defaultProps: TicketListTableSyncProps = { + columnFilters: serverDefaultColumnFilters, + serverDefaultColumnFilters, + pageNumber: 1, +}; + +function renderTicketListTableSync() { + return renderHook((props: TicketListTableSyncProps) => useTicketListTableSync(props), { + initialProps: defaultProps, + }); +} + function setWindowLocation(search: string) { Object.defineProperty(window, 'location', { writable: true, @@ -28,30 +46,23 @@ describe('Hook: useTicketListTableSync', () => { pushStateSpy.mockRestore(); }); - it('renders without crashing', () => { - // ARRANGE - const { result } = renderHook(() => useTicketListTableSync(1)); - - // ASSERT - expect(result).toBeDefined(); - }); - it('given it is the first render cycle, does not update URL params', () => { // ARRANGE - renderHook(() => useTicketListTableSync(1)); + renderTicketListTableSync(); // ASSERT expect(pushStateSpy).not.toHaveBeenCalled(); }); - it('given the user advances from page 1 to page 2, updates URL params accordingly', () => { + it('updates URL params when the page changes', () => { // ARRANGE - const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { - initialProps: 1, - }); + const { rerender } = renderTicketListTableSync(); // ACT - rerender(3); + rerender({ + ...defaultProps, + pageNumber: 3, + }); // ASSERT expect(pushStateSpy).toHaveBeenCalledWith( @@ -59,18 +70,37 @@ describe('Hook: useTicketListTableSync', () => { '', `/tickets2?${encodeURIComponent('page[number]')}=3`, ); + + // ACT + setWindowLocation('?page[number]=3'); + rerender({ + ...defaultProps, + }); + + // ASSERT + expect(pushStateSpy).toHaveBeenCalledWith({ inertia: true }, '', '/tickets2'); }); - it('given the user goes from page 2 to page 1, updates URL params accordingly', () => { + it('given a filter value changes from the default, the URL updates accordingly', () => { // ARRANGE - setWindowLocation('?page[number]=3'); + const { rerender } = renderTicketListTableSync(); - const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { - initialProps: 3, + // ACT + rerender({ + ...defaultProps, + columnFilters: [{ id: 'status', value: ['resolved'] }] as ColumnFiltersState, }); - // ACT - rerender(1); + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + '/tickets2?filter%5Bstatus%5D=resolved', + ); + + setWindowLocation('?filter[status]=resolved'); + rerender({ + ...defaultProps, + }); // ASSERT expect(pushStateSpy).toHaveBeenCalledWith({ inertia: true }, '', '/tickets2'); @@ -78,20 +108,21 @@ describe('Hook: useTicketListTableSync', () => { it('given the URL contains unrelated params, leaves them untouched', () => { // ARRANGE - setWindowLocation('?filter[status]=resolved&sort=state'); + setWindowLocation('?sort=state'); - const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { - initialProps: 1, - }); + const { rerender } = renderTicketListTableSync(); // ACT - rerender(2); + rerender({ + ...defaultProps, + pageNumber: 2, + }); // ASSERT expect(pushStateSpy).toHaveBeenCalledWith( { inertia: true }, '', - '/tickets2?filter%5Bstatus%5D=resolved&sort=state&page%5Bnumber%5D=2', + `/tickets2?sort=state&${encodeURIComponent('page[number]')}=2`, ); }); @@ -99,12 +130,13 @@ describe('Hook: useTicketListTableSync', () => { // ARRANGE setWindowLocation('?page%5Bnumber%5D=3'); - const { rerender } = renderHook((pageNumber: number) => useTicketListTableSync(pageNumber), { - initialProps: 1, - }); + const { rerender } = renderTicketListTableSync(); // ACT - rerender(3); + rerender({ + ...defaultProps, + pageNumber: 3, + }); // ASSERT expect(pushStateSpy).not.toHaveBeenCalled(); diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.ts index 72cbac5cd9..4855e3ea8d 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.ts @@ -1,20 +1,32 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; import { useUpdateEffect } from 'react-use'; +import { serializeTicketListSearchParams } from '../utils/serializeTicketListSearchParams'; + // TODO user's persistence cookie support +interface UseTicketListTableSyncProps { + columnFilters: ColumnFiltersState; + serverDefaultColumnFilters: ColumnFiltersState; + pageNumber: number; +} + /** - * This hook is designed to keep the URL query params and - * user's persistence cookie in sync with the table state. + * Keeps the URL in step with the table, so a refresh or a shared link + * lands on the same view. */ -export function useTicketListTableSync(pageNumber: number) { +export function useTicketListTableSync({ + columnFilters, + serverDefaultColumnFilters, + pageNumber, +}: UseTicketListTableSyncProps) { useUpdateEffect(() => { - const searchParams = new URLSearchParams(window.location.search); - - if (pageNumber > 1) { - searchParams.set('page[number]', String(pageNumber)); - } else { - searchParams.delete('page[number]'); - } + const searchParams = serializeTicketListSearchParams({ + columnFilters, + serverDefaultColumnFilters, + pageNumber, + currentSearch: window.location.search, + }); const newUrl = Array.from(searchParams).length ? `${window.location.pathname}?${searchParams.toString()}` @@ -27,5 +39,5 @@ export function useTicketListTableSync(pageNumber: number) { } window.history.pushState({ inertia: true }, '', newUrl); - }, [pageNumber]); + }, [columnFilters, pageNumber]); } diff --git a/resources/js/features/tickets/models/index.ts b/resources/js/features/tickets/models/index.ts new file mode 100644 index 0000000000..542b7a6c03 --- /dev/null +++ b/resources/js/features/tickets/models/index.ts @@ -0,0 +1,2 @@ +export * from './ticket-list-filter-property.model'; +export * from './ticket-list-filter-property-option.model'; diff --git a/resources/js/features/tickets/models/ticket-list-filter-property-option.model.ts b/resources/js/features/tickets/models/ticket-list-filter-property-option.model.ts new file mode 100644 index 0000000000..42f9b5f074 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-filter-property-option.model.ts @@ -0,0 +1,7 @@ +export interface TicketListFilterPropertyOption { + label: string; + value: string; + + count?: number; + glyphState?: App.Community.Enums.TicketState; +} diff --git a/resources/js/features/tickets/models/ticket-list-filter-property.model.ts b/resources/js/features/tickets/models/ticket-list-filter-property.model.ts new file mode 100644 index 0000000000..73aed48c8d --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-filter-property.model.ts @@ -0,0 +1,8 @@ +import type { TicketListFilterPropertyOption } from './ticket-list-filter-property-option.model'; + +export interface TicketListFilterProperty { + id: string; + label: string; + noFilterValue: string; + options: TicketListFilterPropertyOption[]; +} diff --git a/resources/js/features/tickets/test/openPropertySubmenu.ts b/resources/js/features/tickets/test/openPropertySubmenu.ts new file mode 100644 index 0000000000..da4643b7d7 --- /dev/null +++ b/resources/js/features/tickets/test/openPropertySubmenu.ts @@ -0,0 +1,20 @@ +import userEvent from '@testing-library/user-event'; + +import { screen } from '@/test'; + +/** + * Opens one property's value list in the ticket filter menu. + * + * Radix uses pointer events to trigger when submenus should open. + * JSDOM does not produce pointer events. This helper util drives + * the keyboard a11y path instead. + */ +export async function openPropertySubmenu(propertyIndex: number): Promise { + await userEvent.click(screen.getByTestId('add-filter')); + + for (let pressCount = 0; pressCount <= propertyIndex; pressCount += 1) { + await userEvent.keyboard('{ArrowDown}'); + } + + await userEvent.keyboard('{ArrowRight}'); +} diff --git a/resources/js/features/tickets/utils/buildTicketListFilterParams.ts b/resources/js/features/tickets/utils/buildTicketListFilterParams.ts new file mode 100644 index 0000000000..91a1894f4f --- /dev/null +++ b/resources/js/features/tickets/utils/buildTicketListFilterParams.ts @@ -0,0 +1,13 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +export function buildTicketListFilterParams( + columnFilters: ColumnFiltersState, +): Record { + const params: Record = {}; + + for (const columnFilter of columnFilters) { + params[`filter[${columnFilter.id}]`] = String(columnFilter.value); + } + + return params; +} diff --git a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts deleted file mode 100644 index f2ecb686da..0000000000 --- a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { buildTicketListPassthroughParams } from './buildTicketListPassthroughParams'; - -describe('Util: buildTicketListPassthroughParams', () => { - it('is defined', () => { - // ASSERT - expect(buildTicketListPassthroughParams).toBeDefined(); - }); - - it('given the URL carries a sort and filters, returns them as flat params', () => { - // ACT - const result = buildTicketListPassthroughParams({ - sort: 'state', - filter: { status: 'resolved', emulator: 'RetroArch' }, - }); - - // ASSERT - expect(result).toEqual({ - sort: 'state', - 'filter[status]': 'resolved', - 'filter[emulator]': 'RetroArch', - }); - }); - - it('given the URL is bare, returns no params', () => { - // ACT - const result = buildTicketListPassthroughParams({}); - - // ASSERT - expect(result).toEqual({}); - }); - - it('given empty or non-string values, drops them', () => { - // ACT - const result = buildTicketListPassthroughParams({ - sort: '', - filter: { status: '', mode: ['softcore'] as unknown as string }, - }); - - // ASSERT - expect(result).toEqual({}); - }); - - it('given the filter param is not an object, ignores it', () => { - // ACT - const result = buildTicketListPassthroughParams({ filter: 'resolved' }); - - // ASSERT - expect(result).toEqual({}); - }); -}); diff --git a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts b/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts deleted file mode 100644 index 8598dfdaf1..0000000000 --- a/resources/js/features/tickets/utils/buildTicketListPassthroughParams.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AppGlobalProps } from '@/common/models'; - -// temporary - this will be deleted -// there aren't any client-side controls for filtering and sorting -// if we don't have this temporary code, pagination will discard manually -// provided filter+sort params on the url - -export function buildTicketListPassthroughParams( - query: AppGlobalProps['ziggy']['query'], -): Record { - const params: Record = {}; - - if (typeof query.sort === 'string' && query.sort.length) { - params.sort = query.sort; - } - - const filterQuery = query.filter; - if (filterQuery && typeof filterQuery === 'object') { - for (const [filterKey, filterValue] of Object.entries(filterQuery)) { - if (typeof filterValue === 'string' && filterValue.length) { - params[`filter[${filterKey}]`] = filterValue; - } - } - } - - return params; -} diff --git a/resources/js/features/tickets/utils/getActiveTicketListFilterProperties.ts b/resources/js/features/tickets/utils/getActiveTicketListFilterProperties.ts new file mode 100644 index 0000000000..c284039789 --- /dev/null +++ b/resources/js/features/tickets/utils/getActiveTicketListFilterProperties.ts @@ -0,0 +1,26 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import type { TicketListFilterProperty } from '../models'; +import { getTicketListFilterValue } from './getTicketListFilterValue'; + +interface ActiveTicketListFilterProperty { + property: TicketListFilterProperty; + value: string; +} + +export function getActiveTicketListFilterProperties( + properties: TicketListFilterProperty[], + columnFilters: ColumnFiltersState, +): ActiveTicketListFilterProperty[] { + const active: ActiveTicketListFilterProperty[] = []; + + for (const property of properties) { + const value = getTicketListFilterValue(columnFilters, property.id); + + if (value !== null && value !== property.noFilterValue) { + active.push({ property, value }); + } + } + + return active; +} diff --git a/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.test.ts b/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.test.ts new file mode 100644 index 0000000000..a377af66c7 --- /dev/null +++ b/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.test.ts @@ -0,0 +1,56 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { getAreTicketListFiltersNonDefault } from './getAreTicketListFiltersNonDefault'; + +const serverDefaultColumnFilters: ColumnFiltersState = [ + { id: 'status', value: ['unresolved'] }, + { id: 'type', value: ['0'] }, +]; + +describe('Util: getAreTicketListFiltersNonDefault', () => { + it('is defined', () => { + // ASSERT + expect(getAreTicketListFiltersNonDefault).toBeDefined(); + }); + + it('given every filter has its current default value, returns false', () => { + // ACT + const result = getAreTicketListFiltersNonDefault( + [ + { id: 'status', value: ['unresolved'] }, + { id: 'type', value: ['0'] }, + ], + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual(false); + }); + + it('given any filter has a non-default value, returns true', () => { + // ACT + const movedStatus = getAreTicketListFiltersNonDefault( + [{ id: 'status', value: ['resolved'] }], + serverDefaultColumnFilters, + ); + const movedFacet = getAreTicketListFiltersNonDefault( + [{ id: 'type', value: ['2'] }], + serverDefaultColumnFilters, + ); + + // ASSERT + expect(movedStatus).toEqual(true); + expect(movedFacet).toEqual(true); + }); + + it('given a non-scoped filter, returns true', () => { + // ACT + const result = getAreTicketListFiltersNonDefault( + [{ id: 'emulator', value: ['RetroArch'] }], + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual(true); + }); +}); diff --git a/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.ts b/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.ts new file mode 100644 index 0000000000..ebee242fa2 --- /dev/null +++ b/resources/js/features/tickets/utils/getAreTicketListFiltersNonDefault.ts @@ -0,0 +1,21 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { normalizeTicketListFilterValue } from './normalizeTicketListFilterValue'; + +export function getAreTicketListFiltersNonDefault( + columnFilters: ColumnFiltersState, + serverDefaultColumnFilters: ColumnFiltersState, +): boolean { + const defaultValuesById = new Map( + serverDefaultColumnFilters.map((filter) => [ + filter.id, + normalizeTicketListFilterValue(filter.value), + ]), + ); + + return columnFilters.some( + (filter) => + !defaultValuesById.has(filter.id) || + defaultValuesById.get(filter.id) !== normalizeTicketListFilterValue(filter.value), + ); +} diff --git a/resources/js/features/tickets/utils/getTicketListFilterValue.ts b/resources/js/features/tickets/utils/getTicketListFilterValue.ts new file mode 100644 index 0000000000..8cb58dd649 --- /dev/null +++ b/resources/js/features/tickets/utils/getTicketListFilterValue.ts @@ -0,0 +1,15 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { normalizeTicketListFilterValue } from './normalizeTicketListFilterValue'; + +/** + * The current value of a single filter. Null when it's unset. + */ +export function getTicketListFilterValue( + columnFilters: ColumnFiltersState, + filterId: string, +): string | null { + const columnFilter = columnFilters.find((filter) => filter.id === filterId); + + return columnFilter ? normalizeTicketListFilterValue(columnFilter.value) : null; +} diff --git a/resources/js/features/tickets/utils/normalizeTicketListFilterValue.ts b/resources/js/features/tickets/utils/normalizeTicketListFilterValue.ts new file mode 100644 index 0000000000..168e55f2f3 --- /dev/null +++ b/resources/js/features/tickets/utils/normalizeTicketListFilterValue.ts @@ -0,0 +1,9 @@ +export function normalizeTicketListFilterValue(value: unknown): string | null { + const flatValue = Array.isArray(value) ? value.join(',') : value; + + if (flatValue === null || flatValue === undefined || flatValue === '') { + return null; + } + + return String(flatValue); +} diff --git a/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.test.ts b/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.test.ts new file mode 100644 index 0000000000..49be35dcd4 --- /dev/null +++ b/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.test.ts @@ -0,0 +1,67 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { resolveInitialTicketListColumnFilters } from './resolveInitialTicketListColumnFilters'; + +const serverDefaultColumnFilters: ColumnFiltersState = [{ id: 'status', value: ['unresolved'] }]; + +describe('Util: resolveInitialTicketListColumnFilters', () => { + it('is defined', () => { + // ASSERT + expect(resolveInitialTicketListColumnFilters).toBeDefined(); + }); + + it('given the URL is silent, falls back to the scope defaults', () => { + // ACT + const result = resolveInitialTicketListColumnFilters({}, serverDefaultColumnFilters); + + // ASSERT + expect(result).toEqual(serverDefaultColumnFilters); + }); + + it('given the URL carries a filter, uses it and still applies defaults the URL is silent about', () => { + // ACT + const result = resolveInitialTicketListColumnFilters( + { filter: { emulator: 'RetroArch' } }, + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual([ + { id: 'emulator', value: ['RetroArch'] }, + { id: 'status', value: ['unresolved'] }, + ]); + }); + + it('given the URL overrides a default filter kind, the URL wins', () => { + // ACT + const result = resolveInitialTicketListColumnFilters( + { filter: { status: 'resolved' } }, + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual([{ id: 'status', value: ['resolved'] }]); + }); + + it('given the URL filter values are empty strings, ignores them', () => { + // ACT + const result = resolveInitialTicketListColumnFilters( + { filter: { status: '' } }, + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual(serverDefaultColumnFilters); + }); + + it('given the URL filter param is not an object, ignores it', () => { + // ACT + const result = resolveInitialTicketListColumnFilters( + { filter: 'resolved' }, + serverDefaultColumnFilters, + ); + + // ASSERT + expect(result).toEqual(serverDefaultColumnFilters); + }); +}); diff --git a/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.ts b/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.ts new file mode 100644 index 0000000000..4c9ab6452f --- /dev/null +++ b/resources/js/features/tickets/utils/resolveInitialTicketListColumnFilters.ts @@ -0,0 +1,45 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import type { AppGlobalProps } from '@/common/models'; + +/** + * Resolves the filters the list starts with. The URL wins, and the scope + * defaults fill in whatever the URL is silent about, so the first client + * render matches the page the server just sent. + */ +export function resolveInitialTicketListColumnFilters( + query: AppGlobalProps['ziggy']['query'], + serverDefaultColumnFilters: ColumnFiltersState, +): ColumnFiltersState { + const filterQuery = getFilterQuery(query); + if (!filterQuery) { + return serverDefaultColumnFilters; + } + + const columnFilters: ColumnFiltersState = Object.entries(filterQuery).map( + ([filterKey, filterValue]) => ({ id: filterKey, value: [filterValue] }), + ); + + for (const defaultColumnFilter of serverDefaultColumnFilters) { + const isAlreadySet = columnFilters.some((filter) => filter.id === defaultColumnFilter.id); + if (!isAlreadySet) { + columnFilters.push(defaultColumnFilter); + } + } + + return columnFilters; +} + +function getFilterQuery(query: AppGlobalProps['ziggy']['query']): Record | null { + const filterQuery = query.filter; + + if (!filterQuery || typeof filterQuery !== 'object') { + return null; + } + + const stringEntries = Object.entries(filterQuery).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0, + ); + + return stringEntries.length ? Object.fromEntries(stringEntries) : null; +} diff --git a/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts b/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts new file mode 100644 index 0000000000..3c8488e864 --- /dev/null +++ b/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts @@ -0,0 +1,107 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { serializeTicketListSearchParams } from './serializeTicketListSearchParams'; + +const serverDefaultColumnFilters: ColumnFiltersState = [{ id: 'status', value: ['unresolved'] }]; + +describe('Util: serializeTicketListSearchParams', () => { + it('is defined', () => { + // ASSERT + expect(serializeTicketListSearchParams).toBeDefined(); + }); + + it('given page and filter state, serializes them while omitting defaults', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '', + columnFilters: [ + { id: 'status', value: ['resolved'] }, + { id: 'emulator', value: ['RetroArch'] }, + ], + pageNumber: 2, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual( + 'page%5Bnumber%5D=2&filter%5Bstatus%5D=resolved&filter%5Bemulator%5D=RetroArch', + ); + }); + + it('given every value equals its default, serializes to an empty string', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '?filter[status]=resolved&page[number]=3', + columnFilters: serverDefaultColumnFilters, + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual(''); + }); + + it('given a filter kind is no longer active, removes it from the current search', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '?filter[emulator]=RetroArch', + columnFilters: [{ id: 'status', value: ['resolved'] }], + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual('filter%5Bstatus%5D=resolved'); + }); + + it('given a filter with an empty value, removes it from the current search', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '?filter[emulator]=RetroArch', + columnFilters: [{ id: 'emulator', value: [] }], + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual(''); + }); + + it('given a filter value stored as a plain string, serializes it as-is', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '', + columnFilters: [{ id: 'status', value: 'all' }], + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual('filter%5Bstatus%5D=all'); + }); + + it('given no defaults are provided, treats every active filter as non-default', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '', + columnFilters: serverDefaultColumnFilters, + pageNumber: 1, + }); + + // ASSERT + expect(result.toString()).toEqual('filter%5Bstatus%5D=unresolved'); + }); + + it('given the URL has a param the list does not even own, leaves it alone', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '?sort=state', + columnFilters: serverDefaultColumnFilters, + pageNumber: 2, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual('sort=state&page%5Bnumber%5D=2'); + }); +}); diff --git a/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts b/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts new file mode 100644 index 0000000000..660393a10a --- /dev/null +++ b/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts @@ -0,0 +1,69 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import { normalizeTicketListFilterValue } from './normalizeTicketListFilterValue'; + +interface SerializeTicketListSearchParamsOptions { + columnFilters: ColumnFiltersState; + pageNumber: number; + + currentSearch?: string; + serverDefaultColumnFilters?: ColumnFiltersState; +} + +/** + * Builds the URL search params for the current list state. Values that + * equal the default params are omitted. + */ +export function serializeTicketListSearchParams({ + columnFilters, + pageNumber, + currentSearch = '', + serverDefaultColumnFilters = [], +}: SerializeTicketListSearchParamsOptions): URLSearchParams { + const searchParams = new URLSearchParams(currentSearch); + + if (pageNumber > 1) { + searchParams.set('page[number]', String(pageNumber)); + } else { + searchParams.delete('page[number]'); + } + + updateFilters(searchParams, columnFilters, serverDefaultColumnFilters); + + return searchParams; +} + +function updateFilters( + searchParams: URLSearchParams, + columnFilters: ColumnFiltersState, + serverDefaultColumnFilters: ColumnFiltersState, +): void { + const activeFilterKeys = new Set(columnFilters.map((filter) => `filter[${filter.id}]`)); + const defaultFilterValues = new Map( + serverDefaultColumnFilters.map((filter) => [ + filter.id, + normalizeTicketListFilterValue(filter.value), + ]), + ); + + for (const columnFilter of columnFilters) { + const filterKey = `filter[${columnFilter.id}]`; + const filterValue = normalizeTicketListFilterValue(columnFilter.value); + + const isDefaultValue = defaultFilterValues.get(columnFilter.id) === filterValue; + + if (isDefaultValue || filterValue === null) { + searchParams.delete(filterKey); + continue; + } + + searchParams.set(filterKey, filterValue); + } + + // Filters that were in the URL but are no longer active are stale. + for (const paramKey of Array.from(searchParams.keys())) { + if (paramKey.startsWith('filter[') && !activeFilterKeys.has(paramKey)) { + searchParams.delete(paramKey); + } + } +} diff --git a/resources/js/features/tickets/utils/setTicketListColumnFilterValue.ts b/resources/js/features/tickets/utils/setTicketListColumnFilterValue.ts new file mode 100644 index 0000000000..fb1208a0ad --- /dev/null +++ b/resources/js/features/tickets/utils/setTicketListColumnFilterValue.ts @@ -0,0 +1,22 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +/** + * Replaces one filter's value, or appends it when the filter is not yet + * set. Values are always wrapped in an array because that is the shape the + * URL resolver produces. + */ +export function setTicketListColumnFilterValue( + columnFilters: ColumnFiltersState, + filterId: string, + value: string, +): ColumnFiltersState { + const hasFilter = columnFilters.some((filter) => filter.id === filterId); + + if (!hasFilter) { + return [...columnFilters, { id: filterId, value: [value] }]; + } + + return columnFilters.map((filter) => + filter.id === filterId ? { ...filter, value: [value] } : filter, + ); +} diff --git a/resources/js/features/tickets/utils/ticketStateGlyphs.ts b/resources/js/features/tickets/utils/ticketStateGlyphs.ts new file mode 100644 index 0000000000..99f7ae39f1 --- /dev/null +++ b/resources/js/features/tickets/utils/ticketStateGlyphs.ts @@ -0,0 +1,17 @@ +import type { IconType } from 'react-icons'; +import { LuCircle, LuCircleCheck, LuCircleDot, LuCircleSlash, LuCircleX } from 'react-icons/lu'; + +/** + * Each state has a designated shape and color. This is a deliberate a11y choice to + * ensure the glyphs remain distinct for the ~10% of users who are colorblind. + */ +export const TICKET_STATE_GLYPHS: Record< + App.Community.Enums.TicketState, + { Icon: IconType; className: string } +> = { + open: { Icon: LuCircle, className: 'text-neutral-400 light:text-neutral-600' }, + request: { Icon: LuCircleDot, className: 'text-sky-300' }, + resolved: { Icon: LuCircleCheck, className: 'text-green-500' }, + closed: { Icon: LuCircleX, className: 'text-neutral-500' }, + quarantined: { Icon: LuCircleSlash, className: 'text-rose-500' }, +}; diff --git a/resources/js/test/factories/createTicketListStateCounts.ts b/resources/js/test/factories/createTicketListStateCounts.ts index 8211d35a94..ebd5507b1d 100644 --- a/resources/js/test/factories/createTicketListStateCounts.ts +++ b/resources/js/test/factories/createTicketListStateCounts.ts @@ -5,14 +5,16 @@ export const createTicketListStateCounts = createFactory; stateCounts: App.Platform.Data.TicketListStateCounts; availableFilters: Array; +facetCounts: Record>; game: App.Platform.Data.Game | null; achievement: App.Platform.Data.Achievement | null; user: App.Data.User | null; @@ -1222,6 +1223,7 @@ export type TicketListStateCounts = { unresolved: number; request: number; resolved: number; +closed: number; quarantined: number; all: number; }; @@ -1277,7 +1279,7 @@ export type ScreenshotType = 'title' | 'ingame' | 'completion'; export type TicketListFilterKind = 'type' | 'publishedStatus' | 'mode' | 'developerType' | 'developer' | 'reporter' | 'emulator'; export type TicketListScope = 'all' | 'game' | 'achievement' | 'assignedTo' | 'reportedBy' | 'awaitingReporter' | 'resolvedBy'; export type TicketListSortField = 'createdAt' | 'state' | 'resolvedAt'; -export type TicketListStatusFilter = 'all' | 'unresolved' | 'request' | 'resolved' | 'quarantined'; +export type TicketListStatusFilter = 'all' | 'unresolved' | 'request' | 'resolved' | 'closed' | 'quarantined'; export type TicketableType = 'achievement' | 'leaderboard' | 'game.rich-presence'; export type TriggerableType = 'achievement' | 'leaderboard' | 'game'; } diff --git a/tests/Feature/Platform/Actions/BuildTicketListActionTest.php b/tests/Feature/Platform/Actions/BuildTicketListActionTest.php index 1dbb8cbb0d..d1d79df9fb 100644 --- a/tests/Feature/Platform/Actions/BuildTicketListActionTest.php +++ b/tests/Feature/Platform/Actions/BuildTicketListActionTest.php @@ -77,6 +77,30 @@ function entryIds(array $result): array } describe('scope default values', function () { + it('excludes tickets for deleted ticketables from rows and counts', function () { + // ARRANGE + $fixture = createTicketListFixture(2); + $fixture['otherAchievement']->delete(); + + // ACT + $result = (new BuildTicketListAction())->execute( + TicketListScope::All, + null, + TicketListRequest::create('/internal-api/tickets', 'GET', [ + 'filter' => ['emulator' => 'unknown'], + ]), + ); + + // ASSERT + expect(entryIds($result))->toEqual([$fixture['tickets'][0]->id]); + expect($result['paginatedTickets']->total)->toEqual(1); + expect($result['paginatedTickets']->unfilteredTotal)->toEqual(1); + expect($result['stateCounts']->unresolved)->toEqual(1); + expect($result['stateCounts']->request)->toEqual(0); + expect($result['stateCounts']->all)->toEqual(1); + expect($result['facetCounts']['type']['0'])->toEqual(1); + }); + it('given scope is set to game and there is no query string, only the open and request tickets are returned for the game, sorted by newest tickets first', function () { // ARRANGE $developer = User::factory()->create(); @@ -177,6 +201,11 @@ function entryIds(array $result): array ]); Ticket::factory()->forAchievement($achievement)->open()->create($attributes); + Ticket::factory()->forAchievement($achievement)->open()->create([ + ...$attributes, + 'resolver_id' => $user->id, + ]); + // ACT $result = (new BuildTicketListAction())->execute( TicketListScope::ResolvedBy, diff --git a/tests/Feature/Platform/Controllers/Api/TicketApiControllerTest.php b/tests/Feature/Platform/Controllers/Api/TicketApiControllerTest.php index a0c12ce6a4..a8d8e84049 100644 --- a/tests/Feature/Platform/Controllers/Api/TicketApiControllerTest.php +++ b/tests/Feature/Platform/Controllers/Api/TicketApiControllerTest.php @@ -500,7 +500,7 @@ function createTicketIndexFixture(int $ticketCount = 3): array $response->assertUnauthorized(); }); - it('given scope game and a game id, the JSON has paginated tickets and four state counts', function () { + it('given scope game and a game id, the JSON has paginated tickets, every state count, and the facet counts', function () { // ARRANGE $fixture = createTicketIndexFixture(3); $this->actingAs(User::factory()->create()); @@ -515,11 +515,16 @@ function createTicketIndexFixture(int $ticketCount = 3): array $response->assertJsonCount(3, 'paginatedTickets.items'); $response->assertJsonStructure([ 'paginatedTickets' => ['currentPage', 'lastPage', 'perPage', 'total', 'unfilteredTotal', 'items', 'links'], - 'stateCounts' => ['unresolved', 'request', 'resolved', 'quarantined', 'all'], + 'stateCounts' => ['unresolved', 'request', 'resolved', 'closed', 'quarantined', 'all'], ]); $response->assertJsonPath('stateCounts.unresolved', 3); + $response->assertJsonPath('stateCounts.closed', 0); $response->assertJsonPath('stateCounts.all', 3); + $response->assertJsonStructure(['facetCounts' => ['type', 'mode', 'emulator']]); + $response->assertJsonPath('facetCounts.mode.all', 3); + $response->assertJsonMissingPath('facetCounts.developer'); + $ids = collect($response->json('paginatedTickets.items'))->pluck('id')->all(); $expectedIds = collect($fixture['tickets'])->sortByDesc('created_at')->pluck('id')->values()->all(); expect($ids)->toEqual($expectedIds); diff --git a/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php b/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php index d768f402ab..6ebebfc1e5 100644 --- a/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php +++ b/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php @@ -84,6 +84,7 @@ function createTicketListPageTickets(int $ticketCount): array ->has('unresolved') ->has('request') ->has('resolved') + ->has('closed') ->has('quarantined') ->has('all') ) diff --git a/tests/Feature/Platform/Services/TicketListServiceTest.php b/tests/Feature/Platform/Services/TicketListServiceTest.php index 37008b9f7f..b3f901d312 100644 --- a/tests/Feature/Platform/Services/TicketListServiceTest.php +++ b/tests/Feature/Platform/Services/TicketListServiceTest.php @@ -128,7 +128,8 @@ function sortedTicketIds(array $tickets): array })->with([ 'unresolved' => ['unresolved', ['open', 'request']], 'request' => ['request', ['request']], - 'resolved' => ['resolved', ['resolved', 'closed']], + 'resolved' => ['resolved', ['resolved']], + 'closed' => ['closed', ['closed']], 'quarantined' => ['quarantined', ['quarantined']], 'all' => ['all', ['open', 'request', 'resolved', 'closed', 'quarantined']], ]); From e5470620ca1edd8bcba821ec85f6befc6eda148d Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 22 Aug 2026 14:43:03 -0400 Subject: [PATCH 2/4] fix: address feedback --- .../+index/TicketIndexRoot.test.tsx | 10 +++++++- .../tickets/hooks/useTicketListState.ts | 8 ++++++- .../tickets/hooks/useTicketListTableRoot.ts | 13 +++++++---- .../hooks/useTicketListTableSync.test.ts | 17 ++++++++++++++ .../tickets/hooks/useTicketListTableSync.ts | 20 ++++++++++++++++ .../utils/readTicketListSearchParams.ts | 23 +++++++++++++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 resources/js/features/tickets/utils/readTicketListSearchParams.ts diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx index 6bf8592911..a7352d4f66 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx @@ -1,7 +1,7 @@ import userEvent from '@testing-library/user-event'; import axios from 'axios'; -import { render, screen, waitFor } from '@/test'; +import { act, render, screen, waitFor } from '@/test'; import { createGame, createPaginatedData, @@ -433,6 +433,14 @@ describe('Component: TicketIndexRoot', () => { '', expect.stringContaining('page%5Bnumber%5D=2'), ); + + await act(async () => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Ticket #1001' })).toBeVisible(); + }); }); it('given the user types a page number instead of clicking, the query fetches the page', async () => { diff --git a/resources/js/features/tickets/hooks/useTicketListState.ts b/resources/js/features/tickets/hooks/useTicketListState.ts index 51a4123b1e..a8f2f8ec52 100644 --- a/resources/js/features/tickets/hooks/useTicketListState.ts +++ b/resources/js/features/tickets/hooks/useTicketListState.ts @@ -31,10 +31,16 @@ export function useTicketListState( setColumnFilters(updaterOrValue); }; + const restoreState = (nextColumnFilters: ColumnFiltersState, nextPageNumber: number) => { + setColumnFilters(nextColumnFilters); + setPageNumber(nextPageNumber); + }; + return { columnFilters, pageNumber, - setColumnFilters: setColumnFiltersAndResetPage, + restoreState, setPageNumber, + setColumnFilters: setColumnFiltersAndResetPage, }; } diff --git a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts index 623f5ee889..6437f36a87 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts @@ -33,10 +33,8 @@ export function useTicketListTableRoot({ typeof query.sort === 'string' && query.sort.length > 0 ? query.sort : null, ); - const { columnFilters, pageNumber, setColumnFilters, setPageNumber } = useTicketListState( - paginatedTickets, - serverDefaultColumnFilters, - ); + const { columnFilters, pageNumber, restoreState, setColumnFilters, setPageNumber } = + useTicketListState(paginatedTickets, serverDefaultColumnFilters); const { queryClientWithInitialData } = usePreloadedTicketListQueryClient({ columnFilters, @@ -48,7 +46,12 @@ export function useTicketListTableRoot({ stateCounts, }); - useTicketListTableSync({ columnFilters, serverDefaultColumnFilters, pageNumber }); + useTicketListTableSync({ + columnFilters, + serverDefaultColumnFilters, + pageNumber, + restoreState, + }); const ticketListQuery = useTicketListPaginatedQuery({ columnFilters, diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts index cc65b41405..58695c3681 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts @@ -12,6 +12,7 @@ const defaultProps: TicketListTableSyncProps = { columnFilters: serverDefaultColumnFilters, serverDefaultColumnFilters, pageNumber: 1, + restoreState: vi.fn(), }; function renderTicketListTableSync() { @@ -141,4 +142,20 @@ describe('Hook: useTicketListTableSync', () => { // ASSERT expect(pushStateSpy).not.toHaveBeenCalled(); }); + + it('given the user navigates history, syncs state correctly', () => { + // ARRANGE + const restoreState = vi.fn(); + + renderHook((props: TicketListTableSyncProps) => useTicketListTableSync(props), { + initialProps: { ...defaultProps, restoreState }, + }); + + // ACT + setWindowLocation('?filter[status]=resolved&page[number]=3'); + window.dispatchEvent(new PopStateEvent('popstate')); + + // ASSERT + expect(restoreState).toHaveBeenCalledWith([{ id: 'status', value: ['resolved'] }], 3); + }); }); diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.ts index 4855e3ea8d..7e322a9224 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.ts @@ -1,6 +1,9 @@ import type { ColumnFiltersState } from '@tanstack/react-table'; +import { useEffect } from 'react'; import { useUpdateEffect } from 'react-use'; +import { readTicketListSearchParams } from '../utils/readTicketListSearchParams'; +import { resolveInitialTicketListColumnFilters } from '../utils/resolveInitialTicketListColumnFilters'; import { serializeTicketListSearchParams } from '../utils/serializeTicketListSearchParams'; // TODO user's persistence cookie support @@ -9,6 +12,7 @@ interface UseTicketListTableSyncProps { columnFilters: ColumnFiltersState; serverDefaultColumnFilters: ColumnFiltersState; pageNumber: number; + restoreState: (columnFilters: ColumnFiltersState, pageNumber: number) => void; } /** @@ -19,7 +23,23 @@ export function useTicketListTableSync({ columnFilters, serverDefaultColumnFilters, pageNumber, + restoreState, }: UseTicketListTableSyncProps) { + useEffect(() => { + const handlePopState = () => { + const restored = readTicketListSearchParams(window.location.search); + + restoreState( + resolveInitialTicketListColumnFilters(restored.query, serverDefaultColumnFilters), + restored.pageNumber, + ); + }; + + window.addEventListener('popstate', handlePopState); + + return () => window.removeEventListener('popstate', handlePopState); + }); + useUpdateEffect(() => { const searchParams = serializeTicketListSearchParams({ columnFilters, diff --git a/resources/js/features/tickets/utils/readTicketListSearchParams.ts b/resources/js/features/tickets/utils/readTicketListSearchParams.ts new file mode 100644 index 0000000000..edc04b12b3 --- /dev/null +++ b/resources/js/features/tickets/utils/readTicketListSearchParams.ts @@ -0,0 +1,23 @@ +import type { AppGlobalProps } from '@/common/models'; + +interface TicketListSearchParams { + pageNumber: number; + query: AppGlobalProps['ziggy']['query']; +} + +export function readTicketListSearchParams(search: string): TicketListSearchParams { + const searchParams = new URLSearchParams(search); + + const filter: Record = {}; + for (const [paramKey, paramValue] of searchParams) { + const filterKind = paramKey.match(/^filter\[(.+)\]$/)?.[1]; + if (filterKind) { + filter[filterKind] = paramValue; + } + } + + return { + pageNumber: Number(searchParams.get('page[number]') ?? 1), + query: { filter }, + }; +} From 299510a9c58e0555b51c0ee7e2ee338e5d4fbcc6 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 22 Aug 2026 14:57:50 -0400 Subject: [PATCH 3/4] fix: address feedback --- .../hooks/useTicketListTableSync.test.ts | 40 +++++++++++++++++++ .../tickets/hooks/useTicketListTableSync.ts | 31 +++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts index 58695c3681..aca6de5626 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts @@ -158,4 +158,44 @@ describe('Hook: useTicketListTableSync', () => { // ASSERT expect(restoreState).toHaveBeenCalledWith([{ id: 'status', value: ['resolved'] }], 3); }); + + it('given history restores a URL with different state, does not push a new history entry', () => { + // ARRANGE + const restoreState = vi.fn(); + + const { rerender } = renderHook( + (props: TicketListTableSyncProps) => useTicketListTableSync(props), + { + initialProps: { ...defaultProps, restoreState }, + }, + ); + + // ACT + setWindowLocation('?filter[status]=unresolved'); + window.dispatchEvent(new PopStateEvent('popstate')); + + const [restoredColumnFilters, restoredPageNumber] = restoreState.mock.calls[0]; + rerender({ + ...defaultProps, + restoreState, + columnFilters: restoredColumnFilters, + pageNumber: restoredPageNumber, + }); + + // ASSERT + expect(pushStateSpy).not.toHaveBeenCalled(); + + rerender({ + ...defaultProps, + restoreState, + columnFilters: restoredColumnFilters, + pageNumber: 2, + }); + + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true }, + '', + `/tickets2?${encodeURIComponent('page[number]')}=2`, + ); + }); }); diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.ts index 7e322a9224..1dcf8f1508 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.ts @@ -1,5 +1,5 @@ import type { ColumnFiltersState } from '@tanstack/react-table'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useUpdateEffect } from 'react-use'; import { readTicketListSearchParams } from '../utils/readTicketListSearchParams'; @@ -25,14 +25,24 @@ export function useTicketListTableSync({ pageNumber, restoreState, }: UseTicketListTableSyncProps) { + const restoredStateRef = useRef<{ + columnFilters: ColumnFiltersState; + pageNumber: number; + } | null>(null); + useEffect(() => { const handlePopState = () => { const restored = readTicketListSearchParams(window.location.search); - - restoreState( - resolveInitialTicketListColumnFilters(restored.query, serverDefaultColumnFilters), - restored.pageNumber, + const restoredColumnFilters = resolveInitialTicketListColumnFilters( + restored.query, + serverDefaultColumnFilters, ); + + restoredStateRef.current = { + columnFilters: restoredColumnFilters, + pageNumber: restored.pageNumber, + }; + restoreState(restoredColumnFilters, restored.pageNumber); }; window.addEventListener('popstate', handlePopState); @@ -41,6 +51,17 @@ export function useTicketListTableSync({ }); useUpdateEffect(() => { + const restoredState = restoredStateRef.current; + restoredStateRef.current = null; + + if ( + restoredState && + restoredState.columnFilters === columnFilters && + restoredState.pageNumber === pageNumber + ) { + return; + } + const searchParams = serializeTicketListSearchParams({ columnFilters, serverDefaultColumnFilters, From 6987a60d67c60e3c97b9d02acc91e719c2302159 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 26 Aug 2026 19:15:01 -0400 Subject: [PATCH 4/4] fix: address feedback --- .../TicketListFilterControl.test.tsx | 35 ++++++++++++++ .../TicketListFilterControl.tsx | 48 ++++++++++++------- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx index 2b62c98221..214f7a22d5 100644 --- a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx +++ b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.test.tsx @@ -97,6 +97,41 @@ describe('Component: TicketListFilterControl', () => { expect(screen.getByTestId('filter-property-type')).toHaveTextContent('Type'); }); + it('given a property has an active filter, shows a dot', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.click(screen.getByTestId('add-filter')); + + // ASSERT + expect(screen.getByTestId('filter-property-status-active')).toHaveAccessibleName('Active'); + expect(screen.queryByTestId('filter-property-type-active')).not.toBeInTheDocument(); + }); + + it('given a property does not have an active filter, does not show a dot', async () => { + // ARRANGE + render( + , + ); + + // ACT + await userEvent.click(screen.getByTestId('add-filter')); + + // ASSERT + expect(screen.queryByTestId('filter-property-status-active')).not.toBeInTheDocument(); + }); + it('given the user opens a property submenu, shows its values alongside associated counts', async () => { // ARRANGE render( diff --git a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx index e9cfa97488..614abfca16 100644 --- a/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx +++ b/resources/js/features/tickets/components/TicketListFilterControl/TicketListFilterControl.tsx @@ -64,23 +64,39 @@ export const TicketListFilterControl: FC = ({ - {properties.map((property) => ( - - - {property.label} - + {properties.map((property) => { + const selectedValue = + getTicketListFilterValue(columnFilters, property.id) ?? property.noFilterValue; + const isActive = selectedValue !== property.noFilterValue; - - handleValueSelect(property, value)} - /> - - - ))} + return ( + + + {property.label} + + {isActive ? ( + + ) : null} + + + + handleValueSelect(property, value)} + /> + + + ); + })} );