Skip to content

Add click-to-sort column headers to all entity list table views - #6934

Open
slick-daddy wants to merge 14 commits into
stashapp:developfrom
slick-daddy:clickable-elements
Open

Add click-to-sort column headers to all entity list table views#6934
slick-daddy wants to merge 14 commits into
stashapp:developfrom
slick-daddy:clickable-elements

Conversation

@slick-daddy

@slick-daddy slick-daddy commented May 16, 2026

Copy link
Copy Markdown
Contributor

This PR shouldn't be merged before #6933 gets merged. We are good now.

The only thing I didn't like about this PR is reverse mapping approach. Maybe we should edit onClick instead. I am open to feedbacks.

Summary

Makes column headers clickable in list table views for Scenes, Galleries,
Performers, Studios, and Tags. The sort infrastructure is generic so all
entity tables benefit without per-entity duplication.

Changes

Changes After Review

Server: favorite sort option for Performer, Studio, Tag sort options
Server: birthdate sort case for age column support
StudioListTable: Favourite column with FavoriteCell
StudioListTable: RelatedCell uses sub_studios pluralized label

Generic sort support (ListTable.tsx)

  • Added optional sortable flag to IColumn — columns without a backend
    sort field (image, composite, etc.) are marked sortable: false.
  • Added onSort, sortBy, sortDirection props. Active sort column
    renders ▲ (ASC) or ▼ (DEC) indicator.
  • Clicking a header fires onSort(column.value) — new column starts ASC,
    same column toggles direction.

Per-entity wiring (*List.tsx + *ListTable.tsx)

Each entity follows the same pattern:

  1. *ListTable.tsx — accepts onSort/sortBy/sortDirection props
    and threads them through to ListTable. Non-sortable columns marked
    sortable: false.
  2. *List.tsxFiltered*List defines a module-level
    columnSortMap translating column values to backend sort fields.
    onSort useCallback maps the column value via this map before calling
    filter.setSortBy(). The inner *List component derives
    activeSortColumn by reverse-mapping filter.sortBy back to the
    column value so ListTable matches correctly for arrow display.

Column mappings (value → backend field)

Entity Mappings
Studios scene_count→scenes_count, image_count→images_count, gallery_count→galleries_count
Scenes scene_code→code
Galleries images→images_count
Performers height_cm→height, penis_length_cm→penis_length, scene_count→scenes_count, gallery_count→galleries_count, image_count→images_count, age→birthdate
Tags scene_count→scenes_count, gallery_count→galleries_count, image_count→images_count, group_count→groups_count, performer_count→performers_count, studio_count→studios_count

Bug fixes

  • Active sort arrow not showing: filter.sortBy contained the backend
    field name (e.g. "scenes_count") but ListTable compared against
    column.value (e.g. "scene_count"). Fixed by reverse-mapping to
    column value before passing to the table.
  • TypeScript index error: filter.sortBy could be undefined, which
    can't be used as an object index. Fixed with ?? "" fallback.

Studio child count label

  • StudioListTable RelatedCell now shows "N Studios" (pluralized via
    intl.formatMessage) instead of a bare number.

Files changed (11 files)

File Changes
ui/.../List/ListTable.tsx Generic sort header support
ui/.../Studios/StudioList.tsx Sort plumbing + column map
ui/.../Studios/StudioListTable.tsx Sort props + non-sortable columns + "N Studios" label
ui/.../Scenes/SceneList.tsx Sort plumbing + column map
ui/.../Scenes/SceneListTable.tsx Sort props + non-sortable columns
ui/.../Galleries/GalleryList.tsx Sort plumbing + column map
ui/.../Galleries/GalleryListTable.tsx Sort props + non-sortable columns
ui/.../Performers/PerformerList.tsx Sort plumbing + column map
ui/.../Performers/PerformerListTable.tsx Sort props + non-sortable columns
ui/.../Tags/TagList.tsx Sort plumbing + column map
ui/.../Tags/TagListTable.tsx Sort props + non-sortable columns

@WithoutPants WithoutPants left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please include screenshots when making UI changes. Users shouldn't need to try it locally to find out what has changed.

For reference, here's how it looks on the scenes page, sorting by title ascending:

Image

I think it would look more consistent if we used the same icons as the sort selector and that the icon should be placed at the right edge:

Image

I would extract the Icon element from ListFilter.tsx and create an exported React component in the same file called SortByIcon which accepts a SortDirectionEnum. SortBySelect should replace its Icon with the new component.

I was able to style it like above by putting contents of the column header th element into a div and styling it with display: flex; align-items: center; justify-content: space-between.

The other thing I noticed is that you've used string instead of SortDirectionEnum for the sortBy parameter. Please change these to use the enum.

While you are in there, please add padding-bottom: 0 to the .table-list .table thead th css rule. The bottom padding is unnecessary and would be a good incidental fix.

@WithoutPants WithoutPants added this to the Version 0.32.0 milestone Jun 11, 2026
@WithoutPants WithoutPants added the improvement Something needed tweaking. label Jun 11, 2026
const playScene = usePlayScene();

