Skip to content

Commit 6806fc2

Browse files
authored
fix(resources): sort folders and resources as one list so pinned items reach the top (#6213)
* fix(resources): stop hoisting folders so pinned items reach the top Folders and their sibling resources were sorted as two lists and concatenated folders-first, so a pinned file, table, or knowledge base could never rank above an unpinned folder — pinning only reordered within each partition. Sort them as one list instead: pinned -> sort key -> name, in a shared `sortResources` comparator. Rows with no value for the active column (a folder has no row count or token count) sort last in both directions, and the name tiebreak is never inverted by `desc`. Also aligns the surfaces that browse the same resources: the mothership resource trees, the collapsed sidebar flyout (whose nested levels partitioned folders-first while its own root interleaved), the sidebar file tree, and the recently-deleted tiebreak. Files' sort params were nullable only to encode "folders name/asc, files updated/desc"; with one list that state is gone, so they default to updated/desc like Tables and Knowledge. * improvement(resources): prefetch pinned ids and members with the resource lists Files, Tables, and Knowledge already prefetched their items and folders, but not the two lists a complete row needs. Pinned ids are the list's primary sort key now, so a page that painted before they arrived rendered the whole list in the wrong order and then visibly re-sorted. Members back the Owner column, which painted empty and filled in after. Both now hydrate alongside the lists via `prefetchResourceListChrome`. Pinned keys move to `hooks/queries/utils/pinned-item-keys` so the server prefetch can address them without pulling the contracts barrel and the optimistic-mutation machinery into the route. * chore(knowledge): drop the dead pre-redesign knowledge base sort `sortKnowledgeBases` was a second, complete ordering for knowledge bases (name/createdAt/updatedAt/docCount) left over from the old sort dropdown, with no consumers since the shared Resource sort menu replaced it — the kind of duplicate that silently diverges from the page it shadows. Its `SortOption`/`SortOrder` types and the `SORT_OPTIONS` list that fed it were dead with it. `utils/sort.ts` now holds only a filter, so it is renamed to `utils/filter.ts`. * fix(resources): sort unknown owners last and align the knowledge sort menu An owner id that resolves to no workspace member renders an empty cell, but its sort key was `''`, so those rows floated to the TOP of an ascending Owner sort while every other valueless cell sorted last. They now key `null` and follow the same nulls-last rule on all three pages. The Knowledge sort menu also listed Owner after Last Updated while its column order — and Tables' menu — put Owner before it. * chore(knowledge): delete the dead base-card grid view `BaseCard`/`BaseCardSkeleton`/`BaseCardSkeletonGrid` were the pre-redesign grid-card knowledge base view, kept alive only by the barrel re-export — no call sites since the Resource list replaced it. Removing it orphans `components/constants.ts` entirely (its sort types went with the dead sort; its three class-name constants had no consumers left), so that goes too.
1 parent feaddc4 commit 6806fc2

29 files changed

Lines changed: 1016 additions & 977 deletions

File tree

.claude/rules/sim-url-state.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams,
179179
Two modes, chosen by whether you pass a default:
180180

181181
- **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state.
182-
- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s).
182+
- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. document chunks: with no sort the query omits `sortBy` entirely and the server's own order applies). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s).
183183

184184
Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there.
185185

