From 7973549e7f34266dcce6e53edf6d3276015ced70 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 27 Jul 2026 18:17:32 +0200 Subject: [PATCH] feat: list draft-only runnables on the homepage again (#10361) * feat: list draft-only runnables on the homepage again Co-Authored-By: Claude Opus 5 (1M context) * perf: trim the draft listing index to the columns that measure Co-Authored-By: Claude Opus 5 (1M context) * fix: address review findings on draft-only runnables Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- ...151319_draft_only_listing_indexes.down.sql | 1 + ...27151319_draft_only_listing_indexes.up.sql | 22 ++ backend/windmill-api/openapi.yaml | 15 ++ backend/windmill-api/src/db.rs | 3 + backend/windmill-api/src/runnables.rs | 244 +++++++++++++++++- .../src/lib/components/home/ItemsList.svelte | 34 ++- .../src/lib/components/home/treeViewUtils.ts | 17 +- 7 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 backend/migrations/20260727151319_draft_only_listing_indexes.down.sql create mode 100644 backend/migrations/20260727151319_draft_only_listing_indexes.up.sql diff --git a/backend/migrations/20260727151319_draft_only_listing_indexes.down.sql b/backend/migrations/20260727151319_draft_only_listing_indexes.down.sql new file mode 100644 index 0000000000..b18887d736 --- /dev/null +++ b/backend/migrations/20260727151319_draft_only_listing_indexes.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS draft_kind_user_listing_idx; diff --git a/backend/migrations/20260727151319_draft_only_listing_indexes.up.sql b/backend/migrations/20260727151319_draft_only_listing_indexes.up.sql new file mode 100644 index 0000000000..ee299e4739 --- /dev/null +++ b/backend/migrations/20260727151319_draft_only_listing_indexes.up.sql @@ -0,0 +1,22 @@ +-- Index backing draft-only rows in the unified homepage listing +-- (/runnables/list) and its per-owner counts (/runnables/counts). +-- +-- Every draft query there is "this caller's drafts of this kind", so `typ` and +-- `email` are key columns rather than filters: without them the existing +-- draft_user_listing_idx hands back the caller's drafts of every kind and each +-- per-kind branch throws most away. +-- +-- Deliberately just these three columns. `path` would not help, because a draft +-- is listed, grouped and filtered under the path it says it will deploy to, +-- which lives in the draft JSON and is only resolved after the DISTINCT ON +-- dedup, so no index can prune on it. Nor can "draft-only" itself be pushed +-- into the index: it means "no deployed row at this path", and Postgres rejects +-- a subquery in an index predicate. The anti-join prunes that at query time off +-- the deployed tables' own (workspace_id, path) indexes. +-- +-- Keep this file free of statement separators outside the statement below, +-- comments included: the CONCURRENTLY rewrite in windmill-api/src/db.rs splits +-- the file on them and would run the trailing comment text as SQL. +-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs. +CREATE INDEX IF NOT EXISTS draft_kind_user_listing_idx + ON draft (workspace_id, typ, email); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8b4d35e70c..074b547d85 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -10564,6 +10564,14 @@ paths: description: opaque keyset cursor from a previous page's next_cursor schema: type: string + - name: include_draft_only + in: query + description: >- + also list the caller's drafts at paths with no deployed row, sorted + and paginated with the deployed ones. Ignored for operators, in the + archived view, and under a label filter (a draft carries no labels). + schema: + type: boolean responses: "200": description: a page of merged, ordered runnables @@ -10598,6 +10606,13 @@ paths: description: include library scripts (no runnable main) schema: type: boolean + - name: include_draft_only + in: query + description: >- + also count the caller's drafts at paths with no deployed row, + matching the same flag on /runnables/list + schema: + type: boolean responses: "200": description: >- diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 9f3cb5892b..04d21c7b53 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -99,6 +99,9 @@ lazy_static::lazy_static! { (20260727093955, include_str!( "../../migrations/20260727093955_runnable_owner_prefix_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), + (20260727151319, include_str!( + "../../migrations/20260727151319_draft_only_listing_indexes.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), ].into_iter().collect(); } diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index d89f8b6875..58b62eede1 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -64,6 +64,9 @@ struct ListRunnablesQuery { per_page: Option, /// Opaque keyset cursor from a previous page's `next_cursor`. cursor: Option, + /// Also list the caller's drafts at paths with no deployed row. Off by + /// default so picker callers stay deployed-only. + include_draft_only: Option, } // Absent optional fields are omitted (not serialized as null) to match the @@ -289,6 +292,65 @@ fn branch_sqls() -> Branches { Branches { script, flow, app } } +/// The draft-only branch for a kind: the caller's drafts at paths carrying no +/// deployed row, projected into the same column set as `branch_sqls` so they +/// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract. +fn draft_branch_sql(kind: &str) -> String { + let (typ_pred, deployed) = match kind { + "script" => ("d.typ = 'script'", "script"), + "flow" => ("d.typ = 'flow'", "flow"), + _ => ("d.typ IN ('app', 'raw_app')", "app"), + }; + // Scripts bind the Path widget to `script.path`, so the typed path round-trips + // through the draft JSON's own `path`; flows and apps write a separate + // `draft_path` only when it differs from the deployed one. See scripts.rs. + let typed_path = if kind == "script" { + "path" + } else { + "draft_path" + }; + // `auto_kind` is only what the editor stamped into the draft — a `// pipeline` + // annotation is not re-derived from the content here, unlike the per-kind + // endpoints. That errs toward listing a pipeline member as its own row rather + // than folding it into a pipeline entry and hiding it. + let kind_cols = match kind { + "script" => { + "d.value->>'language' as language, d.value->>'kind' as script_kind, \ + d.value->>'auto_kind' as auto_kind, false as raw_app" + } + _ => { + "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + (d.typ = 'raw_app') as raw_app" + } + }; + format!( + "SELECT '{kind}' as kind, o.path, o.summary, o.workspace_id, '{{}}'::jsonb as extra_perms, \ + false as starred, false as archived, \ + true as is_draft, true as draft_only, o.draft_path, \ + json_build_array(json_build_object('username', $2::text)) as draft_users, \ + NULL::text[] as labels, NULL::text[] as inherited_labels, \ + NULL::bool as ws_error_handler_muted, o.created_at as edited_at, \ + NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ + o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \ + FROM ( \ + SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \ + COALESCE(d.value->>'summary', '') as summary, \ + NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \ + {kind_cols} \ + FROM draft d \ + WHERE d.workspace_id = $1 AND {typ_pred} AND (d.email = $3 OR d.email IS NULL) \ + AND NOT EXISTS (SELECT 1 FROM {deployed} x \ + WHERE x.workspace_id = d.workspace_id AND x.path = d.path) \ + -- Owned draft over a legacy NULL-email one, then newest: the app branch spans + -- two draft kinds (`app` and `raw_app`) that can both exist at a path, and the + -- pick decides raw_app, summary and draft_path. Same tiebreak as apps.rs. + ORDER BY d.path, (d.email IS NULL), d.created_at DESC \ + ) o" + ) +} + async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension, @@ -331,13 +393,24 @@ async fn list_runnables( }; let mut common: Vec = vec!["o.workspace_id = $1".to_string()]; + // Same predicates for the draft branches, except the search: a draft is named by + // the path typed in the editor, its stored path being a generated `draft_` + // nobody searches for. Labels never apply (the draft branches are dropped under a + // label filter), so they are not repeated here. + let mut draft_common: Vec = vec!["o.workspace_id = $1".to_string()]; if let Some(ps) = q.path_start.as_ref().filter(|s| !s.is_empty()) { let p = add_bind(&mut binds, format!("{}%", escape_like(ps))); common.push(format!("o.path LIKE {}", p)); + // An owner filter follows where the draft says it will live, not the + // `u//draft_` it is parked at. + draft_common.push(format!("COALESCE(o.draft_path, o.path) LIKE {}", p)); } if let Some(search) = q.search.as_ref().filter(|s| !s.is_empty()) { let p = add_bind(&mut binds, format!("%{}%", escape_like(search))); common.push(format!("(o.summary ILIKE {p} OR o.path ILIKE {p})")); + draft_common.push(format!( + "(o.summary ILIKE {p} OR o.path ILIKE {p} OR o.draft_path ILIKE {p})" + )); } if let Some(label) = q.label.as_ref().filter(|s| !s.is_empty()) { for l in label.split(',') { @@ -348,6 +421,7 @@ async fn list_runnables( } } let common_where = common.join(" AND "); + let draft_common_where = draft_common.join(" AND "); // Keyset predicate for pages after the first (non-starred rows only). A // row-value comparison keeps the composite order; the key is cast to the @@ -428,11 +502,38 @@ async fn list_runnables( app_extras.push(s.clone()); } + // Draft-only rows are the caller's own work in progress: never archived, so they + // have no place in the archived view, and carrying no labels of their own they are + // out of scope of a label filter (as in the per-kind endpoints). Operators don't + // see other people's drafts and have none of their own to see. + let include_drafts = q.include_draft_only.unwrap_or(false) + && !authed.is_operator + && !show_archived + && q.label.as_ref().filter(|s| !s.is_empty()).is_none(); + let draft_extras_for = |kind: &str| -> Vec { + let mut extras: Vec = vec![]; + let scope = match kind { + "script" => &script_scope, + "flow" => &flow_scope, + _ => &app_scope, + }; + // The lib filter reads the same projected `auto_kind`, so a draft-only library + // script hides with the deployed ones. + if kind == "script" && (!q.include_without_main.unwrap_or(false) || authed.is_operator) { + extras.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string()); + } + if let Some(s) = scope { + extras.push(s.clone()); + } + extras + }; + // Favorite filter for a branch: Some(true) = starred only, Some(false) = // non-starred only, None = no filter. Both views pin starred on the first page // (each is one row per path), so the paged stream always passes Some(false). let build_branch = |base: &str, kind: &str, + common: &str, extras: &[String], fav: Option, keyset: Option<&str>, @@ -441,7 +542,7 @@ async fn list_runnables( // Base-table predicates go inside the projection subquery (they read // o.*/favorite.*); the keyset reads the projected sort aliases, so it // sits in the wrapper WHERE where those aliases are visible. - let mut w = vec![common_where.clone()]; + let mut w = vec![common.to_string()]; w.extend(extras.iter().cloned()); match fav { Some(true) => w.push("favorite.path IS NOT NULL".to_string()), @@ -476,7 +577,37 @@ async fn list_runnables( "app" => (&branches.app, &app_extras), _ => return None, }; - Some(build_branch(base, kind, extras, fav, keyset, limit)) + Some(build_branch( + base, + kind, + &common_where, + extras, + fav, + keyset, + limit, + )) + }; + + let draft_branch_for = |kind: &str, + fav: Option, + keyset: Option<&str>, + limit: Option| + -> Option { + if !include_drafts || !kinds.contains(&kind) { + return None; + } + // `fav` is ignored: with no favorite join there is nothing to filter on, and the + // starred pass skips draft branches entirely. + let _ = fav; + Some(build_branch( + &draft_branch_sql(kind), + &format!("draft_{kind}"), + &draft_common_where, + &draft_extras_for(kind), + None, + keyset, + limit, + )) }; let run_union = |branches_sql: Vec, limit: Option| -> String { @@ -497,6 +628,9 @@ async fn list_runnables( // favorite is a single row in either — the starred-first contract holds in the // archived view too, and the pinned first page stays bounded. if first_page { + // No draft branch here: a draft-only path has no favorite row (the UI won't let + // you star one), so it would scan the caller's whole draft slice per kind to + // return nothing. The main stream below takes them unfiltered instead. let starred_branches: Vec = ["script", "flow", "app"] .iter() .filter_map(|k| branch_for(k, Some(true), None, None)) @@ -516,10 +650,14 @@ async fn list_runnables( // Main paged stream: non-starred rows (starred were pinned on the first page above). let main_fav = Some(false); - let ns_branches: Vec = ["script", "flow", "app"] - .iter() - .filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))) - .collect(); + let ns_branches: Vec = + ["script", "flow", "app"] + .iter() + .filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))) + .chain(["script", "flow", "app"].iter().filter_map(|k| { + draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)) + })) + .collect(); let mut next_cursor: Option = None; if !ns_branches.is_empty() { @@ -551,6 +689,9 @@ struct CountRunnablesQuery { kinds: Option, /// Include library scripts (no runnable main). Ignored for flows/apps. include_without_main: Option, + /// Also count the caller's drafts at paths with no deployed row, matching + /// the same flag on `/list`. + include_draft_only: Option, } #[derive(Serialize)] @@ -702,6 +843,7 @@ async fn count_runnables_by_owner( for r in query.fetch_all(&db).await? { counts.insert(r.owner, r.count); } + add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?; counts.retain(|_, c| *c > 0); return Ok(Json(RunnableCountsResponse { counts })); } @@ -774,6 +916,96 @@ async fn count_runnables_by_owner( counts.entry(r.owner).or_insert(r.count); } + add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?; counts.retain(|_, c| *c > 0); Ok(Json(RunnableCountsResponse { counts })) } + +/// Adds the caller's draft-only rows to `counts`, in the same shape `/list` +/// returns them so a badge never disagrees with the rows behind it. +/// +/// Not restricted to the readable owners the deployed passes walk: a draft is +/// the caller's own and `/list` reads it off the non-RLS `draft` table, so +/// scoping it to folder grants would hide a count for a row that still lists. +/// Deployed and draft rows can't overlap (the anti-join is what makes a draft +/// "draft-only"), so the two counts add. +async fn add_draft_counts( + authed: &ApiAuthed, + db: &DB, + w_id: &str, + kinds: &[&str], + with_libs: bool, + q: &CountRunnablesQuery, + counts: &mut HashMap, +) -> Result<(), Error> { + if !q.include_draft_only.unwrap_or(false) || authed.is_operator { + return Ok(()); + } + // $1 = workspace, $2 = the caller's email. + let mut binds: Vec = vec![]; + let branches: Vec = kinds + .iter() + .map(|kind| { + let (typ_pred, deployed, domain) = match *kind { + "script" => ("d.typ = 'script'", "script", "scripts"), + "flow" => ("d.typ = 'flow'", "flow", "flows"), + _ => ("d.typ IN ('app', 'raw_app')", "app", "apps"), + }; + // The owner a draft counts under is where it says it will live, not the + // `u//draft_` it is parked at — same path `/list` groups and + // filters on. Scripts round-trip the typed path through the draft JSON's + // own `path`; flows and apps write `draft_path`. See scripts.rs. + let typed_path = if *kind == "script" { "path" } else { "draft_path" }; + let effective_path = + format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path"); + let mut w = vec![ + "d.workspace_id = $1".to_string(), + typ_pred.to_string(), + "(d.email = $2 OR d.email IS NULL)".to_string(), + format!( + "NOT EXISTS (SELECT 1 FROM {deployed} x \ + WHERE x.workspace_id = d.workspace_id AND x.path = d.path)" + ), + ]; + if *kind == "script" { + // Same rule as the deployed count, over the `auto_kind` the editor + // stamped into the draft: a pipeline member is folded into its + // pipeline entry rather than listed, and `lib` follows the caller. + let mut hidden = vec!["'pipeline'"]; + if !with_libs { + hidden.push("'lib'"); + } + w.push(format!( + "(d.value->>'auto_kind' IS NULL OR d.value->>'auto_kind' NOT IN ({}))", + hidden.join(", ") + )); + } + if let Some(s) = scope_path_predicate(authed, domain, "d", 2, &mut binds) { + w.push(s); + } + // DISTINCT ON collapses a path holding both the caller's draft and a + // legacy NULL-email one, which `/list` shows as a single row. Wrapped + // because its ORDER BY would otherwise bind to the whole UNION. + format!( + "SELECT path FROM (SELECT DISTINCT ON (d.path) {effective_path} FROM draft d WHERE {} ORDER BY d.path, (d.email IS NULL), d.created_at DESC) s", + w.join(" AND ") + ) + }) + .collect(); + let sql = format!( + "SELECT split_part(p.path, '/', 1) || '/' || split_part(p.path, '/', 2) AS owner, \ + count(*)::bigint AS count \ + FROM ({}) p GROUP BY 1", + branches.join(" UNION ALL ") + ); + let mut query = sqlx::query_as::<_, OwnerCount>(&sql) + .bind(w_id) + .bind(&authed.email); + for b in &binds { + query = query.bind(b); + } + for r in query.fetch_all(db).await? { + *counts.entry(r.owner).or_insert(0) += r.count; + } + Ok(()) +} diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 0765dc82eb..8eafad1824 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -47,7 +47,7 @@ import DrawerContent from '../common/drawer/DrawerContent.svelte' import Item from './Item.svelte' import TreeViewRoot from './TreeViewRoot.svelte' - import type { ItemType } from './treeViewUtils' + import { effectivePath, type ItemType } from './treeViewUtils' import Popover from '$lib/components/meltComponents/Popover.svelte' import { getContext, tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' @@ -260,6 +260,9 @@ // so a folder's full contents load on demand rather than relying on the // folder happening to be within the loaded browse window. pathStart: ownerFilter ? ownerFilter + '/' : undefined, + // Your own not-yet-deployed work belongs in the list you browse; the + // endpoint sorts and pages it with everything else. + includeDraftOnly: true, perPage: 100, cursor }) @@ -284,7 +287,7 @@ if (it.type === 'script') { // Pipeline-member scripts are folded into their pipeline entry. if (it.auto_kind === 'pipeline') { - const m = it.path.match(/^f\/([^/]+)\//) + const m = effectivePath(it).match(/^f\/([^/]+)\//) if (m) memberFolders.add(m[1]) continue } @@ -419,6 +422,7 @@ includeWithoutMain: includeWithoutMain ? true : undefined, kinds: itemKind !== 'all' ? itemKind : undefined, pathStart: `${owner}/`, + includeDraftOnly: true, perPage: 100, cursor: more ? st?.cursor : undefined }) @@ -436,7 +440,9 @@ // other owner's untouched) so a re-sort/re-filter swaps its items atomically // without blanking the whole tree; load-more appends to what's already shown. const prefix = `${owner}/` - const base = more ? treeOwnerItems : treeOwnerItems.filter((x) => !x.path.startsWith(prefix)) + const base = more + ? treeOwnerItems + : treeOwnerItems.filter((x) => !effectivePath(x).startsWith(prefix)) const have = new Set(base.map(itemKey)) const merged = [...base] for (const it of res.items ?? []) { @@ -483,7 +489,7 @@ // previous scope's items instead of re-fetching. Drop them and let expand reload. if (treeLazyMode) { const open = new Set(toReload) - treeOwnerItems = treeOwnerItems.filter((x) => open.has(ownerOf(x.path))) + treeOwnerItems = treeOwnerItems.filter((x) => open.has(ownerOf(effectivePath(x)))) ownerLoad = Object.fromEntries(Object.entries(ownerLoad).filter(([o]) => open.has(o))) } await loadRunnables(true) @@ -507,9 +513,10 @@ filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined ) { if (!filterUserFoldersType || !filterUserFolders) return true - if (filterUserFoldersType === 'only f/*') return item.path.startsWith('f/') + const path = effectivePath(item) + if (filterUserFoldersType === 'only f/*') return path.startsWith('f/') if (filterUserFoldersType === 'u/username and f/*') - return item.path.startsWith('f/') || item.path.startsWith(`u/${$userStore?.username}/`) + return path.startsWith('f/') || path.startsWith(`u/${$userStore?.username}/`) return true // should not happen } @@ -705,7 +712,7 @@ // counting 0 gets no chip at all — including your own space. let owners = $derived.by(() => { const self = $userStore?.username ? `u/${$userStore.username}` : undefined - const loaded = filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [] + const loaded = filteredItems?.map((x) => ownerOf(effectivePath(x))) ?? [] if (ownerCounts == undefined) { // Counts still in flight: the folder/user lists resolve first, so painting the full // list here would show the wall this drops and snap to the ranked set a tick later. @@ -769,7 +776,9 @@ const res = await ScriptService.countRunnablesByOwner({ workspace: ws, kinds: kind !== 'all' ? kind : undefined, - includeWithoutMain: withoutMain ? true : undefined + includeWithoutMain: withoutMain ? true : undefined, + // Same scope as the listing, so a badge counts the rows behind it. + includeDraftOnly: true }) return res.counts } catch { @@ -866,6 +875,7 @@ includeWithoutMain: withoutMain ? true : undefined, kinds: kind !== 'all' ? kind : undefined, pathStart: owner ? owner + '/' : undefined, + includeDraftOnly: true, perPage: 1000 }) } catch { @@ -912,6 +922,7 @@ includeWithoutMain: withoutMain ? true : undefined, kinds: kind !== 'all' ? kind : undefined, pathStart: owner ? owner + '/' : undefined, + includeDraftOnly: true, perPage: 1000, cursor }) @@ -1308,7 +1319,12 @@ {filter} items={preFilteredItems} bind:filteredItems - f={(x) => (x.summary ? x.summary + ' (' + x.path + ')' : x.path)} + f={(x) => { + // A draft-only row is named by the path typed in the editor — its stored path is a + // generated `draft_` nobody types into the search box. + const p = x.draft_only && x.draft_path ? x.draft_path : x.path + return x.summary ? x.summary + ' (' + p + ')' : p + }} {opts} /> diff --git a/frontend/src/lib/components/home/treeViewUtils.ts b/frontend/src/lib/components/home/treeViewUtils.ts index e1d769fd56..6103096547 100644 --- a/frontend/src/lib/components/home/treeViewUtils.ts +++ b/frontend/src/lib/components/home/treeViewUtils.ts @@ -27,6 +27,21 @@ export type UserItem = { items: (ItemType | FolderItem)[] } +/** + * Where an item belongs: its owner, folder, and the name it is filtered and searched by. + * A draft-only item is parked at a generated `u//draft_` but names the path it + * will deploy to, and that is what the row shows and what the server lists, filters and + * counts it under — so every categorization has to follow it. `path` stays the storage + * identity that the editor link and the row key resolve. + */ +export function effectivePath(item: { + path: string + draft_only?: boolean | null + draft_path?: string | null +}): string { + return (item.draft_only && item.draft_path) || item.path +} + function insertItemInFolder( root: (ItemType | FolderItem | UserItem)[], item: ItemType, @@ -75,7 +90,7 @@ export function groupItems( const root: (ItemType | FolderItem | UserItem)[] = [] items.forEach((item) => { - const pathSplit = item.path.split('/') + const pathSplit = effectivePath(item).split('/') if (pathSplit[0] === 'u') { const username = pathSplit[1] let userItem = root.find((f): f is UserItem => 'username' in f && f.username === username) as