diff --git a/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php b/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php index 6634bf1f33..c690b951d1 100644 --- a/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php +++ b/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php @@ -27,14 +27,7 @@ public function execute( ): PaginatedData|array { $topics = $this->getRecentForumTopics($page, $permissions, $limit); - $shortcodeIds = []; - foreach ($topics as $topic) { - $postShortcodeIds = Shortcode::extractShortcodeIds($topic['ShortMsg']); - foreach ($postShortcodeIds as $key => $ids) { - $shortcodeIds[$key] = array_merge($shortcodeIds[$key] ?? [], $ids); - } - } - $shortcodeRecords = Shortcode::fetchRecords($shortcodeIds); + $shortcodeRecords = Shortcode::fetchRecordsFor(array_column($topics, 'ShortMsg')); $transformedTopics = array_map( fn ($topic) => ForumTopicData::fromRecentlyActiveTopic($topic, $shortcodeRecords)->include( diff --git a/app/Community/Actions/BuildThinRecentForumPostsDataAction.php b/app/Community/Actions/BuildThinRecentForumPostsDataAction.php index b533caa8ab..d4c9ddfe66 100644 --- a/app/Community/Actions/BuildThinRecentForumPostsDataAction.php +++ b/app/Community/Actions/BuildThinRecentForumPostsDataAction.php @@ -51,14 +51,7 @@ public function execute( ->limit($limit) ->get(); - $shortcodeIds = []; - foreach ($latestComments as $post) { - $postShortcodeIds = Shortcode::extractShortcodeIds($post->Payload); - foreach ($postShortcodeIds as $key => $ids) { - $shortcodeIds[$key] = array_merge($shortcodeIds[$key] ?? [], $ids); - } - } - $shortcodeRecords = Shortcode::fetchRecords($shortcodeIds); + $shortcodeRecords = Shortcode::fetchRecordsFor($latestComments->pluck('Payload')); return $latestComments->map(function ($post) use ($numMessageChars, $shortcodeRecords) { $postArray = (array) $post; diff --git a/app/Community/Components/ForumRecentActivity.php b/app/Community/Components/ForumRecentActivity.php index d69cf9b4fa..5af8dc5655 100644 --- a/app/Community/Components/ForumRecentActivity.php +++ b/app/Community/Components/ForumRecentActivity.php @@ -49,14 +49,7 @@ private function prepareRecentForumPosts(int $numToFetch = 4, int $userPermissio $isShowAbsoluteDatesPreferenceSet = $userPreferences && BitSet($userPreferences, UserPreference::Forum_ShowAbsoluteDates); - $shortcodeIds = []; - foreach ($rawRecentPosts as $rawRecentPost) { - $postShortcodeIds = Shortcode::extractShortcodeIds($rawRecentPost['Payload']); - foreach ($postShortcodeIds as $key => $ids) { - $shortcodeIds[$key] = array_merge($shortcodeIds[$key] ?? [], $ids); - } - } - $shortcodeRecords = Shortcode::fetchRecords($shortcodeIds); + $shortcodeRecords = Shortcode::fetchRecordsFor($rawRecentPosts->pluck('Payload')); foreach ($rawRecentPosts as $rawRecentPost) { $recentForumPosts[] = [ diff --git a/app/Community/Controllers/UserForumTopicCommentController.php b/app/Community/Controllers/UserForumTopicCommentController.php index 9c107f79da..557efe3a83 100644 --- a/app/Community/Controllers/UserForumTopicCommentController.php +++ b/app/Community/Controllers/UserForumTopicCommentController.php @@ -43,14 +43,7 @@ public function index(Request $request, User $user): InertiaResponse page: $page, ); - $shortcodeIds = []; - foreach ($posts as $post) { - $postShortcodeIds = Shortcode::extractShortcodeIds($post['ShortMsg']); - foreach ($postShortcodeIds as $key => $ids) { - $shortcodeIds[$key] = array_merge($shortcodeIds[$key] ?? [], $ids); - } - } - $shortcodeRecords = Shortcode::fetchRecords($shortcodeIds); + $shortcodeRecords = Shortcode::fetchRecordsFor(array_column($posts, 'ShortMsg')); $transformedPosts = array_map( fn ($post) => ForumTopicData::fromUserPost($post, $shortcodeRecords)->include('latestComment'), diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php index 87e2d14d8e..5275e07902 100755 --- a/app/Http/Middleware/EncryptCookies.php +++ b/app/Http/Middleware/EncryptCookies.php @@ -22,6 +22,7 @@ class EncryptCookies extends Middleware 'datatable_view_preference_setrequest_general_games', 'datatable_view_preference_setrequest_user_games', 'datatable_view_preference_system_games', + 'datatable_view_preference_tickets_all', 'hide_nonmissable_achievements_games', 'hide_unlocked_achievements_games', 'prefers_hidden_user_completed_sets', diff --git a/app/Platform/Controllers/TicketController.php b/app/Platform/Controllers/TicketController.php index 86041d8512..cdb255a3a8 100644 --- a/app/Platform/Controllers/TicketController.php +++ b/app/Platform/Controllers/TicketController.php @@ -32,15 +32,19 @@ public function index(TicketListRequest $request): InertiaResponse { $this->authorize('viewAny', Ticket::class); + $scope = TicketListScope::All; + $action = new BuildTicketListAction(); - $result = $action->execute(TicketListScope::All, null, $request); + $result = $action->execute($scope, null, $request); $props = new TicketListPagePropsData( - scope: TicketListScope::All, + scope: $scope, paginatedTickets: $result['paginatedTickets'], stateCounts: $result['stateCounts'], - availableFilters: $action->getAvailableFilters(TicketListScope::All), + availableFilters: $action->getAvailableFilters($scope), facetCounts: $result['facetCounts'], + persistenceCookieName: 'datatable_view_preference_tickets_all', + persistedViewPreferences: $request->getCookiePreferences(), ); return Inertia::render('tickets', $props); diff --git a/app/Platform/Data/TicketListPagePropsData.php b/app/Platform/Data/TicketListPagePropsData.php index 862edff413..d5ba87af03 100644 --- a/app/Platform/Data/TicketListPagePropsData.php +++ b/app/Platform/Data/TicketListPagePropsData.php @@ -27,6 +27,9 @@ public function __construct( public array $availableFilters, #[LiteralTypeScriptType('Record>')] public array $facetCounts, + public string $persistenceCookieName, + #[LiteralTypeScriptType('Record | null')] + public ?array $persistedViewPreferences = null, public ?GameData $game = null, public ?AchievementData $achievement = null, public ?UserData $user = null, diff --git a/app/Platform/Requests/TicketListRequest.php b/app/Platform/Requests/TicketListRequest.php index 1ba4064dc5..700ec57ceb 100644 --- a/app/Platform/Requests/TicketListRequest.php +++ b/app/Platform/Requests/TicketListRequest.php @@ -27,24 +27,53 @@ public function getPage(): int return (int) $this->input('page.number', 1); } + /** + * @return array|null + */ + public function getCookiePreferences(): ?array + { + return once(function (): ?array { + $cookie = $this->cookie('datatable_view_preference_tickets_all'); + if (!is_string($cookie)) { + return null; + } + + $preferences = json_decode($cookie, true); + + return is_array($preferences) ? $preferences : null; + }); + } + /** * @return array{field: TicketListSortField, direction: 'asc'|'desc'} */ public function getSort(): array { - $sortParam = $this->input('sort'); + $sortParam = $this->normalizeSortParam($this->input('sort')) + ?? $this->normalizeSortParam($this->getCookiePreferences()['sortParam'] ?? null) + ?? '-' . TicketListSortField::CreatedAt->value; - // Newest first is the default sort order. + $isDescending = str_starts_with($sortParam, '-'); + + return [ + 'field' => TicketListSortField::from(ltrim($sortParam, '-')), + 'direction' => $isDescending ? 'desc' : 'asc', + ]; + } + + private function normalizeSortParam(mixed $sortParam): ?string + { if (!is_string($sortParam) || $sortParam === '') { - return ['field' => TicketListSortField::CreatedAt, 'direction' => 'desc']; + return null; } - $direction = 'asc'; - if (str_starts_with($sortParam, '-')) { - $direction = 'desc'; - $sortParam = ltrim($sortParam, '-'); + $isDescending = str_starts_with($sortParam, '-'); + $fieldValue = $isDescending ? mb_substr($sortParam, 1) : $sortParam; + + if (TicketListSortField::tryFrom($fieldValue) === null) { + return null; } - return ['field' => TicketListSortField::from($sortParam), 'direction' => $direction]; + return $isDescending ? "-{$fieldValue}" : $fieldValue; } } diff --git a/app/Support/Shortcode/Shortcode.php b/app/Support/Shortcode/Shortcode.php index 53c781fe31..72a4e9abab 100644 --- a/app/Support/Shortcode/Shortcode.php +++ b/app/Support/Shortcode/Shortcode.php @@ -215,6 +215,22 @@ public static function extractShortcodeIds(string $input): array ]; } + /** + * @param iterable $inputs + */ + public static function fetchRecordsFor(iterable $inputs): array + { + $shortcodeIds = []; + + foreach ($inputs as $input) { + foreach (self::extractShortcodeIds($input) as $key => $ids) { + $shortcodeIds[$key] = array_merge($shortcodeIds[$key] ?? [], $ids); + } + } + + return self::fetchRecords($shortcodeIds); + } + public static function fetchRecords(array $shortcodeIds): array { $results = []; @@ -294,7 +310,7 @@ public static function stripAndClamp( $input = trim($input); } - if (!$shortcodeRecords) { + if ($shortcodeRecords === null) { $shortcodeIds = Shortcode::extractShortcodeIds($input); $shortcodeRecords = Shortcode::fetchRecords($shortcodeIds); } diff --git a/lang/en_US.json b/lang/en_US.json index e64c433b38..7289b47a35 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -1561,5 +1561,21 @@ "Issue type": "Issue type", "Change {{label}} filter": "Change {{label}} filter", "{{label}} is <1>{{value}}": "{{label}} is <1>{{value}}", - "Publish status": "Publish status" + "Publish status": "Publish status", + "Resolved by": "Resolved by", + "Version": "Version", + "Core": "Core", + "Hash": "Hash", + "Did not cancel": "Did not cancel", + "Did not start": "Did not start", + "Did not submit": "Did not submit", + "Submitted wrong value": "Submitted wrong value", + "Display": "Display", + "Sort": "Sort", + "Reset to defaults": "Reset to defaults", + "Sort by": "Sort by", + "Ascending": "Ascending", + "Descending": "Descending", + "Oldest first": "Oldest first", + "Newest first": "Newest first" } diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx index a7352d4f66..a9c91e8184 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.test.tsx @@ -32,6 +32,8 @@ function renderTicketIndexRoot(pageProps: TicketIndexRenderProps = {}) { stateCounts: createTicketListStateCounts(), availableFilters: [{ kind: 'type', values: ['0', '1', '2'] }], facetCounts: {}, + persistenceCookieName: 'datatable_view_preference_tickets_all', + persistedViewPreferences: null, ziggy: createZiggyProps({ query: {} }), ...pageProps, }, @@ -58,11 +60,13 @@ function createTicketListResponse( describe('Component: TicketIndexRoot', () => { it('shows the ticket manager list', () => { // ARRANGE + const getSpy = vi.spyOn(axios, 'get'); renderTicketIndexRoot(); // ASSERT expect(screen.getByTestId('ticket-list')).toBeVisible(); expect(screen.getByRole('heading', { level: 1, name: 'Ticket Manager' })).toBeVisible(); + expect(getSpy).not.toHaveBeenCalled(); }); it('renders every default column and one row per ticket from the page props', () => { @@ -262,7 +266,13 @@ describe('Component: TicketIndexRoot', () => { await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'quarantined', 'filter[type]': '0', 'page[number]': 1 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'quarantined', + 'filter[type]': '0', + 'page[number]': 1, + }, ]); }); @@ -271,13 +281,13 @@ describe('Component: TicketIndexRoot', () => { }); expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', expect.stringContaining('filter%5Bstatus%5D=quarantined'), ); expect(pushStateSpy).not.toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', expect.stringContaining('filter%5Btype%5D'), ); @@ -307,7 +317,13 @@ describe('Component: TicketIndexRoot', () => { await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'all', 'filter[type]': '0', 'page[number]': 1 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'all', + 'filter[type]': '0', + 'page[number]': 1, + }, ]); }); @@ -347,7 +363,13 @@ describe('Component: TicketIndexRoot', () => { await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'resolved', 'filter[type]': '0', 'page[number]': 1 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'resolved', + 'filter[type]': '0', + 'page[number]': 1, + }, ]); }); }); @@ -413,14 +435,26 @@ describe('Component: TicketIndexRoot', () => { await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 2 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'unresolved', + 'filter[type]': '0', + 'page[number]': 2, + }, ]); }); await waitFor(() => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 3 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'unresolved', + 'filter[type]': '0', + 'page[number]': 3, + }, ]); }); @@ -429,7 +463,7 @@ describe('Component: TicketIndexRoot', () => { }); expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', expect.stringContaining('page%5Bnumber%5D=2'), ); @@ -477,7 +511,13 @@ describe('Component: TicketIndexRoot', () => { () => { expect(getSpy).toHaveBeenCalledWith([ 'api.ticket.index', - { scope: 'all', 'filter[status]': 'unresolved', 'filter[type]': '0', 'page[number]': 3 }, + { + scope: 'all', + sort: '-createdAt', + 'filter[status]': 'unresolved', + 'filter[type]': '0', + 'page[number]': 3, + }, ]); }, { timeout: 2000 }, @@ -530,4 +570,56 @@ describe('Component: TicketIndexRoot', () => { ]); }); }); + + it('given the user hides a column then shows it again, the display returns to its default state', async () => { + // ARRANGE + renderTicketIndexRoot(); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + await userEvent.click(screen.getByTestId('column-toggle-game')); + + // ASSERT + expect(screen.queryByRole('columnheader', { name: 'Game' })).not.toBeInTheDocument(); + expect(screen.getByTestId('reset-display')).toBeVisible(); + + await userEvent.click(screen.getByTestId('column-toggle-game')); + + expect(screen.getByRole('columnheader', { name: 'Game' })).toBeVisible(); + expect(screen.queryByTestId('reset-display')).not.toBeInTheDocument(); + expect(screen.queryByTestId('display-changed-dot')).not.toBeInTheDocument(); + }); + + it('given only column visibility changed, resetting the display keeps the current page', async () => { + // ARRANGE + const getSpy = vi.spyOn(axios, 'get').mockResolvedValue({ + data: createTicketListResponse( + createPaginatedData([createTicketListEntry()], { + currentPage: 2, + lastPage: 3, + perPage: 50, + total: 150, + }), + ), + }); + + renderTicketIndexRoot({ + paginatedTickets: createPaginatedData([createTicketListEntry()], { + currentPage: 2, + lastPage: 3, + perPage: 50, + total: 150, + }), + ziggy: createZiggyProps({ query: { 'page[number]': '2' } }), + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + await userEvent.click(screen.getByTestId('column-toggle-game')); + await userEvent.click(screen.getByTestId('reset-display')); + + // ASSERT + expect(screen.getByRole('spinbutton', { name: 'current page number' })).toHaveValue(2); + expect(getSpy).not.toHaveBeenCalled(); + }); }); diff --git a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx index 895ea88f09..1142148225 100644 --- a/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx +++ b/resources/js/features/tickets/components/+index/TicketIndexRoot.tsx @@ -1,4 +1,3 @@ -import { HydrationBoundary } from '@tanstack/react-query'; import type { ColumnFiltersState } from '@tanstack/react-table'; import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,17 +10,13 @@ import { useTicketListFilterProperties } from '../../hooks/useTicketListFilterPr 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 { TicketListDisplayPanel } from '../TicketListDisplayPanel'; 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 = () => { @@ -36,7 +31,7 @@ export const TicketIndexRoot: FC = () => { ...availableFilters.map((filter) => ({ id: filter.kind, value: [filter.values[0]] })), ]; - const { hydrationState, ticketListTableProps } = useTicketListTableRoot({ + const { ticketListTableProps } = useTicketListTableRoot({ serverDefaultColumnFilters, facetCounts, paginatedTickets, @@ -100,27 +95,34 @@ export const TicketIndexRoot: FC = () => { }) : t('{{val, number}} tickets', { count: visibleTotal, val: visibleTotal })}

- - - } - isFetching={ticketListTableProps.isFetching} - paginatedTickets={ticketListTableProps.paginatedTickets} - paginatorNode={ -
- -
- } + columnVisibility={ticketListTableProps.columnVisibility} + hasColumnVisibilityOverrides={ticketListTableProps.hasColumnVisibilityOverrides} + onResetDisplay={ticketListTableProps.resetDisplay} + onSortChange={ticketListTableProps.setSortParam} + onToggleColumn={ticketListTableProps.toggleColumnVisibility} + sortParam={ticketListTableProps.sortParam} /> -
+ + + + + + } + /> ); }; diff --git a/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.test.tsx b/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.test.tsx new file mode 100644 index 0000000000..a38ff060f9 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.test.tsx @@ -0,0 +1,124 @@ +import type { VisibilityState } from '@tanstack/react-table'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'react'; + +import { render, screen } from '@/test'; +import type { TranslatedString } from '@/types/i18next'; + +import type { TicketListColumnDefinition } from '../../models'; +import { TICKET_LIST_COLUMN_IDS } from '../../utils/ticketListColumnIds'; +import { TicketListDisplayPanel } from './TicketListDisplayPanel'; + +const columnDefinitions: TicketListColumnDefinition[] = [ + { id: 'id', enableHiding: false, meta: { t_label: 'ID' as TranslatedString } }, + { id: 'game', meta: { t_label: 'Game' as TranslatedString } }, +]; + +const defaultColumnVisibility = Object.fromEntries( + TICKET_LIST_COLUMN_IDS.map((columnId) => [columnId, ['id', 'game'].includes(columnId)]), +) as VisibilityState; + +type TicketListDisplayPanelProps = ComponentProps; + +function renderTicketListDisplayPanel(overrides: Partial = {}) { + const props: TicketListDisplayPanelProps = { + columnDefinitions, + columnVisibility: defaultColumnVisibility, + hasColumnVisibilityOverrides: false, + onResetDisplay: vi.fn(), + onSortChange: vi.fn(), + onToggleColumn: vi.fn(), + sortParam: '-createdAt', + ...overrides, + }; + + return { ...render(), props }; +} + +describe('Component: TicketListDisplayPanel', () => { + beforeEach(() => { + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.setPointerCapture = vi.fn(); + }); + + it('given the user picks a different sort field, keeps the current direction', async () => { + // ARRANGE + const onSortChange = vi.fn(); + renderTicketListDisplayPanel({ onSortChange }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + await userEvent.click(screen.getByTestId('sort-field')); + await userEvent.click(screen.getByRole('option', { name: 'Status' })); + + // ASSERT + expect(onSortChange).toHaveBeenCalledWith('-state'); + }); + + it('given the user toggles the direction, flips it while keeping the field', async () => { + // ARRANGE + const onSortChange = vi.fn(); + renderTicketListDisplayPanel({ onSortChange }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + await userEvent.click(screen.getByTestId('toggle-sort-direction')); + + // ASSERT + expect(onSortChange).toHaveBeenCalledWith('createdAt'); + }); + + it('given an ascending date sort, labels the direction control as oldest first', async () => { + // ARRANGE + renderTicketListDisplayPanel({ sortParam: 'createdAt' }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + + // ASSERT + expect(screen.getByRole('button', { name: 'Oldest first' })).toBeVisible(); + }); + + it('given the user opens the display panel, does not focus the sort direction', async () => { + // ARRANGE + renderTicketListDisplayPanel(); + const displayButton = screen.getByRole('button', { name: 'Display' }); + + // ACT + await userEvent.click(displayButton); + + // ASSERT + expect(displayButton).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Newest first' })).not.toHaveFocus(); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('given the status sort, labels its directions as ascending and descending', async () => { + // ARRANGE + const { props, rerender } = renderTicketListDisplayPanel({ sortParam: 'state' }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + + // ASSERT + expect(screen.getByRole('button', { name: 'Ascending' })).toBeVisible(); + rerender(); + expect(screen.getByRole('button', { name: 'Descending' })).toBeVisible(); + }); + + it('given a column is hidden, marks its control as unpressed', async () => { + // ARRANGE + renderTicketListDisplayPanel({ + columnVisibility: { ...defaultColumnVisibility, game: false }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Display' })); + + // ASSERT + expect(screen.queryByTestId('column-toggle-id')).not.toBeInTheDocument(); + expect(screen.getByTestId('column-toggle-game')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('column-toggle-game')).toHaveAttribute('data-state', 'off'); + }); +}); diff --git a/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.tsx b/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.tsx new file mode 100644 index 0000000000..cef0f8e249 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListDisplayPanel/TicketListDisplayPanel.tsx @@ -0,0 +1,188 @@ +import type { VisibilityState } from '@tanstack/react-table'; +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LuArrowDownWideNarrow, LuArrowUpNarrowWide } from 'react-icons/lu'; +import { RxMixerHorizontal } from 'react-icons/rx'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; +import { + BasePopover, + BasePopoverContent, + BasePopoverTrigger, +} from '@/common/components/+vendor/BasePopover'; +import { + BaseSelect, + BaseSelectContent, + BaseSelectItem, + BaseSelectTrigger, + BaseSelectValue, +} from '@/common/components/+vendor/BaseSelect'; +import { BaseSeparator } from '@/common/components/+vendor/BaseSeparator'; +import { BaseToggle } from '@/common/components/+vendor/BaseToggle'; +import { + BaseTooltip, + BaseTooltipContent, + BaseTooltipTrigger, +} from '@/common/components/+vendor/BaseTooltip'; +import { buildTrackingClassNames } from '@/common/utils/buildTrackingClassNames'; +import { cn } from '@/common/utils/cn'; + +import type { + TicketListColumnDefinition, + TicketListColumnId, + TicketListSortParam, +} from '../../models'; +import { ticketListSort } from '../../utils/ticketListSort'; + +interface TicketListDisplayPanelProps { + columnDefinitions: TicketListColumnDefinition[]; + columnVisibility: VisibilityState; + hasColumnVisibilityOverrides: boolean; + onResetDisplay: () => void; + onSortChange: (sortParam: TicketListSortParam) => void; + onToggleColumn: (columnId: TicketListColumnId) => void; + sortParam: TicketListSortParam; +} + +export const TicketListDisplayPanel: FC = ({ + columnDefinitions, + columnVisibility, + hasColumnVisibilityOverrides, + onResetDisplay, + onSortChange, + onToggleColumn, + sortParam, +}) => { + const { t } = useTranslation(); + + const isAscending = ticketListSort.isAscending(sortParam); + const sortField = ticketListSort.field(sortParam); + + let directionLabel = isAscending ? t('Oldest first') : t('Newest first'); + if (sortField === 'state') { + directionLabel = isAscending ? t('Ascending') : t('Descending'); + } + + const sortFieldLabels = { + createdAt: t('Created'), + state: t('Status'), + resolvedAt: t('Resolved'), + }; + + const hasDisplayChanges = + hasColumnVisibilityOverrides || sortParam !== ticketListSort.defaultParam; + + const handleSortFieldChange = (field: App.Platform.Enums.TicketListSortField) => { + onSortChange(ticketListSort.build(field, isAscending)); + }; + + return ( + + + + + + {hasDisplayChanges ? ( + + + + event.preventDefault()} + > +
+ {t('Sort')} + +
+ + + onSortChange(ticketListSort.build(sortField, !isAscending))} + > + {isAscending ? ( + + ) : ( + + )} + + + + {directionLabel} + + + + + + + + + {ticketListSort.fields.map((field) => ( + + {sortFieldLabels[field]} + + ))} + + +
+
+ + + +

{t('Columns')}

+ +
+ {columnDefinitions + .filter((columnDefinition) => columnDefinition.enableHiding !== false) + .map((columnDefinition) => ( + onToggleColumn(columnDefinition.id)} + className={cn( + 'h-auto rounded-full border border-neutral-800 px-2.5 py-1', + 'text-xs font-normal text-neutral-500', + 'data-[state=on]:border-neutral-600 data-[state=on]:bg-neutral-700', + 'data-[state=on]:text-neutral-100 light:border-neutral-200 light:text-neutral-400', + 'data-[state=on]:light:border-neutral-300 data-[state=on]:light:bg-neutral-200 data-[state=on]:light:text-neutral-900', + )} + > + {columnDefinition.meta.t_label} + + ))} +
+ + {hasDisplayChanges ? ( + <> + + + + {t('Reset to defaults')} + + + ) : null} +
+
+ ); +}; diff --git a/resources/js/features/tickets/components/TicketListDisplayPanel/index.ts b/resources/js/features/tickets/components/TicketListDisplayPanel/index.ts new file mode 100644 index 0000000000..6e1fabb981 --- /dev/null +++ b/resources/js/features/tickets/components/TicketListDisplayPanel/index.ts @@ -0,0 +1 @@ +export * from './TicketListDisplayPanel'; diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx index 3acb7f7260..5bd59d949f 100644 --- a/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx +++ b/resources/js/features/tickets/components/TicketListTable/TicketListTable.test.tsx @@ -1,10 +1,13 @@ -import type { ColumnDef } from '@tanstack/react-table'; +import type { VisibilityState } from '@tanstack/react-table'; +import userEvent from '@testing-library/user-event'; import { FC } from 'react'; import { route } from 'ziggy-js'; -import { render, screen } from '@/test'; +import { render, screen, within } from '@/test'; import { + createEmulator, createGame, + createGameHash, createPaginatedData, createSystem, createTicketListEntry, @@ -12,14 +15,19 @@ import { } from '@/test/factories'; import { useTicketListColumnDefinitions } from '../../hooks/useTicketListColumnDefinitions'; +import type { TicketListColumnDefinition } from '../../models'; import { TICKET_LIST_COLUMN_IDS } from '../../utils/ticketListColumnIds'; import { TicketListTable } from './TicketListTable'; -const allVisible = Object.fromEntries(TICKET_LIST_COLUMN_IDS.map((id) => [id, true])); -const noneVisible = Object.fromEntries(TICKET_LIST_COLUMN_IDS.map((id) => [id, false])); +const allVisible = Object.fromEntries( + TICKET_LIST_COLUMN_IDS.map((id) => [id, true]), +) as VisibilityState; +const noneVisible = Object.fromEntries( + TICKET_LIST_COLUMN_IDS.map((id) => [id, false]), +) as VisibilityState; interface TestHarnessProps { - columnVisibility?: Record; + columnVisibility?: VisibilityState; emptyStateNode?: React.ReactNode; isFetching?: boolean; lastPage?: number; @@ -35,8 +43,7 @@ const TestHarness: FC = ({ lastPage = 1, tickets = [createTicketListEntry()], }) => { - const columnDefinitions: ColumnDef[] = - useTicketListColumnDefinitions(); + const columnDefinitions: TicketListColumnDefinition[] = useTicketListColumnDefinitions(); const paginatedTickets = createPaginatedData(tickets, { lastPage, @@ -77,7 +84,7 @@ describe('Component: TicketListTable', () => { expect(headers.map((header) => header.textContent)).toEqual(['ID', 'Issue with', 'Age']); }); - it('given every column is visible, renders all six headers in registry order', () => { + it('given every column is visible, renders all the headers in registry order', () => { // ARRANGE render(); @@ -86,8 +93,15 @@ describe('Component: TicketListTable', () => { 'ID', 'Issue with', 'Game', + 'Issue type', + 'Mode', 'Developer', 'Reporter', + 'Resolved by', + 'Emulator', + 'Version', + 'Core', + 'Hash', 'Age', ]); }); @@ -236,6 +250,24 @@ describe('Component: TicketListTable', () => { expect(route).not.toHaveBeenCalledWith('user.show', expect.anything()); }); + it('leaves an unassigned resolver empty but labels a deleted resolver', () => { + // ARRANGE + const unresolvedTicket = createTicketListEntry({ id: 640, state: 'open', resolver: null }); + const resolvedTicket = createTicketListEntry({ id: 641, state: 'resolved', resolver: null }); + + render( + , + ); + + // ASSERT + const unresolvedRow = screen.getByRole('row', { name: /Ticket #640/ }); + expect(within(unresolvedRow).getAllByRole('cell').at(-1)).toBeEmptyDOMElement(); + expect(screen.getAllByText('Deleted user')).toHaveLength(1); + }); + it('given a game with a badge and a short system name, links to the game and shows the system name inline', () => { // ARRANGE const ticket = createTicketListEntry({ @@ -322,4 +354,104 @@ describe('Component: TicketListTable', () => { // ASSERT expect(screen.queryByRole('presentation')).not.toBeInTheDocument(); }); + + it('given the type column is visible, shows the translated issue type', () => { + // ARRANGE + const ticket = createTicketListEntry({ type: 'submitted_wrong_value' }); + + render(); + + // ASSERT + expect(screen.getByText('Submitted wrong value')).toBeVisible(); + }); + + it('given the mode column is visible, shows the mode, and shows nothing when the mode is unknown', () => { + // ARRANGE + const hardcoreTicket = createTicketListEntry({ hardcore: true }); + const casualTicket = createTicketListEntry({ hardcore: false }); + const unknownTicket = createTicketListEntry({ id: 7303, hardcore: null }); + + render( + , + ); + + // ASSERT + expect(screen.getByText('Hardcore')).toBeVisible(); + expect(screen.getByText('Casual')).toBeVisible(); + expect( + within(screen.getByRole('row', { name: /Ticket #7303/ })).queryByText(/Hardcore|Casual/), + ).not.toBeInTheDocument(); + }); + + it('given the emulator columns are visible, shows the emulator, version, and core', () => { + // ARRANGE + const ticket = createTicketListEntry({ + emulator: createEmulator({ name: 'Bizhawk' }), + emulatorVersion: '2.9.1', + emulatorCore: 'mGBA', + }); + + render( + , + ); + + // ASSERT + expect(screen.getByText('Bizhawk')).toBeVisible(); + expect(screen.getByText('2.9.1')).toBeVisible(); + expect(screen.getByText('mGBA')).toBeVisible(); + }); + + it('given the hash column is visible, shows the hash name tags and falls back to a short md5', async () => { + // ARRANGE + const namedTicket = createTicketListEntry({ + gameHash: createGameHash({ + name: 'Sonic The Hedgehog (USA, Europe).md', + md5: 'aaaaaaaabbbbbbbbccccccccdddddddd', + }), + }); + const unnamedTicket = createTicketListEntry({ + gameHash: createGameHash({ name: null, md5: 'bbbbbbbbccccccccddddddddeeeeeeee' }), + }); + + render( + , + ); + + // ASSERT + expect(screen.getByText('(USA, Europe)')).toBeVisible(); + expect(screen.getByText('bbbbbbbb')).toBeVisible(); + + // ... the full name and md5 are available in a tooltip ... + await userEvent.hover(screen.getByText('(USA, Europe)')); + expect( + (await screen.findAllByText('Sonic The Hedgehog (USA, Europe).md'))[0], + ).toBeVisible(); + }); + + it('given the ticket has no emulator or hash, those cells stay empty', () => { + // ARRANGE + const ticket = createTicketListEntry({ id: 7404, emulator: null, gameHash: null }); + + render( + , + ); + + // ASSERT + const row = screen.getByRole('row', { name: /Ticket #7404/ }); + const [, emulatorCell, hashCell] = within(row).getAllByRole('cell'); + expect(emulatorCell.textContent).toEqual(''); + expect(hashCell.textContent).toEqual(''); + }); }); diff --git a/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx b/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx index 5d52f3f158..7e0da12ad1 100644 --- a/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx +++ b/resources/js/features/tickets/components/TicketListTable/TicketListTable.tsx @@ -1,11 +1,16 @@ -import type { ColumnDef, VisibilityState } from '@tanstack/react-table'; -import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + useReactTable, + type VisibilityState, +} from '@tanstack/react-table'; import { type FC, Fragment, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { route } from 'ziggy-js'; import { cn } from '@/common/utils/cn'; +import type { TicketListColumnDefinition } from '../../models'; import { TicketListEmptyState } from '../TicketListEmptyState'; import { TicketStateGlyph } from '../TicketStateGlyph'; import { TicketListMobileRow } from './TicketListMobileRow'; @@ -16,7 +21,7 @@ type TicketListTablePage = Pick< >; interface TicketListTableProps { - columnDefinitions: ColumnDef[]; + columnDefinitions: TicketListColumnDefinition[]; columnVisibility: VisibilityState; paginatedTickets: TicketListTablePage; diff --git a/resources/js/features/tickets/hooks/ticketListQueryOptions.ts b/resources/js/features/tickets/hooks/ticketListQueryOptions.ts index 5f3c53e60b..29723168a2 100644 --- a/resources/js/features/tickets/hooks/ticketListQueryOptions.ts +++ b/resources/js/features/tickets/hooks/ticketListQueryOptions.ts @@ -1,24 +1,11 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; import axios from 'axios'; import { route } from 'ziggy-js'; +import type { TicketListQueryData, TicketListQueryOptionsInput } from '../models'; 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, @@ -32,7 +19,7 @@ export function buildTicketListQueryOptions({ const response = await axios.get( route('api.ticket.index', { scope, - ...(sortParam ? { sort: sortParam } : {}), + sort: sortParam, ...buildTicketListFilterParams(columnFilters), 'page[number]': pageNumber, }), diff --git a/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts b/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts deleted file mode 100644 index 99870dc60e..0000000000 --- a/resources/js/features/tickets/hooks/usePreloadedTicketListQueryClient.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { QueryClient } from '@tanstack/react-query'; -import { useMemo, useState } from 'react'; - -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({ - facetCounts, - paginatedTickets, - stateCounts, - ...queryOptionsInput -}: UsePreloadedTicketListQueryClientProps) { - const [queryClient] = useState(() => new QueryClient()); - - /** - * It's very important to memoize the queryClient. - * If we don't, the whole queryClient will be reset on every single re-render. - * From the user's perspective, it'll appear that they can never page, filter, sort, etc. - */ - useMemo(() => { - queryClient.setQueryData(buildTicketListQueryOptions(queryOptionsInput).queryKey, { - paginatedTickets, - stateCounts, - facetCounts, - }); - - /* eslint-disable react-compiler/react-compiler -- exhaustive-deps is intentionally constrained */ - /* eslint-disable-next-line react-hooks/exhaustive-deps -- needed for ssr */ - }, [queryClient]); - - return { queryClientWithInitialData: queryClient }; -} diff --git a/resources/js/features/tickets/hooks/useTicketListColumnDefinitions.ts b/resources/js/features/tickets/hooks/useTicketListColumnDefinitions.ts index 2f085dffe0..b4025f442b 100644 --- a/resources/js/features/tickets/hooks/useTicketListColumnDefinitions.ts +++ b/resources/js/features/tickets/hooks/useTicketListColumnDefinitions.ts @@ -1,19 +1,51 @@ -import type { ColumnDef } from '@tanstack/react-table'; import { useTranslation } from 'react-i18next'; +import type { TicketListColumnDefinition } from '../models'; import { buildAgeColumnDef } from '../utils/column-definitions/buildAgeColumnDef'; import { buildGameColumnDef } from '../utils/column-definitions/buildGameColumnDef'; +import { buildHashColumnDef } from '../utils/column-definitions/buildHashColumnDef'; import { buildIdColumnDef } from '../utils/column-definitions/buildIdColumnDef'; import { buildTicketableColumnDef } from '../utils/column-definitions/buildTicketableColumnDef'; +import { buildTicketMetadataColumnDef } from '../utils/column-definitions/buildTicketMetadataColumnDef'; import { buildUserColumnDef } from '../utils/column-definitions/buildUserColumnDef'; -export function useTicketListColumnDefinitions(): ColumnDef[] { +export function useTicketListColumnDefinitions(): TicketListColumnDefinition[] { const { t } = useTranslation(); + const ticketTypeLabels: Record = { + did_not_cancel: t('Did not cancel'), + did_not_start: t('Did not start'), + did_not_submit: t('Did not submit'), + did_not_trigger: t('Did not trigger'), + submitted_wrong_value: t('Submitted wrong value'), + triggered_at_wrong_time: t('Triggered at the wrong time'), + }; + + const hardcoreLabel = t('Hardcore'); + const casualLabel = t('Casual'); + return [ buildIdColumnDef({ t_label: t('ID') }), buildTicketableColumnDef({ t_label: t('Issue with') }), buildGameColumnDef({ t_label: t('Game') }), + buildTicketMetadataColumnDef({ + id: 'type', + t_label: t('Issue type'), + getText: (entry) => ticketTypeLabels[entry.type], + widthClassName: 'w-[12em] flex-none', + }), + buildTicketMetadataColumnDef({ + id: 'mode', + t_label: t('Mode'), + getText: (entry) => { + if (entry.hardcore === null) { + return null; + } + + return entry.hardcore ? hardcoreLabel : casualLabel; + }, + widthClassName: 'w-[6em] flex-none', + }), buildUserColumnDef({ id: 'developer', @@ -25,6 +57,29 @@ export function useTicketListColumnDefinitions(): ColumnDef entry.reporter, }), + buildUserColumnDef({ + id: 'resolver', + t_label: t('Resolved by'), + getUser: (entry) => entry.resolver, + }), + + buildTicketMetadataColumnDef({ + id: 'emulator', + t_label: t('Emulator'), + getText: (entry) => entry.emulator?.name ?? null, + }), + buildTicketMetadataColumnDef({ + id: 'version', + t_label: t('Version'), + getText: (entry) => entry.emulatorVersion, + widthClassName: 'w-[7em] flex-none', + }), + buildTicketMetadataColumnDef({ + id: 'core', + t_label: t('Core'), + getText: (entry) => entry.emulatorCore, + }), + buildHashColumnDef({ t_label: t('Hash') }), buildAgeColumnDef({ t_label: t('Age') }), ]; diff --git a/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts index 6ccd917757..a4597c7eb9 100644 --- a/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts +++ b/resources/js/features/tickets/hooks/useTicketListPaginatedQuery.ts @@ -1,21 +1,30 @@ -import type { QueryClient } from '@tanstack/react-query'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { hashKey, keepPreviousData, QueryClient, useQuery } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; -import type { TicketListQueryOptionsInput } from './ticketListQueryOptions'; +import type { TicketListQueryData, TicketListQueryOptionsInput } from '../models'; import { buildTicketListQueryOptions } from './ticketListQueryOptions'; interface UseTicketListPaginatedQueryProps extends TicketListQueryOptionsInput { - queryClient?: QueryClient; + initialData: TicketListQueryData; } export function useTicketListPaginatedQuery({ - queryClient, + initialData, ...queryOptionsInput }: UseTicketListPaginatedQueryProps) { - return useQuery( + const [queryClient] = useState(() => new QueryClient()); + + const queryOptions = buildTicketListQueryOptions(queryOptionsInput); + + const initialQueryHash = useRef(hashKey(queryOptions.queryKey)).current; + + const isInitialQuery = hashKey(queryOptions.queryKey) === initialQueryHash; + + const { data, isFetching } = useQuery( { - ...buildTicketListQueryOptions(queryOptionsInput), + ...queryOptions, + initialData: isInitialQuery ? initialData : undefined, placeholderData: keepPreviousData, refetchOnWindowFocus: false, @@ -23,4 +32,10 @@ export function useTicketListPaginatedQuery({ }, queryClient, ); + + const prefetchPage = (pageNumber: number) => { + queryClient.prefetchQuery(buildTicketListQueryOptions({ ...queryOptionsInput, pageNumber })); + }; + + return { data: data!, isFetching, prefetchPage }; } diff --git a/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts b/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts deleted file mode 100644 index 618380031f..0000000000 --- a/resources/js/features/tickets/hooks/useTicketListPrefetchPagination.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { QueryClient } from '@tanstack/react-query'; - -import type { TicketListQueryOptionsInput } from './ticketListQueryOptions'; -import { buildTicketListQueryOptions } from './ticketListQueryOptions'; - -interface UseTicketListPrefetchPaginationProps extends Omit< - TicketListQueryOptionsInput, - 'pageNumber' -> { - queryClient: QueryClient; -} - -/** - * Given the user hovers over a pagination button, it is very likely they will - * wind up clicking the button. Queries are cheap, so prefetch the destination page. - */ -export function useTicketListPrefetchPagination({ - queryClient, - ...queryOptionsInput -}: UseTicketListPrefetchPaginationProps) { - const prefetchPage = (pageNumber: number) => { - queryClient.prefetchQuery(buildTicketListQueryOptions({ ...queryOptionsInput, pageNumber })); - }; - - return { prefetchPage }; -} diff --git a/resources/js/features/tickets/hooks/useTicketListState.test.ts b/resources/js/features/tickets/hooks/useTicketListState.test.ts new file mode 100644 index 0000000000..0db364fc24 --- /dev/null +++ b/resources/js/features/tickets/hooks/useTicketListState.test.ts @@ -0,0 +1,89 @@ +import { act, renderHook } from '@/test'; +import { createPaginatedData, createZiggyProps } from '@/test/factories'; + +import { useTicketListState } from './useTicketListState'; + +const serverDefaultColumnFilters = [{ id: 'status', value: ['unresolved'] }]; + +function renderTicketListState({ + currentPage = 1, + persistedViewPreferences = null, + query = {}, +}: { + currentPage?: number; + persistedViewPreferences?: unknown; + query?: Record>; +} = {}) { + return renderHook( + () => + useTicketListState( + createPaginatedData([], { currentPage }), + serverDefaultColumnFilters, + ), + { + pageProps: { + persistedViewPreferences, + ziggy: createZiggyProps({ query }), + }, + }, + ); +} + +describe('Hook: useTicketListState', () => { + it('uses persisted display preferences and resets the page when the sort changes', () => { + // ARRANGE + const persistedViewPreferences = { + columnVisibility: { game: false, type: true }, + sortParam: 'state', + }; + + // ACT + const { result } = renderTicketListState({ currentPage: 3, persistedViewPreferences }); + + // ASSERT + expect(result.current.columnVisibilityOverrides).toEqual({ game: false, type: true }); + expect(result.current.sortParam).toBe('state'); + expect(result.current.pageNumber).toBe(3); + + act(() => result.current.setSortParam('createdAt')); + + expect(result.current.sortParam).toBe('createdAt'); + expect(result.current.pageNumber).toBe(1); + }); + + it('prefers a URL sort over the persisted sort', () => { + // ARRANGE + const persistedViewPreferences = { + columnVisibility: {}, + sortParam: 'state', + }; + + // ACT + const { result } = renderTicketListState({ + persistedViewPreferences, + query: { sort: '-resolvedAt' }, + }); + + // ASSERT + expect(result.current.sortParam).toBe('-resolvedAt'); + }); + + it('ignores invalid persisted display preferences', () => { + // ARRANGE + const persistedViewPreferences = { + columnVisibility: { + game: false, + id: false, + unknown: true, + }, + sortParam: 'garbage', + }; + + // ACT + const { result } = renderTicketListState({ persistedViewPreferences }); + + // ASSERT + expect(result.current.columnVisibilityOverrides).toEqual({ game: false }); + expect(result.current.sortParam).toBe('-createdAt'); + }); +}); diff --git a/resources/js/features/tickets/hooks/useTicketListState.ts b/resources/js/features/tickets/hooks/useTicketListState.ts index a8f2f8ec52..0a45644581 100644 --- a/resources/js/features/tickets/hooks/useTicketListState.ts +++ b/resources/js/features/tickets/hooks/useTicketListState.ts @@ -3,7 +3,10 @@ import { useState } from 'react'; import { usePageProps } from '@/common/hooks/usePageProps'; +import type { TicketListSortParam, TicketListUrlState } from '../models'; import { resolveInitialTicketListColumnFilters } from '../utils/resolveInitialTicketListColumnFilters'; +import { resolveTicketListViewPreferences } from '../utils/resolveTicketListViewPreferences'; +import { ticketListSort } from '../utils/ticketListSort'; /** * 🔴 You should only use this hook once in the entire component tree. @@ -16,31 +19,57 @@ export function useTicketListState( serverDefaultColumnFilters: ColumnFiltersState, ) { const { + persistedViewPreferences, ziggy: { query }, } = usePageProps(); + const [initialViewPreferences] = useState(() => + resolveTicketListViewPreferences(persistedViewPreferences), + ); + const [initialColumnFilters] = useState(() => resolveInitialTicketListColumnFilters(query, serverDefaultColumnFilters), ); const [columnFilters, setColumnFilters] = useState(initialColumnFilters); const [pageNumber, setPageNumber] = useState(paginatedTickets.currentPage); + const [sortParam, setSortParam] = useState(() => + ticketListSort.resolve(query.sort, initialViewPreferences.sortParam), + ); + + const [columnVisibilityOverrides, setColumnVisibilityOverrides] = useState( + initialViewPreferences.columnVisibility, + ); const setColumnFiltersAndResetPage = (updaterOrValue: Updater) => { setPageNumber(1); setColumnFilters(updaterOrValue); }; - const restoreState = (nextColumnFilters: ColumnFiltersState, nextPageNumber: number) => { - setColumnFilters(nextColumnFilters); - setPageNumber(nextPageNumber); + const setSortParamAndResetPage = (nextSortParam: TicketListSortParam) => { + if (nextSortParam === sortParam) { + return; + } + + setPageNumber(1); + setSortParam(nextSortParam); + }; + + const restoreState = (urlState: TicketListUrlState) => { + setColumnFilters(urlState.columnFilters); + setPageNumber(urlState.pageNumber); + setSortParam(urlState.sortParam); }; return { columnFilters, + columnVisibilityOverrides, pageNumber, restoreState, + setColumnVisibilityOverrides, setPageNumber, + sortParam, setColumnFilters: setColumnFiltersAndResetPage, + setSortParam: setSortParamAndResetPage, }; } diff --git a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts index 6437f36a87..a06cac9173 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableRoot.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableRoot.ts @@ -1,12 +1,11 @@ -import { dehydrate } from '@tanstack/react-query'; import type { ColumnFiltersState } from '@tanstack/react-table'; -import { useState } from 'react'; -import { usePageProps } from '@/common/hooks/usePageProps'; - -import { usePreloadedTicketListQueryClient } from './usePreloadedTicketListQueryClient'; +import type { TicketListColumnId } from '../models'; +import { getTicketListDefaultColumnVisibility } from '../utils/getTicketListDefaultColumnVisibility'; +import { getTicketListFilterValue } from '../utils/getTicketListFilterValue'; +import { ticketListSort } from '../utils/ticketListSort'; +import { toggleTicketListColumnOverride } from '../utils/toggleTicketListColumnOverride'; import { useTicketListPaginatedQuery } from './useTicketListPaginatedQuery'; -import { useTicketListPrefetchPagination } from './useTicketListPrefetchPagination'; import { useTicketListState } from './useTicketListState'; import { useTicketListTableSync } from './useTicketListTableSync'; @@ -26,57 +25,66 @@ export function useTicketListTableRoot({ stateCounts, }: UseTicketListTableRootOptions) { const { - ziggy: { query }, - } = usePageProps(); - - const [sortParam] = useState(() => - typeof query.sort === 'string' && query.sort.length > 0 ? query.sort : null, - ); - - const { columnFilters, pageNumber, restoreState, setColumnFilters, setPageNumber } = - useTicketListState(paginatedTickets, serverDefaultColumnFilters); - - const { queryClientWithInitialData } = usePreloadedTicketListQueryClient({ columnFilters, - facetCounts, + columnVisibilityOverrides, pageNumber, - paginatedTickets, - scope, + restoreState, + setColumnFilters, + setColumnVisibilityOverrides, + setPageNumber, + setSortParam, sortParam, - stateCounts, - }); + } = useTicketListState(paginatedTickets, serverDefaultColumnFilters); + + const statusValue = getTicketListFilterValue( + columnFilters, + 'status', + ) as App.Platform.Enums.TicketListStatusFilter; + + const defaultColumnVisibility = getTicketListDefaultColumnVisibility(scope, statusValue); + + const toggleColumnVisibility = (columnId: TicketListColumnId) => { + setColumnVisibilityOverrides((previousOverrides) => + toggleTicketListColumnOverride(previousOverrides, defaultColumnVisibility, columnId), + ); + }; + + const resetDisplay = () => { + setColumnVisibilityOverrides({}); + setSortParam(ticketListSort.defaultParam); + }; useTicketListTableSync({ columnFilters, + columnVisibilityOverrides, serverDefaultColumnFilters, pageNumber, restoreState, + sortParam, }); const ticketListQuery = useTicketListPaginatedQuery({ columnFilters, + initialData: { facetCounts, paginatedTickets, stateCounts }, pageNumber, scope, sortParam, - queryClient: queryClientWithInitialData, - }); - - const { prefetchPage } = useTicketListPrefetchPagination({ - columnFilters, - scope, - sortParam, - queryClient: queryClientWithInitialData, }); return { - hydrationState: dehydrate(queryClientWithInitialData), ticketListTableProps: { columnFilters, - prefetchPage, + resetDisplay, setColumnFilters, setPageNumber, + setSortParam, + sortParam, + toggleColumnVisibility, + prefetchPage: ticketListQuery.prefetchPage, + columnVisibility: { ...defaultColumnVisibility, ...columnVisibilityOverrides }, + hasColumnVisibilityOverrides: Object.keys(columnVisibilityOverrides).length > 0, isFetching: ticketListQuery.isFetching, - ...(ticketListQuery.data ?? { paginatedTickets, stateCounts, facetCounts }), + ...ticketListQuery.data, }, }; } diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts index aca6de5626..0c13143142 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.test.ts @@ -10,17 +10,28 @@ type TicketListTableSyncProps = Parameters[0]; const defaultProps: TicketListTableSyncProps = { columnFilters: serverDefaultColumnFilters, + columnVisibilityOverrides: {}, serverDefaultColumnFilters, pageNumber: 1, restoreState: vi.fn(), + sortParam: '-createdAt', }; function renderTicketListTableSync() { return renderHook((props: TicketListTableSyncProps) => useTicketListTableSync(props), { initialProps: defaultProps, + pageProps: { + persistenceCookieName: 'datatable_view_preference_tickets_all', + }, }); } +function readCookieWrite(cookieWrite: string, cookieName: string): unknown { + const cookieMatch = cookieWrite.match(new RegExp(`${cookieName}=(.+?);`)); + + return JSON.parse(decodeURIComponent(cookieMatch![1])); +} + function setWindowLocation(search: string) { Object.defineProperty(window, 'location', { writable: true, @@ -30,6 +41,7 @@ function setWindowLocation(search: string) { describe('Hook: useTicketListTableSync', () => { let pushStateSpy: ReturnType; + let replaceStateSpy: ReturnType; let originalLocation: Location; beforeEach(() => { @@ -37,6 +49,7 @@ describe('Hook: useTicketListTableSync', () => { setWindowLocation(''); pushStateSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + replaceStateSpy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); }); afterEach(() => { @@ -45,14 +58,62 @@ describe('Hook: useTicketListTableSync', () => { value: originalLocation, }); pushStateSpy.mockRestore(); + replaceStateSpy.mockRestore(); }); it('given it is the first render cycle, does not update URL params', () => { // ARRANGE + const setCookieSpy = vi.spyOn(document, 'cookie', 'set'); renderTicketListTableSync(); // ASSERT expect(pushStateSpy).not.toHaveBeenCalled(); + expect(setCookieSpy).not.toHaveBeenCalled(); + }); + + it('persists only the sort and column visibility overrides', () => { + // ARRANGE + const cookieName = 'datatable_view_preference_tickets_all'; + const setCookieSpy = vi.spyOn(document, 'cookie', 'set'); + const { rerender } = renderTicketListTableSync(); + + // ACT + rerender({ + ...defaultProps, + columnVisibilityOverrides: { game: false }, + pageNumber: 3, + sortParam: 'state', + }); + + // ASSERT + const lastCookieWrite = setCookieSpy.mock.calls.at(-1)![0]; + expect(readCookieWrite(lastCookieWrite, cookieName)).toEqual({ + columnVisibility: { game: false }, + sortParam: 'state', + }); + }); + + it('persists the defaults when the display is reset', () => { + // ARRANGE + const cookieName = 'datatable_view_preference_tickets_all'; + const setCookieSpy = vi.spyOn(document, 'cookie', 'set'); + const { rerender } = renderTicketListTableSync(); + + rerender({ + ...defaultProps, + columnVisibilityOverrides: { game: false }, + sortParam: 'state', + }); + + // ACT + rerender(defaultProps); + + // ASSERT + const lastCookieWrite = setCookieSpy.mock.calls.at(-1)![0]; + expect(readCookieWrite(lastCookieWrite, cookieName)).toEqual({ + columnVisibility: {}, + sortParam: '-createdAt', + }); }); it('updates URL params when the page changes', () => { @@ -67,7 +128,7 @@ describe('Hook: useTicketListTableSync', () => { // ASSERT expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', `/tickets2?${encodeURIComponent('page[number]')}=3`, ); @@ -79,7 +140,11 @@ describe('Hook: useTicketListTableSync', () => { }); // ASSERT - expect(pushStateSpy).toHaveBeenCalledWith({ inertia: true }, '', '/tickets2'); + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true, ticketListSortParam: '-createdAt' }, + '', + '/tickets2', + ); }); it('given a filter value changes from the default, the URL updates accordingly', () => { @@ -93,7 +158,7 @@ describe('Hook: useTicketListTableSync', () => { }); expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', '/tickets2?filter%5Bstatus%5D=resolved', ); @@ -104,12 +169,16 @@ describe('Hook: useTicketListTableSync', () => { }); // ASSERT - expect(pushStateSpy).toHaveBeenCalledWith({ inertia: true }, '', '/tickets2'); + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true, ticketListSortParam: '-createdAt' }, + '', + '/tickets2', + ); }); it('given the URL contains unrelated params, leaves them untouched', () => { // ARRANGE - setWindowLocation('?sort=state'); + setWindowLocation('?highlight=17'); const { rerender } = renderTicketListTableSync(); @@ -121,9 +190,27 @@ describe('Hook: useTicketListTableSync', () => { // ASSERT expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, + '', + `/tickets2?highlight=17&${encodeURIComponent('page[number]')}=2`, + ); + }); + + it('given the sort changes, the URL updates accordingly', () => { + // ARRANGE + const { rerender } = renderTicketListTableSync(); + + // ACT + rerender({ + ...defaultProps, + sortParam: 'state', + }); + + // ASSERT + expect(pushStateSpy).toHaveBeenCalledWith( + { inertia: true, ticketListSortParam: 'state' }, '', - `/tickets2?sort=state&${encodeURIComponent('page[number]')}=2`, + '/tickets2?sort=state', ); }); @@ -152,11 +239,51 @@ describe('Hook: useTicketListTableSync', () => { }); // ACT - setWindowLocation('?filter[status]=resolved&page[number]=3'); + setWindowLocation('?filter[status]=resolved&page[number]=3&sort=state'); window.dispatchEvent(new PopStateEvent('popstate')); // ASSERT - expect(restoreState).toHaveBeenCalledWith([{ id: 'status', value: ['resolved'] }], 3); + expect(restoreState).toHaveBeenCalledWith({ + columnFilters: [{ id: 'status', value: ['resolved'] }], + pageNumber: 3, + sortParam: 'state', + }); + }); + + it('given the initial sort comes from persistence, restores it when navigating back', () => { + // ARRANGE + const restoreState = vi.fn(); + const { rerender } = renderHook( + (props: TicketListTableSyncProps) => useTicketListTableSync(props), + { + initialProps: { ...defaultProps, restoreState, sortParam: 'state' }, + }, + ); + + expect(replaceStateSpy).toHaveBeenCalledWith( + expect.objectContaining({ ticketListSortParam: 'state' }), + '', + ); + + rerender({ + ...defaultProps, + pageNumber: 2, + restoreState, + sortParam: 'state', + }); + + // ACT + setWindowLocation(''); + window.dispatchEvent( + new PopStateEvent('popstate', { state: { ticketListSortParam: 'state' } }), + ); + + // ASSERT + expect(restoreState).toHaveBeenCalledWith({ + columnFilters: serverDefaultColumnFilters, + pageNumber: 1, + sortParam: 'state', + }); }); it('given history restores a URL with different state, does not push a new history entry', () => { @@ -174,12 +301,11 @@ describe('Hook: useTicketListTableSync', () => { setWindowLocation('?filter[status]=unresolved'); window.dispatchEvent(new PopStateEvent('popstate')); - const [restoredColumnFilters, restoredPageNumber] = restoreState.mock.calls[0]; + const [restoredState] = restoreState.mock.calls[0]; rerender({ ...defaultProps, + ...restoredState, restoreState, - columnFilters: restoredColumnFilters, - pageNumber: restoredPageNumber, }); // ASSERT @@ -187,13 +313,13 @@ describe('Hook: useTicketListTableSync', () => { rerender({ ...defaultProps, + ...restoredState, restoreState, - columnFilters: restoredColumnFilters, pageNumber: 2, }); expect(pushStateSpy).toHaveBeenCalledWith( - { inertia: true }, + { inertia: true, ticketListSortParam: '-createdAt' }, '', `/tickets2?${encodeURIComponent('page[number]')}=2`, ); diff --git a/resources/js/features/tickets/hooks/useTicketListTableSync.ts b/resources/js/features/tickets/hooks/useTicketListTableSync.ts index 1dcf8f1508..89c9ac5331 100644 --- a/resources/js/features/tickets/hooks/useTicketListTableSync.ts +++ b/resources/js/features/tickets/hooks/useTicketListTableSync.ts @@ -1,18 +1,26 @@ -import type { ColumnFiltersState } from '@tanstack/react-table'; +import type { ColumnFiltersState, VisibilityState } from '@tanstack/react-table'; import { useEffect, useRef } from 'react'; -import { useUpdateEffect } from 'react-use'; +import { useCookie, useUpdateEffect } from 'react-use'; +import { usePageProps } from '@/common/hooks/usePageProps'; + +import type { TicketListSortParam, TicketListUrlState } from '../models'; import { readTicketListSearchParams } from '../utils/readTicketListSearchParams'; import { resolveInitialTicketListColumnFilters } from '../utils/resolveInitialTicketListColumnFilters'; import { serializeTicketListSearchParams } from '../utils/serializeTicketListSearchParams'; +import { ticketListSort } from '../utils/ticketListSort'; -// TODO user's persistence cookie support +interface TicketListHistoryState { + ticketListSortParam?: unknown; +} interface UseTicketListTableSyncProps { columnFilters: ColumnFiltersState; + columnVisibilityOverrides: VisibilityState; serverDefaultColumnFilters: ColumnFiltersState; pageNumber: number; - restoreState: (columnFilters: ColumnFiltersState, pageNumber: number) => void; + restoreState: (urlState: TicketListUrlState) => void; + sortParam: TicketListSortParam; } /** @@ -21,28 +29,44 @@ interface UseTicketListTableSyncProps { */ export function useTicketListTableSync({ columnFilters, + columnVisibilityOverrides, serverDefaultColumnFilters, pageNumber, restoreState, + sortParam, }: UseTicketListTableSyncProps) { - const restoredStateRef = useRef<{ - columnFilters: ColumnFiltersState; - pageNumber: number; - } | null>(null); + const { persistenceCookieName } = + usePageProps>(); + const [, setCookie] = useCookie(persistenceCookieName); + const restoredUrlStateRef = useRef(null); + const initialSortParamRef = useRef(sortParam); + + useEffect(() => { + window.history.replaceState( + { ...window.history.state, ticketListSortParam: initialSortParamRef.current }, + '', + ); + }, []); useEffect(() => { - const handlePopState = () => { + const handlePopState = (event: PopStateEvent) => { const restored = readTicketListSearchParams(window.location.search); - const restoredColumnFilters = resolveInitialTicketListColumnFilters( - restored.query, - serverDefaultColumnFilters, - ); + const historyState = event.state as TicketListHistoryState | null; - restoredStateRef.current = { - columnFilters: restoredColumnFilters, + const urlState: TicketListUrlState = { + columnFilters: resolveInitialTicketListColumnFilters( + restored.query, + serverDefaultColumnFilters, + ), pageNumber: restored.pageNumber, + sortParam: ticketListSort.resolve( + historyState?.ticketListSortParam, + ticketListSort.resolve(restored.sort), + ), }; - restoreState(restoredColumnFilters, restored.pageNumber); + + restoredUrlStateRef.current = urlState; + restoreState(urlState); }; window.addEventListener('popstate', handlePopState); @@ -51,13 +75,24 @@ export function useTicketListTableSync({ }); useUpdateEffect(() => { - const restoredState = restoredStateRef.current; - restoredStateRef.current = null; + setCookie( + JSON.stringify({ + columnVisibility: columnVisibilityOverrides, + sortParam, + }), + { expires: 180 }, + ); + }, [columnVisibilityOverrides, sortParam]); + + useUpdateEffect(() => { + const restoredUrlState = restoredUrlStateRef.current; + restoredUrlStateRef.current = null; if ( - restoredState && - restoredState.columnFilters === columnFilters && - restoredState.pageNumber === pageNumber + restoredUrlState && + restoredUrlState.columnFilters === columnFilters && + restoredUrlState.pageNumber === pageNumber && + restoredUrlState.sortParam === sortParam ) { return; } @@ -66,6 +101,7 @@ export function useTicketListTableSync({ columnFilters, serverDefaultColumnFilters, pageNumber, + sortParam, currentSearch: window.location.search, }); @@ -79,6 +115,6 @@ export function useTicketListTableSync({ return; } - window.history.pushState({ inertia: true }, '', newUrl); - }, [columnFilters, pageNumber]); + window.history.pushState({ inertia: true, ticketListSortParam: sortParam }, '', newUrl); + }, [columnFilters, pageNumber, sortParam]); } diff --git a/resources/js/features/tickets/models/index.ts b/resources/js/features/tickets/models/index.ts index 542b7a6c03..59c0ee05b0 100644 --- a/resources/js/features/tickets/models/index.ts +++ b/resources/js/features/tickets/models/index.ts @@ -1,2 +1,9 @@ +export * from './ticket-list-column-definition.model'; +export * from './ticket-list-column-id.model'; export * from './ticket-list-filter-property.model'; export * from './ticket-list-filter-property-option.model'; +export * from './ticket-list-query-data.model'; +export * from './ticket-list-query-options-input.model'; +export * from './ticket-list-sort-param.model'; +export * from './ticket-list-url-state.model'; +export * from './ticket-list-view-preferences.model'; diff --git a/resources/js/features/tickets/models/ticket-list-column-definition.model.ts b/resources/js/features/tickets/models/ticket-list-column-definition.model.ts new file mode 100644 index 0000000000..8b50176f44 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-column-definition.model.ts @@ -0,0 +1,8 @@ +import type { ColumnDef } from '@tanstack/react-table'; + +import type { TicketListColumnId } from './ticket-list-column-id.model'; + +export type TicketListColumnDefinition = ColumnDef & { + id: TicketListColumnId; + meta: NonNullable['meta']>; +}; diff --git a/resources/js/features/tickets/models/ticket-list-column-id.model.ts b/resources/js/features/tickets/models/ticket-list-column-id.model.ts new file mode 100644 index 0000000000..7decd52721 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-column-id.model.ts @@ -0,0 +1,3 @@ +import { TICKET_LIST_COLUMN_IDS } from '../utils/ticketListColumnIds'; + +export type TicketListColumnId = (typeof TICKET_LIST_COLUMN_IDS)[number]; diff --git a/resources/js/features/tickets/models/ticket-list-query-data.model.ts b/resources/js/features/tickets/models/ticket-list-query-data.model.ts new file mode 100644 index 0000000000..f340be51d5 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-query-data.model.ts @@ -0,0 +1,5 @@ +export interface TicketListQueryData { + facetCounts: Record>; + paginatedTickets: App.Data.PaginatedData; + stateCounts: App.Platform.Data.TicketListStateCounts; +} diff --git a/resources/js/features/tickets/models/ticket-list-query-options-input.model.ts b/resources/js/features/tickets/models/ticket-list-query-options-input.model.ts new file mode 100644 index 0000000000..3effff080f --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-query-options-input.model.ts @@ -0,0 +1,10 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import type { TicketListSortParam } from './ticket-list-sort-param.model'; + +export interface TicketListQueryOptionsInput { + columnFilters: ColumnFiltersState; + pageNumber: number; + scope: App.Platform.Enums.TicketListScope; + sortParam: TicketListSortParam; +} diff --git a/resources/js/features/tickets/models/ticket-list-sort-param.model.ts b/resources/js/features/tickets/models/ticket-list-sort-param.model.ts new file mode 100644 index 0000000000..17e5c6dbb1 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-sort-param.model.ts @@ -0,0 +1,3 @@ +export type TicketListSortParam = + | App.Platform.Enums.TicketListSortField + | `-${App.Platform.Enums.TicketListSortField}`; diff --git a/resources/js/features/tickets/models/ticket-list-url-state.model.ts b/resources/js/features/tickets/models/ticket-list-url-state.model.ts new file mode 100644 index 0000000000..5145b81dc6 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-url-state.model.ts @@ -0,0 +1,9 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +import type { TicketListSortParam } from './ticket-list-sort-param.model'; + +export interface TicketListUrlState { + columnFilters: ColumnFiltersState; + pageNumber: number; + sortParam: TicketListSortParam; +} diff --git a/resources/js/features/tickets/models/ticket-list-view-preferences.model.ts b/resources/js/features/tickets/models/ticket-list-view-preferences.model.ts new file mode 100644 index 0000000000..d539b8cca7 --- /dev/null +++ b/resources/js/features/tickets/models/ticket-list-view-preferences.model.ts @@ -0,0 +1,8 @@ +import type { VisibilityState } from '@tanstack/react-table'; + +import type { TicketListSortParam } from './ticket-list-sort-param.model'; + +export interface TicketListViewPreferences { + columnVisibility: VisibilityState; + sortParam: TicketListSortParam; +} diff --git a/resources/js/features/tickets/utils/column-definitions/buildAgeColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildAgeColumnDef.tsx index f40a8126f8..75cd2032c8 100644 --- a/resources/js/features/tickets/utils/column-definitions/buildAgeColumnDef.tsx +++ b/resources/js/features/tickets/utils/column-definitions/buildAgeColumnDef.tsx @@ -1,4 +1,3 @@ -import type { ColumnDef } from '@tanstack/react-table'; import type { FC } from 'react'; import { @@ -11,15 +10,14 @@ import { cn } from '@/common/utils/cn'; import { useDiffForHumans } from '@/common/utils/l10n/useDiffForHumans'; import type { TranslatedString } from '@/types/i18next'; +import type { TicketListColumnDefinition } from '../../models'; import { ticketListCellClassNames } from './ticketListCellClassNames'; interface BuildAgeColumnDefProps { t_label: TranslatedString; } -export function buildAgeColumnDef({ - t_label, -}: BuildAgeColumnDefProps): ColumnDef { +export function buildAgeColumnDef({ t_label }: BuildAgeColumnDefProps): TicketListColumnDefinition { return { id: 'age', meta: { diff --git a/resources/js/features/tickets/utils/column-definitions/buildGameColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildGameColumnDef.tsx index 74f874f46a..f8f58a13f1 100644 --- a/resources/js/features/tickets/utils/column-definitions/buildGameColumnDef.tsx +++ b/resources/js/features/tickets/utils/column-definitions/buildGameColumnDef.tsx @@ -1,9 +1,8 @@ -import type { ColumnDef } from '@tanstack/react-table'; - import { GameAvatar } from '@/common/components/GameAvatar'; import { cn } from '@/common/utils/cn'; import type { TranslatedString } from '@/types/i18next'; +import type { TicketListColumnDefinition } from '../../models'; import { ticketListCellClassNames } from './ticketListCellClassNames'; interface BuildGameColumnDefProps { @@ -12,7 +11,7 @@ interface BuildGameColumnDefProps { export function buildGameColumnDef({ t_label, -}: BuildGameColumnDefProps): ColumnDef { +}: BuildGameColumnDefProps): TicketListColumnDefinition { return { id: 'game', meta: { @@ -32,7 +31,10 @@ export function buildGameColumnDef({ {...game} size={24} hasTooltip={false} - wrapperClassName={cn('max-w-full min-w-0', ticketListCellClassNames.entityLinkWrapper)} + wrapperClassName={cn( + 'min-w-0 flex-1 overflow-hidden', + ticketListCellClassNames.entityLinkWrapper, + )} gameTitleClassName={cn( ticketListCellClassNames.entityLinkLabel, ticketListCellClassNames.truncate, diff --git a/resources/js/features/tickets/utils/column-definitions/buildHashColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildHashColumnDef.tsx new file mode 100644 index 0000000000..cf874d991d --- /dev/null +++ b/resources/js/features/tickets/utils/column-definitions/buildHashColumnDef.tsx @@ -0,0 +1,57 @@ +import { + BaseTooltip, + BaseTooltipContent, + BaseTooltipTrigger, +} from '@/common/components/+vendor/BaseTooltip'; +import { cn } from '@/common/utils/cn'; +import type { TranslatedString } from '@/types/i18next'; + +import type { TicketListColumnDefinition } from '../../models'; +import { getGameHashDisplayLabel } from '../getGameHashDisplayLabel'; +import { ticketListCellClassNames } from './ticketListCellClassNames'; + +interface BuildHashColumnDefProps { + t_label: TranslatedString; +} + +export function buildHashColumnDef({ + t_label, +}: BuildHashColumnDefProps): TicketListColumnDefinition { + return { + id: 'hash', + meta: { + t_label, + responsiveClassName: cn('flex items-center', 'w-[10em] flex-none'), + }, + + cell: ({ row }) => { + const { gameHash } = row.original; + if (!gameHash) { + return null; + } + + return ( + + + + {getGameHashDisplayLabel(gameHash)} + + + + +
+ {gameHash.name ? {gameHash.name} : null} + {gameHash.md5} +
+
+
+ ); + }, + }; +} diff --git a/resources/js/features/tickets/utils/column-definitions/buildIdColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildIdColumnDef.tsx index e8cf9d7b09..b3dbc91466 100644 --- a/resources/js/features/tickets/utils/column-definitions/buildIdColumnDef.tsx +++ b/resources/js/features/tickets/utils/column-definitions/buildIdColumnDef.tsx @@ -1,17 +1,17 @@ -import type { ColumnDef } from '@tanstack/react-table'; import { route } from 'ziggy-js'; import type { TranslatedString } from '@/types/i18next'; +import type { TicketListColumnDefinition } from '../../models'; + interface BuildIdColumnDefProps { t_label: TranslatedString; } -export function buildIdColumnDef({ - t_label, -}: BuildIdColumnDefProps): ColumnDef { +export function buildIdColumnDef({ t_label }: BuildIdColumnDefProps): TicketListColumnDefinition { return { id: 'id', + enableHiding: false, meta: { t_label, align: 'left', diff --git a/resources/js/features/tickets/utils/column-definitions/buildTicketMetadataColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildTicketMetadataColumnDef.tsx new file mode 100644 index 0000000000..91a9249c00 --- /dev/null +++ b/resources/js/features/tickets/utils/column-definitions/buildTicketMetadataColumnDef.tsx @@ -0,0 +1,34 @@ +import { cn } from '@/common/utils/cn'; +import type { TranslatedString } from '@/types/i18next'; + +import type { TicketListColumnDefinition, TicketListColumnId } from '../../models'; +import { ticketListCellClassNames } from './ticketListCellClassNames'; + +interface BuildTicketMetadataColumnDefProps { + getText: (entry: App.Platform.Data.TicketListEntry) => string | null; + id: TicketListColumnId; + t_label: TranslatedString; + + widthClassName?: string; +} + +export function buildTicketMetadataColumnDef({ + getText, + id, + t_label, + widthClassName = 'w-[9em] flex-none', +}: BuildTicketMetadataColumnDefProps): TicketListColumnDefinition { + return { + id, + meta: { + t_label, + responsiveClassName: cn('flex items-center', widthClassName), + }, + + cell: ({ row }) => ( + + {getText(row.original)} + + ), + }; +} diff --git a/resources/js/features/tickets/utils/column-definitions/buildTicketableColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildTicketableColumnDef.tsx index 63345b4bd9..fdf6cee70c 100644 --- a/resources/js/features/tickets/utils/column-definitions/buildTicketableColumnDef.tsx +++ b/resources/js/features/tickets/utils/column-definitions/buildTicketableColumnDef.tsx @@ -1,4 +1,3 @@ -import type { ColumnDef } from '@tanstack/react-table'; import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { route } from 'ziggy-js'; @@ -6,6 +5,7 @@ import { route } from 'ziggy-js'; import { cn } from '@/common/utils/cn'; import type { TranslatedString } from '@/types/i18next'; +import type { TicketListColumnDefinition } from '../../models'; import { ticketListCellClassNames } from './ticketListCellClassNames'; interface BuildTicketableColumnDefProps { @@ -14,7 +14,7 @@ interface BuildTicketableColumnDefProps { export function buildTicketableColumnDef({ t_label, -}: BuildTicketableColumnDefProps): ColumnDef { +}: BuildTicketableColumnDefProps): TicketListColumnDefinition { return { id: 'ticketable', meta: { diff --git a/resources/js/features/tickets/utils/column-definitions/buildUserColumnDef.tsx b/resources/js/features/tickets/utils/column-definitions/buildUserColumnDef.tsx index 429e3e6f84..860a4040d8 100644 --- a/resources/js/features/tickets/utils/column-definitions/buildUserColumnDef.tsx +++ b/resources/js/features/tickets/utils/column-definitions/buildUserColumnDef.tsx @@ -1,4 +1,3 @@ -import type { ColumnDef } from '@tanstack/react-table'; import type { FC } from 'react'; import { useTranslation } from 'react-i18next'; @@ -6,11 +5,12 @@ import { UserAvatar } from '@/common/components/UserAvatar'; import { cn } from '@/common/utils/cn'; import type { TranslatedString } from '@/types/i18next'; +import type { TicketListColumnDefinition } from '../../models'; import { ticketListCellClassNames } from './ticketListCellClassNames'; interface BuildUserColumnDefProps { getUser: (entry: App.Platform.Data.TicketListEntry) => App.Data.User | null; - id: string; + id: 'developer' | 'reporter' | 'resolver'; t_label: TranslatedString; } @@ -18,7 +18,7 @@ export function buildUserColumnDef({ getUser, id, t_label, -}: BuildUserColumnDefProps): ColumnDef { +}: BuildUserColumnDefProps): TicketListColumnDefinition { return { id, meta: { @@ -29,17 +29,29 @@ export function buildUserColumnDef({ ), }, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }; } interface UserCellProps { + shouldHideWhenUserIsMissing: boolean; user: App.Data.User | null; } -const UserCell: FC = ({ user }) => { +const UserCell: FC = ({ shouldHideWhenUserIsMissing, user }) => { const { t } = useTranslation(); + if (!user && shouldHideWhenUserIsMissing) { + return null; + } + if (!user) { return ( diff --git a/resources/js/features/tickets/utils/getGameHashDisplayLabel.test.ts b/resources/js/features/tickets/utils/getGameHashDisplayLabel.test.ts new file mode 100644 index 0000000000..7fec7614cc --- /dev/null +++ b/resources/js/features/tickets/utils/getGameHashDisplayLabel.test.ts @@ -0,0 +1,48 @@ +import { getGameHashDisplayLabel } from './getGameHashDisplayLabel'; + +describe('Util: getGameHashDisplayLabel', () => { + it('given a name with a region tag, returns only the tag', () => { + // ACT + const result = getGameHashDisplayLabel({ + md5: 'abcdef0123456789abcdef0123456789', + name: 'Sonic The Hedgehog (USA, Europe).md', + }); + + // ASSERT + expect(result).toEqual('(USA, Europe)'); + }); + + it('given a name with several tags, joins them with a space', () => { + // ACT + const result = getGameHashDisplayLabel({ + md5: 'abcdef0123456789abcdef0123456789', + name: 'Super Metroid (Japan, USA) (En,Ja) [T+Fre v1.1].sfc', + }); + + // ASSERT + expect(result).toEqual('(Japan, USA) (En,Ja) [T+Fre v1.1]'); + }); + + it('given a name with no tags, returns a short md5 prefix', () => { + // ACT + const result = getGameHashDisplayLabel({ + md5: 'abcdef0123456789abcdef0123456789', + name: 'Some Homebrew Game.nes', + }); + + // ASSERT + expect(result).toEqual('abcdef01'); + }); + + // there's ~125 of these + it('given there is no name, returns a short md5 prefix', () => { + // ACT + const result = getGameHashDisplayLabel({ + md5: 'abcdef0123456789abcdef0123456789', + name: null, + }); + + // ASSERT + expect(result).toEqual('abcdef01'); + }); +}); diff --git a/resources/js/features/tickets/utils/getGameHashDisplayLabel.ts b/resources/js/features/tickets/utils/getGameHashDisplayLabel.ts new file mode 100644 index 0000000000..a46d9b161b --- /dev/null +++ b/resources/js/features/tickets/utils/getGameHashDisplayLabel.ts @@ -0,0 +1,14 @@ +/** + * "Super Mario Bros (USA) (Rev 1)" -> "(USA) (Rev 1)" + */ +export function getGameHashDisplayLabel( + gameHash: Pick, +): string { + const tags = gameHash.name?.match(/[([][^)\]]*[)\]]/g); + + if (tags?.length) { + return tags.join(' '); + } + + return gameHash.md5.slice(0, 8); +} diff --git a/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.test.ts b/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.test.ts new file mode 100644 index 0000000000..490ff35142 --- /dev/null +++ b/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.test.ts @@ -0,0 +1,57 @@ +import { getTicketListDefaultColumnVisibility } from './getTicketListDefaultColumnVisibility'; + +describe('Util: getTicketListDefaultColumnVisibility', () => { + it('given the all scope, shows the columns that scope defaults to and hides the rest', () => { + // ACT + const result = getTicketListDefaultColumnVisibility('all', 'unresolved'); + + // ASSERT + expect(result).toEqual({ + id: true, + ticketable: true, + game: true, + type: false, + mode: false, + developer: true, + reporter: true, + resolver: false, + emulator: false, + version: false, + core: false, + hash: false, + age: true, + }); + }); + + it('given the achievement scope, shows the issue type column instead of the game column', () => { + // ACT + const result = getTicketListDefaultColumnVisibility('achievement', 'unresolved'); + + // ASSERT + expect(result.type).toEqual(true); + expect(result.game).toEqual(false); + expect(result.ticketable).toEqual(false); + }); + + it('given a status that surfaces terminal tickets, shows the resolver column', () => { + // ACT + const resolvedResult = getTicketListDefaultColumnVisibility('all', 'resolved'); + const closedResult = getTicketListDefaultColumnVisibility('all', 'closed'); + const allResult = getTicketListDefaultColumnVisibility('all', 'all'); + const unresolvedResult = getTicketListDefaultColumnVisibility('all', 'unresolved'); + + // ASSERT + expect(resolvedResult.resolver).toEqual(true); + expect(closedResult.resolver).toEqual(true); + expect(allResult.resolver).toEqual(true); + expect(unresolvedResult.resolver).toEqual(false); + }); + + it('given the resolvedBy scope, never adds the resolver column', () => { + // ACT + const result = getTicketListDefaultColumnVisibility('resolvedBy', 'resolved'); + + // ASSERT + expect(result.resolver).toEqual(false); + }); +}); diff --git a/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.ts b/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.ts new file mode 100644 index 0000000000..f59da53a72 --- /dev/null +++ b/resources/js/features/tickets/utils/getTicketListDefaultColumnVisibility.ts @@ -0,0 +1,38 @@ +import type { VisibilityState } from '@tanstack/react-table'; + +import type { TicketListColumnId } from '../models'; +import { TICKET_LIST_COLUMN_IDS } from './ticketListColumnIds'; + +export function getTicketListDefaultColumnVisibility( + scope: App.Platform.Enums.TicketListScope, + statusValue: App.Platform.Enums.TicketListStatusFilter, +): VisibilityState { + const visibleColumnIds = new Set(visibleColumnIdsByScope[scope]); + + if (scope !== 'resolvedBy' && statusValuesShowingResolver.includes(statusValue)) { + visibleColumnIds.add('resolver'); + } + + return Object.fromEntries( + TICKET_LIST_COLUMN_IDS.map((columnId) => [columnId, visibleColumnIds.has(columnId)]), + ); +} + +const visibleColumnIdsByScope: Record< + App.Platform.Enums.TicketListScope, + readonly TicketListColumnId[] +> = { + all: ['id', 'ticketable', 'game', 'developer', 'reporter', 'age'], + game: ['id', 'ticketable', 'developer', 'reporter', 'age'], + achievement: ['id', 'type', 'reporter', 'age'], + assignedTo: ['id', 'ticketable', 'game', 'reporter', 'age'], + reportedBy: ['id', 'ticketable', 'game', 'developer', 'age'], + awaitingReporter: ['id', 'ticketable', 'game', 'developer', 'age'], + resolvedBy: ['id', 'ticketable', 'game', 'reporter', 'age'], +}; + +const statusValuesShowingResolver: App.Platform.Enums.TicketListStatusFilter[] = [ + 'all', + 'resolved', + 'closed', +]; diff --git a/resources/js/features/tickets/utils/readTicketListSearchParams.ts b/resources/js/features/tickets/utils/readTicketListSearchParams.ts index edc04b12b3..d9795dc920 100644 --- a/resources/js/features/tickets/utils/readTicketListSearchParams.ts +++ b/resources/js/features/tickets/utils/readTicketListSearchParams.ts @@ -3,6 +3,7 @@ import type { AppGlobalProps } from '@/common/models'; interface TicketListSearchParams { pageNumber: number; query: AppGlobalProps['ziggy']['query']; + sort: string | null; } export function readTicketListSearchParams(search: string): TicketListSearchParams { @@ -19,5 +20,6 @@ export function readTicketListSearchParams(search: string): TicketListSearchPara return { pageNumber: Number(searchParams.get('page[number]') ?? 1), query: { filter }, + sort: searchParams.get('sort'), }; } diff --git a/resources/js/features/tickets/utils/resolveTicketListViewPreferences.ts b/resources/js/features/tickets/utils/resolveTicketListViewPreferences.ts new file mode 100644 index 0000000000..cae0714352 --- /dev/null +++ b/resources/js/features/tickets/utils/resolveTicketListViewPreferences.ts @@ -0,0 +1,24 @@ +import type { VisibilityState } from '@tanstack/react-table'; + +import type { TicketListViewPreferences } from '../models'; +import { TICKET_LIST_COLUMN_IDS } from './ticketListColumnIds'; +import { ticketListSort } from './ticketListSort'; + +export function resolveTicketListViewPreferences( + preferences: Partial | null, +): TicketListViewPreferences { + const rawColumnVisibility = preferences?.columnVisibility ?? {}; + + const columnVisibility = Object.fromEntries( + TICKET_LIST_COLUMN_IDS.flatMap((columnId) => + columnId !== 'id' && typeof rawColumnVisibility[columnId] === 'boolean' + ? [[columnId, rawColumnVisibility[columnId]]] + : [], + ), + ) as VisibilityState; + + return { + columnVisibility, + sortParam: ticketListSort.resolve(preferences?.sortParam), + }; +} diff --git a/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts b/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts index 3c8488e864..6b4db4663d 100644 --- a/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts +++ b/resources/js/features/tickets/utils/serializeTicketListSearchParams.test.ts @@ -18,6 +18,7 @@ describe('Util: serializeTicketListSearchParams', () => { { id: 'status', value: ['resolved'] }, { id: 'emulator', value: ['RetroArch'] }, ], + sortParam: '-createdAt', pageNumber: 2, serverDefaultColumnFilters, }); @@ -33,6 +34,7 @@ describe('Util: serializeTicketListSearchParams', () => { const result = serializeTicketListSearchParams({ currentSearch: '?filter[status]=resolved&page[number]=3', columnFilters: serverDefaultColumnFilters, + sortParam: '-createdAt', pageNumber: 1, serverDefaultColumnFilters, }); @@ -46,6 +48,7 @@ describe('Util: serializeTicketListSearchParams', () => { const result = serializeTicketListSearchParams({ currentSearch: '?filter[emulator]=RetroArch', columnFilters: [{ id: 'status', value: ['resolved'] }], + sortParam: '-createdAt', pageNumber: 1, serverDefaultColumnFilters, }); @@ -59,6 +62,7 @@ describe('Util: serializeTicketListSearchParams', () => { const result = serializeTicketListSearchParams({ currentSearch: '?filter[emulator]=RetroArch', columnFilters: [{ id: 'emulator', value: [] }], + sortParam: '-createdAt', pageNumber: 1, serverDefaultColumnFilters, }); @@ -72,6 +76,7 @@ describe('Util: serializeTicketListSearchParams', () => { const result = serializeTicketListSearchParams({ currentSearch: '', columnFilters: [{ id: 'status', value: 'all' }], + sortParam: '-createdAt', pageNumber: 1, serverDefaultColumnFilters, }); @@ -85,6 +90,7 @@ describe('Util: serializeTicketListSearchParams', () => { const result = serializeTicketListSearchParams({ currentSearch: '', columnFilters: serverDefaultColumnFilters, + sortParam: '-createdAt', pageNumber: 1, }); @@ -95,13 +101,42 @@ describe('Util: serializeTicketListSearchParams', () => { it('given the URL has a param the list does not even own, leaves it alone', () => { // ACT const result = serializeTicketListSearchParams({ - currentSearch: '?sort=state', + currentSearch: '?highlight=17', columnFilters: serverDefaultColumnFilters, + sortParam: '-createdAt', pageNumber: 2, serverDefaultColumnFilters, }); // ASSERT - expect(result.toString()).toEqual('sort=state&page%5Bnumber%5D=2'); + expect(result.toString()).toEqual('highlight=17&page%5Bnumber%5D=2'); + }); + + it('serializes a non-default sort', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '', + columnFilters: serverDefaultColumnFilters, + sortParam: 'state', + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual('sort=state'); + }); + + it('always omits the default sort', () => { + // ACT + const result = serializeTicketListSearchParams({ + currentSearch: '?sort=state', + columnFilters: serverDefaultColumnFilters, + sortParam: '-createdAt', + pageNumber: 1, + serverDefaultColumnFilters, + }); + + // ASSERT + expect(result.toString()).toEqual(''); }); }); diff --git a/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts b/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts index 660393a10a..f82a4833a7 100644 --- a/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts +++ b/resources/js/features/tickets/utils/serializeTicketListSearchParams.ts @@ -1,10 +1,13 @@ import type { ColumnFiltersState } from '@tanstack/react-table'; +import type { TicketListSortParam } from '../models'; import { normalizeTicketListFilterValue } from './normalizeTicketListFilterValue'; +import { ticketListSort } from './ticketListSort'; interface SerializeTicketListSearchParamsOptions { columnFilters: ColumnFiltersState; pageNumber: number; + sortParam: TicketListSortParam; currentSearch?: string; serverDefaultColumnFilters?: ColumnFiltersState; @@ -17,11 +20,18 @@ interface SerializeTicketListSearchParamsOptions { export function serializeTicketListSearchParams({ columnFilters, pageNumber, + sortParam, currentSearch = '', serverDefaultColumnFilters = [], }: SerializeTicketListSearchParamsOptions): URLSearchParams { const searchParams = new URLSearchParams(currentSearch); + if (sortParam !== ticketListSort.defaultParam) { + searchParams.set('sort', sortParam); + } else { + searchParams.delete('sort'); + } + if (pageNumber > 1) { searchParams.set('page[number]', String(pageNumber)); } else { diff --git a/resources/js/features/tickets/utils/ticketListColumnIds.ts b/resources/js/features/tickets/utils/ticketListColumnIds.ts index 7790d17e49..1b07aa5ba7 100644 --- a/resources/js/features/tickets/utils/ticketListColumnIds.ts +++ b/resources/js/features/tickets/utils/ticketListColumnIds.ts @@ -2,9 +2,14 @@ export const TICKET_LIST_COLUMN_IDS = [ 'id', 'ticketable', 'game', + 'type', + 'mode', 'developer', 'reporter', + 'resolver', + 'emulator', + 'version', + 'core', + 'hash', 'age', ] as const; - -export type TicketListColumnId = (typeof TICKET_LIST_COLUMN_IDS)[number]; diff --git a/resources/js/features/tickets/utils/ticketListSort.ts b/resources/js/features/tickets/utils/ticketListSort.ts new file mode 100644 index 0000000000..4a2239b950 --- /dev/null +++ b/resources/js/features/tickets/utils/ticketListSort.ts @@ -0,0 +1,31 @@ +import type { TicketListSortParam } from '../models'; + +const defaultParam: TicketListSortParam = '-createdAt'; +const fields: readonly App.Platform.Enums.TicketListSortField[] = [ + 'createdAt', + 'state', + 'resolvedAt', +]; + +export const ticketListSort = { + defaultParam, + fields, + + build(field: App.Platform.Enums.TicketListSortField, isAscending: boolean): TicketListSortParam { + return isAscending ? field : `-${field}`; + }, + + field(sortParam: TicketListSortParam): App.Platform.Enums.TicketListSortField { + return sortParam.replace(/^-/, '') as App.Platform.Enums.TicketListSortField; + }, + + isAscending(sortParam: TicketListSortParam): boolean { + return !sortParam.startsWith('-'); + }, + + resolve(value: unknown, fallback: TicketListSortParam = defaultParam): TicketListSortParam { + const isKnown = fields.some((field) => value === field || value === `-${field}`); + + return isKnown ? (value as TicketListSortParam) : fallback; + }, +}; diff --git a/resources/js/features/tickets/utils/toggleTicketListColumnOverride.ts b/resources/js/features/tickets/utils/toggleTicketListColumnOverride.ts new file mode 100644 index 0000000000..8143cc3d43 --- /dev/null +++ b/resources/js/features/tickets/utils/toggleTicketListColumnOverride.ts @@ -0,0 +1,22 @@ +import type { VisibilityState } from '@tanstack/react-table'; + +import type { TicketListColumnId } from '../models'; + +export function toggleTicketListColumnOverride( + columnVisibilityOverrides: VisibilityState, + defaultColumnVisibility: VisibilityState, + columnId: TicketListColumnId, +): VisibilityState { + const currentlyVisible = columnVisibilityOverrides[columnId] ?? defaultColumnVisibility[columnId]; + const nextIsVisible = !currentlyVisible; + + const nextOverrides = { ...columnVisibilityOverrides }; + + if (nextIsVisible === defaultColumnVisibility[columnId]) { + delete nextOverrides[columnId]; + } else { + nextOverrides[columnId] = nextIsVisible; + } + + return nextOverrides; +} diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index fd89d7c1d7..cdb3f7be17 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1215,6 +1215,8 @@ paginatedTickets: App.Data.PaginatedData; stateCounts: App.Platform.Data.TicketListStateCounts; availableFilters: Array; facetCounts: Record>; +persistenceCookieName: string; +persistedViewPreferences: Record | null; game: App.Platform.Data.Game | null; achievement: App.Platform.Data.Achievement | null; user: App.Data.User | null; @@ -1247,14 +1249,14 @@ pointsForNext: number; } declare namespace App.Platform.Enums { export type AchievementAuthorTask = 'artwork' | 'design' | 'logic' | 'writing'; +export type UnlockMode = 0 | 1; export type AchievementChangelogEntryType = 'created' | 'deleted' | 'restored' | 'edited' | 'promoted' | 'demoted' | 'description-updated' | 'title-updated' | 'points-changed' | 'badge-updated' | 'embed-url-updated' | 'logic-updated' | 'moved-to-different-game' | 'type-set' | 'type-changed' | 'type-removed'; export type AchievementPageTab = 'changelog' | 'comments' | 'tips' | 'unlocks'; export type AchievementSetAuthorTask = 'artwork' | 'banner' | 'testing'; export type AchievementSetType = 'core' | 'bonus' | 'challenge' | 'specialty' | 'exclusive' | 'will_be_bonus' | 'will_be_specialty' | 'will_be_challenge'; export type EventState = 'active' | 'concluded' | 'evergreen'; -export type GameBannerPreference = 'compact' | 'normal' | 'expanded'; -export type UnlockMode = 0 | 1; export type GameBadgeAttribution = 'live' | 'backfill_audit_log' | 'backfill_comment_heuristic' | 'backfill_current_canonical' | 'backfill_forum_comment'; +export type GameBannerPreference = 'compact' | 'normal' | 'expanded'; export type GameListProgressFilterValue = 'unstarted' | 'unfinished' | 'gte_beaten_softcore' | 'gte_beaten_hardcore' | 'eq_beaten_softcore' | 'eq_beaten_hardcore' | 'gte_completed' | 'eq_completed' | 'eq_mastered' | 'revised' | 'neq_mastered'; export type GameListSetTypeFilterValue = 'all' | 'only-games' | 'only-subsets'; export type GameListSortField = 'achievementsPublished' | 'beatRatio' | 'hasActiveOrInReviewClaims' | 'lastUpdated' | 'masteryRatio' | 'medianTimeToBeatHardcore' | 'medianTimeToMasterHardcore' | 'numRequests' | 'numUnresolvedTickets' | 'numVisibleLeaderboards' | 'playersTotal' | 'pointsTotal' | 'progress' | 'releasedAt' | 'retroRatio' | 'system' | 'title'; diff --git a/tests/Feature/Platform/Actions/BuildTicketListActionTest.php b/tests/Feature/Platform/Actions/BuildTicketListActionTest.php index d1d79df9fb..6fb180658e 100644 --- a/tests/Feature/Platform/Actions/BuildTicketListActionTest.php +++ b/tests/Feature/Platform/Actions/BuildTicketListActionTest.php @@ -13,6 +13,7 @@ use App\Platform\Data\TicketListEntryData; use App\Platform\Enums\TicketListFilterKind; use App\Platform\Enums\TicketListScope; +use App\Platform\Enums\TicketListSortField; use App\Platform\Requests\TicketListRequest; use Carbon\Carbon; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; @@ -262,3 +263,16 @@ function entryIds(array $result): array expect($emulatorFilter->values)->toEqual(['all', 'Bizhawk', 'unknown']); }); }); + +it('prefers the URL sort over the persisted sort', function () { + // ACT + $request = TicketListRequest::create('/tickets', 'GET', ['sort' => '-resolvedAt'], [ + 'datatable_view_preference_tickets_all' => json_encode(['sortParam' => 'state']), + ]); + + // ASSERT + expect($request->getSort())->toEqual([ + 'field' => TicketListSortField::ResolvedAt, + 'direction' => 'desc', + ]); +}); diff --git a/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php b/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php index 6ebebfc1e5..b19da010bc 100644 --- a/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php +++ b/tests/Feature/Platform/Controllers/TicketControllerIndexTest.php @@ -93,3 +93,28 @@ function createTicketListPageTickets(int $ticketCount): array ->missing('user') ); }); + +it('uses persisted display preferences for the initial ticket list', function () { + // ARRANGE + $tickets = createTicketListPageTickets(2); + actingAs(User::factory()->create()); + $cookieName = 'datatable_view_preference_tickets_all'; + $preferences = [ + 'columnVisibility' => ['game' => false], + 'sortParam' => 'createdAt', + ]; + + // ACT + $response = $this + ->withUnencryptedCookie($cookieName, json_encode($preferences)) + ->get(route('tickets2.index')); + + // ASSERT + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->where('persistenceCookieName', $cookieName) + ->where('persistedViewPreferences', $preferences) + ->where('paginatedTickets.items.0.id', $tickets[0]->id) + ->where('paginatedTickets.items.1.id', $tickets[1]->id) + ); +});