apps/sim/app/workspace/[workspaceId]/components/folders/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export {
1515
renderMoveOption,
1616
renderMoveOptions,
1717
} from './move-options'
18+
export type { SortableResource } from './resource-sort'
19+
export { sortResources } from './resource-sort'
1820
export { folderNavParsers, folderNavUrlKeys } from './search-params'
1921
export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation'
2022
export { useFolderNavigation } from './use-folder-navigation'
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
type SortableResource,
7+
sortResources,
8+
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'
9+
10+
type Kind = 'folder' | 'item'
11+
12+
function entry(
13+
name: string,
14+
kind: Kind,
15+
key: string | number | null,
16+
pinned = false
17+
): SortableResource<{ name: string; kind: Kind }> {
18+
return { item: { name, kind }, pinned, name, key }
19+
}
20+
21+
const names = (entries: SortableResource<{ name: string; kind: Kind }>[]) =>
22+
entries.map((e) => e.item.name)
23+
24+
describe('sortResources', () => {
25+
it('interleaves folders and items on the sort key instead of hoisting folders', () => {
26+
const sorted = sortResources(
27+
[
28+
entry('b-folder', 'folder', 'b-folder'),
29+
entry('a-item', 'item', 'a-item'),
30+
entry('c-item', 'item', 'c-item'),
31+
],
32+
'asc'
33+
)
34+
35+
expect(names(sorted)).toEqual(['a-item', 'b-folder', 'c-item'])
36+
})
37+
38+
it('floats a pinned item above every unpinned folder', () => {
39+
const sorted = sortResources(
40+
[
41+
entry('a-folder', 'folder', 'a-folder'),
42+
entry('b-folder', 'folder', 'b-folder'),
43+
entry('z-item', 'item', 'z-item', true),
44+
],
45+
'asc'
46+
)
47+
48+
expect(names(sorted)).toEqual(['z-item', 'a-folder', 'b-folder'])
49+
})
50+
51+
it('keeps pinned rows on top when the direction flips', () => {
52+
const sorted = sortResources(
53+
[
54+
entry('a-folder', 'folder', 3),
55+
entry('b-item', 'item', 2),
56+
entry('c-item', 'item', 1, true),
57+
],
58+
'desc'
59+
)
60+
61+
expect(names(sorted)).toEqual(['c-item', 'a-folder', 'b-item'])
62+
})
63+
64+
it('orders pinned rows among themselves by the active key', () => {
65+
const sorted = sortResources(
66+
[
67+
entry('a-item', 'item', 1, true),
68+
entry('b-folder', 'folder', 3, true),
69+
entry('c-item', 'item', 2, true),
70+
],
71+
'desc'
72+
)
73+
74+
expect(names(sorted)).toEqual(['b-folder', 'c-item', 'a-item'])
75+
})
76+
77+
it('sorts rows with no value for the column last in both directions', () => {
78+
const rows = [
79+
entry('folder-a', 'folder', null),
80+
entry('item-big', 'item', 10),
81+
entry('item-small', 'item', 1),
82+
]
83+
84+
expect(names(sortResources([...rows], 'asc'))).toEqual(['item-small', 'item-big', 'folder-a'])
85+
expect(names(sortResources([...rows], 'desc'))).toEqual(['item-big', 'item-small', 'folder-a'])
86+
})
87+
88+
it('still floats a pinned row that has no value for the column', () => {
89+
const sorted = sortResources(
90+
[entry('item-a', 'item', 5), entry('folder-z', 'folder', null, true)],
91+
'asc'
92+
)
93+
94+
expect(names(sorted)).toEqual(['folder-z', 'item-a'])
95+
})
96+
97+
it('sorts a row whose cell renders empty last, not first', () => {
98+
// An owner id that resolves to no workspace member renders an empty cell, so its key is
99+
// `null` — passing `''` instead would float those rows to the top of an ascending sort.
100+
const rows = [entry('unknown-owner', 'item', null), entry('ada', 'item', 'Ada')]
101+
102+
expect(names(sortResources([...rows], 'asc'))).toEqual(['ada', 'unknown-owner'])
103+
expect(names(sortResources([...rows], 'desc'))).toEqual(['ada', 'unknown-owner'])
104+
})
105+
106+
it('breaks ties by name ascending regardless of direction', () => {
107+
const rows = [
108+
entry('charlie', 'item', 1),
109+
entry('alpha', 'folder', 1),
110+
entry('bravo', 'item', 1),
111+
]
112+
113+
expect(names(sortResources([...rows], 'asc'))).toEqual(['alpha', 'bravo', 'charlie'])
114+
expect(names(sortResources([...rows], 'desc'))).toEqual(['alpha', 'bravo', 'charlie'])
115+
})
116+
117+
it('breaks ties by name among rows that all lack a value', () => {
118+
const sorted = sortResources(
119+
[entry('zeta', 'folder', null), entry('alpha', 'folder', null)],
120+
'desc'
121+
)
122+
123+
expect(names(sorted)).toEqual(['alpha', 'zeta'])
124+
})
125+
126+
it('compares string keys case-insensitively via localeCompare', () => {
127+
const sorted = sortResources(
128+
[entry('Beta', 'item', 'Beta'), entry('alpha', 'folder', 'alpha')],
129+
'asc'
130+
)
131+
132+
expect(names(sorted)).toEqual(['alpha', 'Beta'])
133+
})
134+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { SortDirection } from '@/lib/url-state'
2+
3+
/**
4+
* One row of a foldered list, decorated with everything the comparator needs so the sort
5+
* itself stays O(N log N) on precomputed values rather than re-deriving keys per comparison.
6+
*/
7+
export interface SortableResource<T> {
8+
/** The payload handed back in sorted order. */
9+
item: T
10+
/** Pinned rows float to the top of every column and direction. */
11+
pinned: boolean
12+
/** Display name — the final, direction-independent tiebreaker. */
13+
name: string
14+
/**
15+
* Value for the active sort column, or `null` when the column does not apply to this row
16+
* (a folder has no row count, token count, or connector list). Null keys sort last in both
17+
* directions, the same "nulls last" rule the log list applies server-side.
18+
*/
19+
key: string | number | null
20+
}
21+
22+
/**
23+
* Orders folders and the resources they contain as ONE list.
24+
*
25+
* Folders are not hoisted above their siblings: a folder outranking every file meant a pinned
26+
* file could never reach the top of the list, since pinning only reordered within each
27+
* partition. Precedence is pinned → sort key → name, so pinning is the only thing that jumps
28+
* a row, and it does so regardless of which column is sorted or which way.
29+
*
30+
* Neither the pinned bit nor the name tiebreaker is inverted by `desc` — pinning is a
31+
* user-declared priority rather than another sort key, and a stable A→Z tiebreak keeps rows
32+
* that tie on the active column (equal timestamps, a whole column of `null` folder keys) in
33+
* one predictable order instead of the arbitrary one their source arrays happened to have.
34+
*/
35+
export function sortResources<T>(
36+
entries: SortableResource<T>[],
37+
direction: SortDirection
38+
): SortableResource<T>[] {
39+
return entries.sort((a, b) => {
40+
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
41+
42+
if (a.key === null || b.key === null) {
43+
if (a.key !== b.key) return a.key === null ? 1 : -1
44+
} else {
45+
const cmp =
46+
typeof a.key === 'number' && typeof b.key === 'number'
47+
? a.key - b.key
48+
: String(a.key).localeCompare(String(b.key))
49+
if (cmp !== 0) return direction === 'asc' ? cmp : -cmp
50+
}
51+
52+
return a.name.localeCompare(b.name)
53+
})
54+
}

0 commit comments

Comments
 (0)