Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions .changeset/11626-grouped-nav-reorder.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,4 @@
'@object-ui/app-shell': patch
---

Drag-to-reorder works on a grouped sidebar menu, within each level (objectui#11626). `NavigationRenderer` had a sortable path only in its group-free arm. Every stock app's menu is grouped, so the console's `enableReorder` drew no grip anywhere a user could reach.

**`@object-ui/layout`.** With `enableReorder` on, each group's children are now a sortable list of their own, and so is each run of top-level entries between two groups. An entry moves within its level only. It never moves into or out of a group, because which group an entry sits in is the app's structure, not a personal order. A move is reported through the existing `onReorder(reorderedItems)`, always with the top-level list. After a move within a group, that list is as drawn and the group's `children` are reordered. The moved level's entries carry their new positions as `order` (0, 1, 2, …), as a move in a group-free menu already did. While `searchQuery` narrows a grouped menu, the menu offers no grip, because a narrowed group shows only some of its children.

The drag grip is now the row's drag activator, in both arms. dnd-kit's sortable attributes (`role="button"`, `tabIndex={0}`, the sortable description) used to sit on the row wrapper while the listeners sat on the grip, so every row was a focusable button that no key could start a drag from. The attributes and listeners now sit together on the grip. The one element a keyboard can focus per row is now the element the keyboard sensor listens on.

**`@object-ui/app-shell`.** The sidebar's personal order store (localStorage `objectui-nav-order-APP`) keeps an order for each group under the group's `id`, beside the top level's `__root__`. The `id` is the one key a group has that holds across reloads and locales, because its label is translated. A move writes only the level that moved, so the rest of the menu keeps following the app. Every stored level is applied on load. A group-free app's `__root__` record is written and read exactly as before, so orders users already saved keep working.

A saved order now holds where the app authors `order` on its navigation entries. The renderer sorts each level by `order`, and the store used to apply a saved order by array position only, so on such an app a drag was stored and then sorted straight back, in a group-free menu as well. A level with a saved order now carries its saved positions as `order`. A level with no saved order is passed on untouched.

**Clause-②: no.** No export, prop, type member, callback parameter or i18n key is added or removed, and no signature changes. `NavigationRendererProps.enableReorder` and `onReorder` keep their types. Their doc comments, which ship in the published `.d.ts`, now state the within-level scope and what `onReorder` receives.
Drag-to-reorder of a grouped sidebar menu, within each level (objectui#11626), was retired before it was released. objectui#12059 removed every drag-to-reorder of the sidebar menu: an app's menu order is authored in Studio, and every user sees that order. Nothing of objectui#11626 ships, because objectui#12059 retires the menu reorder whole: `NavigationRenderer`'s `enableReorder` / `onReorder`, the menu drag grip, and the sidebar's per-user menu order in localStorage (`objectui-nav-order-APP`), each of which predates objectui#11626, are gone. The objectui#12059 changeset describes what replaced them: the pinned section is the one place a user orders entries.
15 changes: 15 additions & 0 deletions .changeset/12059-sidebar-no-reorder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/layout': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

The console sidebar no longer lets a user drag the app's menu into a private order; the Pinned section is now the one place a user orders entries (objectui#12059). Arranging an app's navigation is Studio's job: the menu is drawn in the order authored for the app, and every user sees that order. This reverses objectui#11626, which had extended menu drag-to-reorder to grouped menus.

**`@object-ui/layout` (breaking, declared `minor` per the version policy).** `NavigationRenderer` retires `enableReorder` and `onReorder`, and with them every sortable path through the menu: no menu row draws a grip or carries a sortable role. `AppSchemaRenderer` retires the same two props, which only handed them through. Two props are added for the pinned section: `pinnedOrder` (the pinned entry ids in the user's order; the section draws in that order, an unnamed pinned entry following in menu order) and `onPinnedReorder` (makes the pinned rows sortable and receives the drawn ids in their new order). A pinned row is dragged by the row itself: no grip at rest, a grab cursor, and a line marking where the row will land. From the keyboard, with the row's link focused, Space picks it up, the arrow keys move it, Space drops it and Escape cancels; Enter still follows the link. The row's link stays a link and the row's one focus stop. The click a pointer drag ends with is not followed.

**`@object-ui/app-shell`.** The sidebar no longer reads or writes the per-user menu order it kept in localStorage (`objectui-nav-order-APP`); a key left from before is ignored. The pinned order is stored with the pins, in the `UserDataAdapter`-backed favorites, so it syncs across devices the way the pins do: it is the order of the `type: 'nav'` entries in the stored list. A new pin now joins the end of that order (a content favorite still goes first), and an unpin leaves the others in order. `useFavorites()` gains `reorderNavPins(navIds)`, which puts the named pins in a new order in the places they already hold, so a pin another app's sidebar draws and every content favorite stay put; `useNavPins()` gains `reorderPins(orderedIds)` over it, and its `pinnedIds` lists the pins in the user's order. At the nav-pin cap a new pin still joins the end and the earliest-pinned one rolls off. Pins made before this change are now drawn in their stored order, which is newest first (a pin used to be put at the front of the list), rather than in menu order, until the user drags them.

**`@object-ui/i18n` (breaking, declared `minor`).** `console.nav.dragToReorder`, the label of the retired menu grip, is removed from all ten packs. `view.dragToReorder` is a different key and stays.

**Clause-②: yes.** Published surface changes: `NavigationRendererProps` loses `enableReorder` / `onReorder` and gains `pinnedOrder` / `onPinnedReorder`; `AppSchemaRendererProps` loses `enableReorder` / `onReorder`; the return of `useFavorites()` gains `reorderNavPins`; the return of `useNavPins()` gains `reorderPins`; the `console.nav.dragToReorder` pack key leaves.
11 changes: 8 additions & 3 deletions content/docs/guide/user-state-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,8 @@ Each provider keeps a monotonic `hydrationToken`. If the user switches accounts
| `UserStateAdaptersProvider` | `@object-ui/app-shell` | Adapter registry; place above the providers. |
| `useAttachUserStateAdapters()` | `@object-ui/app-shell` | Imperative API for a bridge component to attach/detach adapters. |
| `useUserStateAdapter(kind)` | `@object-ui/app-shell` | Read the currently-attached adapter (rarely needed by app code). |
| `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds }` |
| `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins }`. |
| `useFavorites()` | `@object-ui/app-shell` | `{ favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite, clearFavorites, setPinned, isPinned, pinnedNavIds, reorderNavPins }` |
| `useNavPins()` | `@object-ui/app-shell` | Thin shim over `useFavorites` for sidebar pinning — `{ pinnedIds, togglePin, isPinned, applyPins, clearPins, reorderPins }`. `pinnedIds` is in the user's pinned order. |
| `useRecentItems()` | `@object-ui/app-shell` | `{ recentItems, addRecentItem, clearRecentItems }` |
| `useRecentItemLabel()` | `@object-ui/app-shell` | `(item) => string` — a recent entry's label, resolved on render from the item's metadata in the current language. |
| `useFlowPaletteRecents()` | `@object-ui/app-shell` | `{ recents, recordRecent }` — flow-designer add-node MRU; falls back to localStorage outside a provider. |
Expand Down Expand Up @@ -233,8 +233,13 @@ interface FavoriteItem {
- **Cross-device sync.** Pinning a sidebar item on the desktop shows up in the same place on mobile, behind the same backend that already syncs Favorites.
- **Bucketed caps.** Content favorites and nav pins each have an independent cap of 20 — migrating sidebar pins never evicts starred records.
- **Legacy migration (one-shot).** The old `objectui-nav-pins` localStorage key (a plain `string[]`) is read once on first mount, converted to `type:'nav'` favorites with `pinned: true`, then removed. If an adapter is attached, the migrated set is also pushed to the backend on the next debounce window.
- **The pinned order is the user's.** The order of the `type: 'nav'` entries in the stored list is the order of the sidebar's Pinned section, so it syncs with the pins. A new pin joins the end (a content favorite still goes first); unpinning removes the entry and leaves the others in order. `reorderNavPins(navIds)` (`useNavPins().reorderPins`) puts the named pins in a new order in the places they already hold, so a pin another app's sidebar draws, and every content favorite, stay where they are. At the nav-pin cap, a new pin still joins the end and the earliest-pinned one rolls off.

UIs that surface "favorites" (HomePage Starred, the sidebar Favorites section) filter out `type === 'nav'` so nav-pin records don't pollute the user-visible list. The sidebar Pinned section is rendered from the live navigation tree decorated by `useNavPins.applyPins`.
UIs that surface "favorites" (HomePage Starred, the sidebar Favorites section) filter out `type === 'nav'` so nav-pin records don't pollute the user-visible list. The sidebar Pinned section is rendered from the live navigation tree decorated by `useNavPins.applyPins`, in the order `pinnedIds` gives.

### Ordering the sidebar

The Pinned section is the only part of the console sidebar a user orders. They drag a pinned row by the row itself (no grip is drawn at rest; the cursor turns to a grab hand and a line marks where the row will land), or, with the row's link focused, press Space to pick it up, the arrow keys to move it, and Space to drop it (Escape cancels). The order is saved through `reorderPins`. The app's own menu is not reorderable in the console: its order is the one authored for the app in Studio, and every user sees that order.

## Record-overlay width

Expand Down
7 changes: 7 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,13 @@ adapter syncs both flows. The legacy `objectui-nav-pins` localStorage key is
migrated on first mount and then removed. Content favorites (20) and nav
pins (20) each have an independent cap. See the guide below for details.

The sidebar's Pinned section keeps the user's order, stored with the pins:
`pinnedIds` lists them in it, a new pin joins the end, and
`reorderPins(orderedIds)` saves a new order. The sidebar passes both to
`NavigationRenderer` (`pinnedOrder` / `onPinnedReorder`), which lets a user
drag a pinned row, or move it from the keyboard. The app's own menu is not
reorderable: its order is authored in Studio.

See [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
for the adapter contract, backend schema, and how to plug in your own backend.

Expand Down
82 changes: 72 additions & 10 deletions packages/app-shell/src/context/FavoritesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,18 @@ interface FavoritesContextValue {
isPinned: (id: string) => boolean;
/**
* Set of NavigationItem ids that are currently pinned via a `type === 'nav'`
* favorite. Memoised; safe to use as a dependency.
* favorite, in the user's pinned order (objectui#12059): the order of the
* nav favorites in `favorites`. Memoised; safe to use as a dependency.
*/
pinnedNavIds: Set<string>;
/**
* Put the named nav pins in this order (objectui#12059). `navIds` lists the
* `navId`s of nav favorites as the user arranged them; they take the
* positions those favorites already hold, in the order given, so a pin that
* is not named (one another app's sidebar draws, say) and every content
* favorite keep their places. An id with no nav favorite is ignored.
*/
reorderNavPins: (navIds: string[]) => void;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -148,6 +157,51 @@ function capByBucket(items: FavoriteItem[]): FavoriteItem[] {
return result;
}

/**
* `items` with `entry` added. A content favorite goes first (the most recent
* star leads). A nav pin goes LAST: the nav favorites' order is the user's
* pinned order, and a new pin joins it at the end (objectui#12059). When that
* takes the nav bucket past its cap, the earliest-pinned nav favorite rolls
* off, as the oldest did when pins were prepended.
*/
function withAddedFavorite(items: FavoriteItem[], entry: FavoriteItem): FavoriteItem[] {
if (entry.type !== 'nav') return capByBucket([entry, ...items]);
const next = [...items, entry];
const navPins = next.filter(f => f.type === 'nav');
if (navPins.length <= MAX_NAV_PINS) return capByBucket(next);
const earliest = navPins
.slice(0, -1)
.reduce((a, b) => (b.favoritedAt < a.favoritedAt ? b : a));
return capByBucket(next.filter(f => f !== earliest));
}

/**
* `items` with the nav favorites named by `navIds` put in that order, in the
* positions they already hold; `items` itself when nothing moves.
*/
function withNavPinOrder(items: FavoriteItem[], navIds: string[]): FavoriteItem[] {
const byNavId = new Map<string, FavoriteItem>();
for (const f of items) {
if (f.type === 'nav' && f.navId && !byNavId.has(f.navId)) byNavId.set(f.navId, f);
}
const wanted: FavoriteItem[] = [];
const seen = new Set<string>();
for (const id of navIds) {
const f = byNavId.get(id);
if (f && !seen.has(id)) { wanted.push(f); seen.add(id); }
}
const moving = new Set(wanted);
let k = 0;
let changed = false;
const next = items.map(f => {
if (!moving.has(f)) return f;
const placed = wanted[k++];
if (placed !== f) changed = true;
return placed;
});
return changed ? next : items;
}

function loadFavorites(userId?: string | null): FavoriteItem[] {
try {
const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
Expand Down Expand Up @@ -362,10 +416,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) {
(item: Omit<FavoriteItem, 'favoritedAt'>) => {
setFavorites(prev => {
if (prev.some(f => f.id === item.id)) return prev;
const updated = capByBucket([
{ ...item, favoritedAt: new Date().toISOString() },
...prev,
]);
const updated = withAddedFavorite(prev, { ...item, favoritedAt: new Date().toISOString() });
commit(updated);
return updated;
});
Expand All @@ -390,10 +441,7 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) {
const exists = prev.some(f => f.id === item.id);
const updated = exists
? prev.filter(f => f.id !== item.id)
: capByBucket([
{ ...item, favoritedAt: new Date().toISOString() },
...prev,
]);
: withAddedFavorite(prev, { ...item, favoritedAt: new Date().toISOString() });
commit(updated);
return updated;
});
Expand Down Expand Up @@ -445,6 +493,18 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) {
[commit],
);

const reorderNavPins = useCallback(
(navIds: string[]) => {
setFavorites(prev => {
const updated = withNavPinOrder(prev, navIds);
if (updated === prev) return prev;
commit(updated);
return updated;
});
},
[commit],
);

const value = useMemo<FavoritesContextValue>(() => {
const pinnedNavIds = new Set<string>();
for (const f of favorites) {
Expand All @@ -461,8 +521,9 @@ export function FavoritesProvider({ children }: FavoritesProviderProps) {
setPinned,
isPinned: (id: string) => favorites.some(f => f.id === id && !!f.pinned),
pinnedNavIds,
reorderNavPins,
};
}, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned]);
}, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned, reorderNavPins]);

return (
<FavoritesContext.Provider value={value}>
Expand Down Expand Up @@ -497,6 +558,7 @@ export function useFavorites(): FavoritesContextValue {
setPinned: () => {},
isPinned: () => false,
pinnedNavIds: new Set<string>(),
reorderNavPins: () => {},
};
}
return ctx;
Expand Down
Loading
Loading