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}}1>": "{{label}} is <1>{{value}}1>",
- "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 ? (
+
+ ) : null}
+
+
+
+ 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)
+ );
+});