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
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,14 @@ async def get_dashboards(
keyword=keyword,
dashboard_type=filter_types,
)
# The list only decides visibility. Whether the user may edit/delete/
# manage a board is resolved lazily by the client (per-resource
# my-permissions) at the moment it reaches for those actions, so the
# list no longer pays to BatchCheck "edit" for every candidate — the
# cost drops from (candidates x 2) to (candidates x 1).
action_map = await self._action_map(
candidates,
("visible", "edit"),
("visible",),
)
res = [dashboard for dashboard in candidates if "visible" in action_map.get(str(dashboard.id), frozenset())]
default_dashboard = await DashboardDao.get_default_dashboard(user_id=self.login_user.user_id)
Expand All @@ -151,10 +156,6 @@ async def get_dashboards(
tmp = DashboardRead.model_validate(one)
if default_dashboard and one.id == default_dashboard.dashboard_id:
tmp.is_default = True
tmp.write = "edit" in action_map.get(
str(one.id),
frozenset(),
)
result.append(tmp)
return result

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cn } from "@/utils"
import { MoreHorizontal } from "lucide-react"
import { useContext, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useLazyDashboardPermission } from "../../hook"
import { Dashboard } from "../../types/dataConfig"


Expand All @@ -27,11 +28,6 @@ interface DashboardListItemProps {
onDelete: (id: string) => void
onPermission?: (dashboard: Dashboard) => void
permissionBadge?: React.ReactNode
permissionActions: string[]
/** Resolved by the owner of the permission lookup, not re-derived here. */
visible?: boolean
/** Admins are waved through on identity, so they hold no grants to read. */
privileged?: boolean
}

