Issue Types
View on GitHubAdd a new list column
The issues table's toggleable columns are driven by a single registry,
resources/js/utils/issueTableColumns.ts — it's the one place that
defines which columns exist, their header label, default width, and
whether they're on by default. Everything else (the header row, the
column-visibility dropdown, each row's default enabledColumns prop)
reads from it. What's still bespoke, by design, is each column's
actual cell markup in ListRow.tsx — every column renders differently
(a badge, a date, a user avatar, plain text), so that part isn't
genericized. Worked example: adding a Reporter column, showing the
issue's creator (issue.creator), sortable by that user's name — built
in the exact shape of the existing Assignee column.
Step 1 — Register the column
File: resources/js/utils/issueTableColumns.ts
export const ISSUE_TABLE_COLUMNS: IssueTableColumnDefinition[] = [
{ value: 'id', label: 'ID', defaultWidth: 70, defaultEnabled: true },
{ value: 'title', label: 'Title', defaultWidth: 400, defaultEnabled: true },
{ value: 'type', label: 'Type', defaultWidth: 140, defaultEnabled: true },
{ value: 'status', label: 'Status', defaultWidth: 120, defaultEnabled: true },
{ value: 'assignee', label: 'Assignee', defaultWidth: 140, defaultEnabled: true },
{ value: 'reporter', label: 'Reporter', defaultWidth: 140, defaultEnabled: false },
{ value: 'priority', label: 'Priority', defaultWidth: 140, defaultEnabled: true },
{ value: 'labels', label: 'Labels', defaultWidth: 200, defaultEnabled: true },
{ value: 'updated', label: 'Updated', defaultWidth: 150, defaultEnabled: true },
{ value: 'start_date', label: 'Start', defaultWidth: 150, defaultEnabled: false },
{ value: 'end_date', label: 'End', defaultWidth: 150, defaultEnabled: false },
];
This one entry is enough to make Reporter show up in IssueTable's
header row (headers, derived from this array) and in
IssueTableHead's column-visibility dropdown (SelectionDropdown's
options, also derived from this array) — see
resources/js/Components/Organisms/IssueTable/IssueTable.tsx and
resources/js/Components/Organisms/IssueTableHead/IssueTableHead.tsx,
neither of which needs any further change.
Step 2 — Add it to the SortingColumn union
File: resources/js/types/Issues.ts
export type SortingColumn =
| 'id'
| 'title'
| 'type'
| 'status'
| 'assignee'
| 'reporter'
| 'priority'
| 'labels'
| 'updated'
| 'start_date'
| 'end_date';
Step 3 — Render the cell
File: resources/js/Components/Organisms/ListRow/ListRow.tsx
Add a cell right after the assignee one, following its exact shape:
{enabledColumns.assignee && (
<td
className={cn(
cellBase,
'text-[var(--text-gray-color)]',
)}
data-column="assignee"
>
<UserBadge
avatarSrc={issue.assignee?.avatar}
name={issue.assignee?.name ?? 'Unassigned'}
size="sm"
/>
</td>
)}
{enabledColumns.reporter && (
<td
className={cn(
cellBase,
'text-[var(--text-gray-color)]',
)}
data-column="reporter"
>
<UserBadge
avatarSrc={issue.creator?.avatar}
name={issue.creator?.name ?? 'Unknown'}
size="sm"
/>
</td>
)}
issue.creator is already present on every Issue — IssueRepository
eager-loads creator on every list query already (see Step 4), so no
prop-threading is needed to get the data to this component.
Step 4 — Make it sortable on the backend
File: app/Repositories/IssueRepository.php
Add 'reporter' to $allowedColumns and a case in the sort switch,
mirroring the assignee case's leftJoin pattern exactly (the join
alias avoids a project_id ambiguity the same way the type column's
join does):
$allowedColumns = ['id', 'title', 'status', 'assignee', 'priority', 'labels', 'updated', 'start_date', 'end_date', 'type', 'reporter'];
if ($column && in_array($column, $allowedColumns)) {
switch ($column) {
case 'id':
case 'title':
case 'status':
case 'labels':
$query->orderBy($column, $direction);
break;
case 'priority':
if ($direction === 'asc') {
$query->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 WHEN priority = 'low' THEN 3 ELSE 4 END");
} else {
$query->orderByRaw("CASE WHEN priority = 'high' THEN 4 WHEN priority = 'medium' THEN 3 WHEN priority = 'low' THEN 2 ELSE 1 END");
}
break;
case 'assignee':
$query->leftJoin('users', 'issues.assignee_id', '=', 'users.id')
->select('issues.*')
->orderBy('users.name', $direction);
break;
case 'reporter':
$query->leftJoin('users as reporters', 'issues.user_id', '=', 'reporters.id')
->select('issues.*')
->orderBy('reporters.name', $direction);
break;
case 'type':
$query->leftJoin('issue_types', 'issues.issue_type_id', '=', 'issue_types.id')
->select('issues.*')
->orderBy('issue_types.name', $direction);
break;
case 'updated':
$query->orderBy('updated_at', $direction);
break;
case 'start_date':
$query->orderBy('start_date', $direction);
break;
case 'end_date':
$query->orderBy('end_date', $direction);
break;
}
} else {
$query->latest();
}
The join is aliased reporters (not users) because assignee's own
leftJoin('users', ...) already claims that table name in the same
query builder — sorting by both columns in the same request would
otherwise collide. A column that doesn't need sorting can skip this
step entirely; leaving it out of $allowedColumns just makes clicking
that header a no-op (falls back to the default ->latest() order)
instead of an error.
Step 5 — Allow the column through the visibility-toggle validation
File: app/Http/Controllers/ProjectController.php
$validated = $request->validate([
'columns' => 'required|array',
'columns.id' => 'sometimes|boolean',
'columns.title' => 'sometimes|boolean',
'columns.type' => 'sometimes|boolean',
'columns.status' => 'sometimes|boolean',
'columns.assignee' => 'sometimes|boolean',
'columns.reporter' => 'sometimes|boolean',
'columns.priority' => 'sometimes|boolean',
'columns.labels' => 'sometimes|boolean',
'columns.updated' => 'sometimes|boolean',
'columns.start_date' => 'sometimes|boolean',
'columns.end_date' => 'sometimes|boolean',
]);
Without this, toggling the column off in the UI still works locally
but the PATCH /projects/{project}/columns request silently drops the
unknown key server-side, so it wouldn't persist across a reload.
Step 6 — Tests
File: resources/js/Components/Organisms/ListRow/ListRow.test.tsx
test('renders the reporter column when enabled', () => {
renderRow({
issue: makeIssue({ creator: { name: 'Jane Doe', avatar: '' } }),
enabledColumns: { ...DEFAULT_ENABLED_COLUMNS, reporter: true },
});
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
(Import DEFAULT_ENABLED_COLUMNS from @/utils/issueTableColumns at
the top of the file if it isn't already.)
File: tests/Feature/IssueRepositoryTest.php
Mirror the existing 'it can sort issues by assignee name' test:
test('it can sort issues by reporter name', function () {
$project = Project::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
$bob = User::factory()->create(['name' => 'Bob']);
Issue::factory()->create(['project_id' => $project->id, 'user_id' => $bob->id, 'title' => 'Bob issue']);
Issue::factory()->create(['project_id' => $project->id, 'user_id' => $alice->id, 'title' => 'Alice issue']);
$results = $this->repository->getAllPaginated($project->id, 10, ['sort' => 'reporter', 'direction' => 'AZ']);
expect($results->items()[0]->title)->toBe('Alice issue')
->and($results->items()[1]->title)->toBe('Bob issue');
});
File: tests/Feature/ProjectControllerTest.php
Mirror the existing 'the type column can be toggled' test with
'reporter' in place of 'type'.
Run php artisan test --filter=IssueRepositoryTest,
php artisan test --filter=ProjectControllerTest, and
npx vitest run resources/js/Components/Organisms/ListRow before
committing.
