mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 08:01:38 +00:00
feat(frontend): hide empty folders/users in home tree view
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UomUzAvrC2cto1T2QUBNnT
This commit is contained in:
@@ -129,11 +129,17 @@ async fn list_folders(
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListFoldernamesQuery {
|
||||
pub non_empty: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_foldernames(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListFoldernamesQuery>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -144,6 +150,18 @@ async fn list_foldernames(
|
||||
// LIMIT would let a page return fewer than per_page while authorized folders remain
|
||||
// on later DB pages, stopping such a caller early.)
|
||||
let mut sql = String::from("SELECT name FROM folder WHERE workspace_id = $1");
|
||||
if lq.non_empty.unwrap_or(false) {
|
||||
// Only folders holding at least one non-archived script/flow/app (the kinds the
|
||||
// homepage lists) — a single scan per table semi-joined on the owner segment,
|
||||
// rather than a correlated probe per folder. Runs under the user's RLS, so a
|
||||
// folder whose items the caller cannot read counts as empty for them.
|
||||
sql.push_str(
|
||||
" AND name IN ( \
|
||||
SELECT split_part(path, '/', 2) FROM script WHERE workspace_id = $1 AND path LIKE 'f/%' AND archived = false \
|
||||
UNION SELECT split_part(path, '/', 2) FROM flow WHERE workspace_id = $1 AND path LIKE 'f/%' AND archived = false \
|
||||
UNION SELECT split_part(path, '/', 2) FROM app WHERE workspace_id = $1 AND path LIKE 'f/%')",
|
||||
);
|
||||
}
|
||||
let restricted = match build_scope_path_filter(&authed, "folders", "read") {
|
||||
ScopePathFilter::AllowAll => None,
|
||||
ScopePathFilter::Restricted { exact, prefix } => {
|
||||
|
||||
@@ -545,10 +545,16 @@ async fn update_tutorial_progress(
|
||||
Ok("tutorial progress updated".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListUsernamesQuery {
|
||||
pub non_empty: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_usernames(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(lq): Query<ListUsernamesQuery>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
if *CLOUD_HOSTED && w_id == "demo" {
|
||||
return Ok(Json(vec![
|
||||
@@ -557,9 +563,25 @@ async fn list_usernames(
|
||||
]));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id)
|
||||
let rows = if lq.non_empty.unwrap_or(false) {
|
||||
// Only users whose personal space holds at least one non-archived
|
||||
// script/flow/app (the kinds the homepage lists) — a single scan per table
|
||||
// semi-joined on the owner segment. Runs under the user's RLS, so a user whose
|
||||
// items the caller cannot read counts as empty for them.
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT username FROM usr WHERE workspace_id = $1 AND username IN ( \
|
||||
SELECT split_part(path, '/', 2) FROM script WHERE workspace_id = $1 AND path LIKE 'u/%' AND archived = false \
|
||||
UNION SELECT split_part(path, '/', 2) FROM flow WHERE workspace_id = $1 AND path LIKE 'u/%' AND archived = false \
|
||||
UNION SELECT split_part(path, '/', 2) FROM app WHERE workspace_id = $1 AND path LIKE 'u/%')",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_scalar!("SELECT username from usr WHERE workspace_id = $1", &w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -5707,6 +5707,13 @@ paths:
|
||||
- user
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: non_empty
|
||||
in: query
|
||||
description:
|
||||
only list usernames whose user space contains at least one non-archived
|
||||
script, flow or app (default false)
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: user
|
||||
@@ -19583,6 +19590,13 @@ paths:
|
||||
description: only list the folders the user is member of (default false)
|
||||
schema:
|
||||
type: boolean
|
||||
- name: non_empty
|
||||
in: query
|
||||
description:
|
||||
only list folders containing at least one non-archived script, flow
|
||||
or app (default false)
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: folder list
|
||||
|
||||
@@ -124,12 +124,17 @@
|
||||
new Set<string>([...(pipelineFoldersRes.current ?? []), ...pipelineMemberFolders])
|
||||
)
|
||||
|
||||
// The workspace's full folder list, independent of which items are paged in,
|
||||
// so the owner facet and tree show every folder even when its items sit far
|
||||
// down the sorted stream. Cheap and cached per workspace.
|
||||
let archived = $state(false)
|
||||
|
||||
// The workspace's folder list, independent of which items are paged in, so the
|
||||
// owner facet and tree show a folder even when its items sit far down the sorted
|
||||
// stream. Folders with no listable runnable are dropped server-side (non_empty)
|
||||
// so auto-created/unused folders don't clutter the tree — except in the
|
||||
// archived-only view, where a folder whose items are all archived would count as
|
||||
// empty yet must stay reachable.
|
||||
let folderNamesRes = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws) => {
|
||||
[() => $workspaceStore, () => archived],
|
||||
async ([ws, archivedOnly]) => {
|
||||
if (!ws) return [] as string[]
|
||||
// Page to exhaustion: listFolderNames is capped per page, so a workspace
|
||||
// with more folders than the cap would otherwise be truncated.
|
||||
@@ -137,7 +142,12 @@
|
||||
const all: string[] = []
|
||||
try {
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await FolderService.listFolderNames({ workspace: ws, page, perPage })
|
||||
const batch = await FolderService.listFolderNames({
|
||||
workspace: ws,
|
||||
page,
|
||||
perPage,
|
||||
nonEmpty: archivedOnly ? undefined : true
|
||||
})
|
||||
all.push(...batch)
|
||||
if (batch.length < perPage) break
|
||||
}
|
||||
@@ -148,16 +158,22 @@
|
||||
}
|
||||
)
|
||||
let allFolderOwners = $derived((folderNamesRes.current ?? []).map((f) => `f/${f}`))
|
||||
// Every workspace username, so a user whose items sit beyond the loaded browse
|
||||
// window is still a selectable owner chip (scoping the stream to `u/<user>/`).
|
||||
// Without this, user owners would derive only from loaded rows and a user past
|
||||
// the first page would be unreachable in the tree without searching.
|
||||
// Every workspace username with listable runnables, so a user whose items sit
|
||||
// beyond the loaded browse window is still a selectable owner chip (scoping the
|
||||
// stream to `u/<user>/`). Without this, user owners would derive only from
|
||||
// loaded rows and a user past the first page would be unreachable in the tree
|
||||
// without searching. Users with no runnable are dropped server-side (non_empty)
|
||||
// to keep the tree uncluttered — except in the archived-only view, same as the
|
||||
// folder list above.
|
||||
let usernamesRes = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws) => {
|
||||
[() => $workspaceStore, () => archived],
|
||||
async ([ws, archivedOnly]) => {
|
||||
if (!ws) return [] as string[]
|
||||
try {
|
||||
return await UserService.listUsernames({ workspace: ws })
|
||||
return await UserService.listUsernames({
|
||||
workspace: ws,
|
||||
nonEmpty: archivedOnly ? undefined : true
|
||||
})
|
||||
} catch {
|
||||
return [] as string[] // best-effort facet
|
||||
}
|
||||
@@ -587,8 +603,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let archived = $state(false)
|
||||
|
||||
const TREE_VIEW_SETTING_NAME = 'treeView'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
|
||||
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
|
||||
|
||||
Reference in New Issue
Block a user