export function DashboardListItem({
Expand All @@ -45,9 +41,6 @@ export function DashboardListItem({
onDelete,
onPermission,
permissionBadge,
permissionActions,
visible = false,
privileged = false,
}: DashboardListItemProps) {
const { t } = useTranslation("dashboard")

Expand All @@ -57,6 +50,11 @@ export function DashboardListItem({
const { toast } = useToast()
const { appConfig } = useContext(locationContext)

// Visibility is decided by the server list; edit/delete/manage_permission
// are resolved lazily the moment the user reaches for them (menu open or a
// double-click rename), so the list never front-loads a request per row.
const { actions, privileged, ensureLoaded } = useLazyDashboardPermission(String(dashboard.id))

useEffect(() => {
setTitle(dashboard.title)
}, [dashboard])
Expand All @@ -68,15 +66,15 @@ export function DashboardListItem({
}
}, [isEditing])

// The sidebar only renders items it already resolved as visible; "visible"
// is a relation in the permission model, never one of the returned actions.
const canView = privileged || visible
const canEdit = privileged || permissionActions.includes("edit")
const canDelete = privileged || permissionActions.includes("delete")
const canEdit = privileged || actions.includes("edit")
const canDelete = privileged || actions.includes("delete")
const canManagePermission =
privileged || permissionActions.includes("manage_permission")
privileged || actions.includes("manage_permission")

const handleDoubleClick = () => {
// Prefetch happened on hover; by the time a double-click lands the
// action list is usually ready. Trigger once more in case it was not.
ensureLoaded()
if (canEdit) setIsEditing(true)
}

Expand Down Expand Up @@ -113,15 +111,14 @@ export function DashboardListItem({
}
}

if (!canView) return null

return (
<div
className={cn(
"group flex items-center justify-between px-2 py-[6px] rounded-lg cursor-pointer transition-colors",
selected ? "bg-[#002FFF]/10" : "hover:bg-[#f5f2f2f2]",
)}
onClick={onSelect}
onMouseEnter={ensureLoaded}
>
<div className="flex-1 min-w-0 mr-2">
{isEditing ? (
Expand All @@ -144,7 +141,7 @@ export function DashboardListItem({
{permissionBadge}
{dashboard.is_default && <Badge variant="outline" className="border border-primary rounded-sm py-0 px-1 text-primary scale-75">{t('default')}</Badge>}

<DropdownMenu>
<DropdownMenu onOpenChange={(open) => { if (open) ensureLoaded() }}>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type React from "react"
import { useContext, useMemo, useState } from "react"
import { useMutation, useQueryClient } from "react-query"
import { useNavigate } from "react-router-dom"
import { DashboardsQueryKey, useDashboardPermissions } from "../../hook"
import { DashboardsQueryKey } from "../../hook"
import { Dashboard } from "../../types/dataConfig"
import { DashboardListItem } from "./DashboardListItem"
import { useTranslation } from "react-i18next"
Expand Down Expand Up @@ -51,30 +51,17 @@ export function DashboardSidebar({
// Permission management state
const [permDialogOpen, setPermDialogOpen] = useState(false);
const [permTarget, setPermTarget] = useState<{ id: string; name: string } | null>(null);
const dashboardIds = useMemo(
() => dashboards.map((dashboard) => String(dashboard.id)),
[dashboards],
)
const {
permissions: dashboardPermissions,
loading: permissionsLoading,
privileged,
} = useDashboardPermissions(dashboardIds)

const canCreate = useMemo(() => {
return user.web_menu?.includes('create_dashboard') || user.role === 'admin'
}, [user])

// Visibility is already decided by the server list; the sidebar only narrows
// it by the search box. Per-row edit/delete/manage permissions are resolved
// lazily inside each item the moment the user reaches for them.
const filteredDashboards = useMemo(() => {
const visibleDashboards = dashboards.filter(
(dashboard) =>
privileged ||
Object.hasOwn(dashboardPermissions, String(dashboard.id)),
)
if (!searchQuery.trim()) return visibleDashboards

return visibleDashboards.filter((dashboard) => dashboard.title.toLowerCase().includes(searchQuery.toLowerCase()))
}, [dashboardPermissions, dashboards, privileged, searchQuery])
if (!searchQuery.trim()) return dashboards
return dashboards.filter((dashboard) => dashboard.title.toLowerCase().includes(searchQuery.toLowerCase()))
}, [dashboards, searchQuery])

const handleSearch = useMiniDebounce((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value)
Expand Down Expand Up @@ -230,11 +217,7 @@ export function DashboardSidebar({
</div>

<div className="overflow-y-auto space-y-2 h-[calc(100vh-174px-var(--license-banner-h,0px))]">
{permissionsLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t("loading")}
</div>
) : filteredDashboards.length === 0 ? (
{filteredDashboards.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
{searchQuery ? t('noMatchingDashboards') : t('noDashboards')}
</div>
Expand All @@ -250,12 +233,7 @@ export function DashboardSidebar({
onDefault={onDefault}
onShare={onShare}
onDelete={handleDelete}
permissionActions={dashboardPermissions[String(dashboard.id)] ?? []}
visible
privileged={privileged}
onPermission={privileged || dashboardPermissions[String(dashboard.id)]?.includes("manage_permission")
? (d) => { setPermTarget({ id: String(d.id), name: d.title }); setPermDialogOpen(true); }
: undefined}
onPermission={(d) => { setPermTarget({ id: String(d.id), name: d.title }); setPermDialogOpen(true); }}
/>
))
)}
Expand Down
53 changes: 52 additions & 1 deletion src/frontend/platform/src/pages/Dashboard/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { publishDashboard } from "@/controllers/API/dashboard";
import { getMyResourcePermissionsApi } from "@/controllers/API/permission";
import { userContext } from "@/contexts/userContext";
import { useEditorDashboardStore } from "@/store/dashboardStore";
import { useContext, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "react-query";

Expand Down Expand Up @@ -103,6 +103,57 @@ export function useDashboardPermissions(resourceIds: string[]): {
return { permissions, loading, privileged }
}

/**
* Lazily resolve what the current user may do on a single dashboard.
*
* The dashboard list already decides visibility on the server, so the list no
* longer front-loads a `my-permissions` request per row. This hook fetches the
* per-resource action list on demand — call `ensureLoaded` the moment the user
* reaches for an action (e.g. opens the item menu or double-clicks to rename).
*
* `privileged` short-circuits admins: the backend waves them through on identity
* alone, so they hold no grant rows and their action list would read empty.
* In-flight requests are de-duplicated across callers by `getDashboardPermission
* Actions`, so hovering then opening the same item issues at most one request.
*/
export function useLazyDashboardPermission(resourceId: string): {
actions: string[]
loaded: boolean
loading: boolean
privileged: boolean
ensureLoaded: () => void
} {
const { user } = useContext(userContext)
const userId = user?.user_id == null ? "" : String(user.user_id)
const privileged = user?.role === "admin"
const [actions, setActions] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
const [loading, setLoading] = useState(false)

// A fresh dashboard id invalidates any previously resolved actions.
useEffect(() => {
setActions([])
setLoaded(false)
setLoading(false)
}, [resourceId, userId])

const ensureLoaded = useCallback(() => {
if (privileged || loaded || loading || !resourceId) return
setLoading(true)
getDashboardPermissionActions(userId, String(resourceId))
.then((resolved) => setActions(resolved))
// A rejected my-permissions request means "no extra actions"; the
// row is already visible because the server returned it.
.catch(() => setActions([]))
.finally(() => {
setLoaded(true)
setLoading(false)
})
}, [privileged, loaded, loading, userId, resourceId])

return { actions, loaded, loading, privileged, ensureLoaded }
}

export const usePublishDashboard = () => {
const queryClient = useQueryClient();
const { toast } = useToast();
Expand Down
Loading