const playSelected = useCallback(() => {
// populate queue and go to first scene

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert these removals. There's no reason to remove them for this change.

Comment on lines +192 to +198
const reverseSortMap = useMemo(() => {
const rev: Record<string, string> = {};
Object.entries(sceneColumnSortMap).forEach(([k, v]) => {
rev[v] = k;
});
return rev;
}, []);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved out of the function component. I would probably add a utility function for creating the reverse map (probably in src/utils/data/ts), and the reverse sort map can be declared below the sort map above.

Do this for the other list components as well.

));
}, [visibleColumns]);
return visibleColumns.map((column) => {
const isSortable = column.sortable !== false && onSort;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do sortable columns outnumber non-sortable columns? If not, then perhaps we should consider defaulting to sortable = false.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering my own question, it appears that there's more sortable than not. I don't love defaulting to false with a name like sortable, but I don't have an alternative.

@WithoutPants

Copy link
Copy Markdown
Collaborator

There's a bunch of unrelated changes to the server code.

slick-daddy and others added 9 commits June 25, 2026 15:26
- Add sortable: false to performer_count column in StudioListTable
  (backend does not support sorting by performer_count)
- Add weight_kg: weight mapping to performerColumnSortMap
  (backend expects 'weight', not 'weight_kg')
- Revert unnecessary comment removals in SceneList.tsx
- Create getActiveSortColumn utility in src/utils/data.ts
- Use utility in all 5 entity List.tsx files, removing duplicated
  useMemo+reverseSortMap pattern
- Add 'favorite' to performerSortOptions, studioSortOptions, tagSortOptions
- Remove sortable:false from favourite columns in PerformerListTable and TagListTable
- Add favourite column to StudioListTable with FavoriteCell
- Add favourite/favorite mapping to all three entity ColumnSortMaps
SQLite sorts NULLs first for both ASC and DESC. Add IS NULL
ordering clause to push NULL values to the end in all sort
directions.

- getSort: add IS NULL to default, name, title, and filesize cases
- performer: sortByOCounter, sortByLastPlayedAt, sortByLastOAt,
  sortByLatestScene
- group: sortByOCounter
- studio: sortByLatestScene
- scene: last_played_at, last_o_at
- Revert IS NULL addition in getSort (caused unintended side effects)
- Add targeted birthdate case in getPerformerSort that flips direction
  (birthdate is inverse to age: lower date = older, so ASC exchanges
  with DESC) and adds IS NULL for nulls-last behavior
@slick-daddy

Copy link
Copy Markdown
Contributor Author

There's a bunch of unrelated changes to the server code.

  • Dropped unrelated 3 UI fix commits. My initial thought was it would be nice apply them on the way as well but I agree that they are out of scope, so let's drop them.

  • Commit 4193f2a has also been dropped since it came from syncing fork by mistake.

  • Rebased with cherry-picking and ready for review.

@WithoutPants

Copy link
Copy Markdown
Collaborator

There's still changes to server files in this PR - specifically the sqlite package.

These IS NULL ORDER BY changes were server-side SQL behavior
improvements that don't belong in the click-to-sort column
headers PR. The sort options arrays (favorite, birthdate
handling) are kept since they directly enable the clickable
column feature.
Comment thread pkg/sqlite/performer.go
sortQuery += qb.sortByLastOAt(direction)
case "latest_scene":
sortQuery += qb.sortByLatestScene(direction)
case "birthdate":

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't drop favorite sort option and birthdate case because they are needed for Favourite/Age column clicks.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You didn't include these changes in the original description.

@WithoutPants WithoutPants left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ensure that the description in your PRs matches the changes you've made. You made undocumented changes in this PR, specifically changes to the server sorting behaviour, changing the related studios column presentation, and the favourite column to the studio list table. Having to pore through the changes to discover undocumented changes is significantly time-consuming.

Comment thread pkg/sqlite/performer.go Outdated
));
}, [visibleColumns]);
return visibleColumns.map((column) => {
const isSortable = column.sortable !== false && onSort;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering my own question, it appears that there's more sortable than not. I don't love defaulting to false with a name like sortable, but I don't have an alternative.

Comment thread ui/v2.5/src/components/Studios/StudioListTable.tsx Outdated
Comment thread pkg/sqlite/performer.go
sortQuery += qb.sortByLastOAt(direction)
case "latest_scene":
sortQuery += qb.sortByLatestScene(direction)
case "birthdate":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You didn't include these changes in the original description.

withoutContext
/>
{showAllCounts && !!studio.o_counter_all ? (
{showAllCounts && studio.o_counter_all ? (

@slick-daddy slick-daddy Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an incidental fix from pnpm run lint:js:fix. It flagged unnecessary double-negation (!!) on o_counter fields which are already numbers, not booleans.

@slick-daddy
slick-daddy requested a review from WithoutPants July 8, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Something needed tweaking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants