-
Notifications
You must be signed in to change notification settings - Fork 6
feat: enable system objects visibility in Studio sidebar #918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c5f7eb1
f9a7744
ba7c4c6
eee9dae
c2fac1c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ import { | |
| AppWindow, | ||
| Layers, | ||
| Eye, | ||
| EyeOff, | ||
| FileCode, | ||
| Palette, | ||
| CheckSquare, | ||
|
|
@@ -30,9 +31,10 @@ import { | |
| Anchor, | ||
| UserCog, | ||
| ChevronRight, | ||
| Settings, | ||
| type LucideIcon, | ||
| } from "lucide-react" | ||
| import { useState, useEffect, useCallback } from "react" | ||
| import { useState, useEffect, useCallback, useMemo } from "react" | ||
| import { useClient } from '@objectstack/client-react'; | ||
| import type { InstalledPackage } from '@objectstack/spec/kernel'; | ||
|
|
||
|
|
@@ -128,6 +130,24 @@ const PROTOCOL_GROUPS: ProtocolGroup[] = [ | |
| /** Types that are internal / should be hidden from the sidebar */ | ||
| const HIDDEN_TYPES = new Set(['plugin', 'plugins', 'kind', 'app', 'apps', 'package']); | ||
|
|
||
| /** System namespace used for FQN-based names (e.g., sys__user) */ | ||
| const SYSTEM_NAMESPACE = 'sys'; | ||
|
|
||
| /** System object FQN prefix (namespace + double underscore separator) */ | ||
| const SYSTEM_FQN_PREFIX = `${SYSTEM_NAMESPACE}__`; | ||
|
|
||
| /** Legacy system object name prefix (namespace + single underscore) */ | ||
| const SYSTEM_LEGACY_PREFIX = `${SYSTEM_NAMESPACE}_`; | ||
|
|
||
| /** Check if an object item is a system object */ | ||
| function isSystemObject(item: any): boolean { | ||
| if (item.isSystem === true) return true; | ||
| if (item.namespace === SYSTEM_NAMESPACE) return true; | ||
| const name = item.name || item.id || ''; | ||
| // Match FQN format (sys__user) or legacy format (sys_user) | ||
| return name.startsWith(SYSTEM_FQN_PREFIX) || name.startsWith(SYSTEM_LEGACY_PREFIX); | ||
| } | ||
|
|
||
| /** Icon mapping for package types */ | ||
| const PKG_TYPE_ICONS: Record<string, LucideIcon> = { | ||
| app: AppWindow, plugin: Layers, driver: Database, server: Globe, | ||
|
|
@@ -161,6 +181,9 @@ export function AppSidebar({ | |
| // Track which metadata *types* are expanded (show individual items) | ||
| const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set(['object', 'objects'])); | ||
|
|
||
| // Toggle to show/hide system objects in the Data protocol group | ||
| const [showSystemInData, setShowSystemInData] = useState(true); | ||
|
|
||
| const toggleTypeExpanded = (type: string) => { | ||
| setExpandedTypes(prev => { | ||
| const next = new Set(prev); | ||
|
|
@@ -216,12 +239,35 @@ export function AppSidebar({ | |
| label.toLowerCase().includes(searchQuery.toLowerCase()) || | ||
| name.toLowerCase().includes(searchQuery.toLowerCase()); | ||
|
|
||
| // Extract system objects from loaded metadata (object/objects types) | ||
| const systemObjects = useMemo(() => { | ||
| const objectTypes = ['object', 'objects']; | ||
| const sysItems: any[] = []; | ||
| for (const type of objectTypes) { | ||
| const items = metaItems[type] || []; | ||
| sysItems.push(...items.filter(isSystemObject)); | ||
| } | ||
| return sysItems; | ||
| }, [metaItems]); | ||
|
|
||
| // Filter system objects out of the Data protocol group when toggled off | ||
| const filteredMetaItems = useMemo(() => { | ||
| if (showSystemInData) return metaItems; | ||
| const result = { ...metaItems }; | ||
| for (const type of ['object', 'objects']) { | ||
| if (result[type]) { | ||
| result[type] = result[type].filter((item: any) => !isSystemObject(item)); | ||
| } | ||
| } | ||
| return result; | ||
| }, [metaItems, showSystemInData]); | ||
|
|
||
| // Compute visible groups: only show groups that have at least one type with items | ||
| const visibleGroups = PROTOCOL_GROUPS.map(group => { | ||
| const visibleTypes = group.types.filter(t => | ||
| metaTypes.includes(t) && !HIDDEN_TYPES.has(t) && (metaItems[t]?.length ?? 0) > 0 | ||
| metaTypes.includes(t) && !HIDDEN_TYPES.has(t) && (filteredMetaItems[t]?.length ?? 0) > 0 | ||
| ); | ||
| const totalItems = visibleTypes.reduce((sum, t) => sum + (metaItems[t]?.length ?? 0), 0); | ||
| const totalItems = visibleTypes.reduce((sum, t) => sum + (filteredMetaItems[t]?.length ?? 0), 0); | ||
| return { ...group, visibleTypes, totalItems }; | ||
| }).filter(g => g.totalItems > 0); | ||
|
|
||
|
|
@@ -328,11 +374,25 @@ export function AppSidebar({ | |
| <group.icon className="mr-1.5 h-3.5 w-3.5" /> | ||
| <span className="flex-1 min-w-0 truncate">{group.label}</span> | ||
| <span className="shrink-0 text-xs tabular-nums text-sidebar-foreground/50">{group.totalItems}</span> | ||
| {/* System objects filter toggle for Data group */} | ||
| {group.key === 'data' && systemObjects.length > 0 && ( | ||
| <button | ||
| type="button" | ||
| title={showSystemInData ? 'Hide system objects' : 'Show system objects'} | ||
| aria-label={showSystemInData ? 'Hide system objects' : 'Show system objects'} | ||
| onClick={(e) => { e.stopPropagation(); setShowSystemInData(!showSystemInData); }} | ||
| className="ml-1 shrink-0 rounded p-0.5 text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors" | ||
| > | ||
| {showSystemInData | ||
| ? <Eye className="h-3 w-3" /> | ||
| : <EyeOff className="h-3 w-3" />} | ||
| </button> | ||
| )} | ||
| </SidebarGroupLabel> | ||
| <SidebarGroupContent> | ||
| <SidebarMenu> | ||
| {group.visibleTypes.map(type => { | ||
| const items = metaItems[type] || []; | ||
| const items = filteredMetaItems[type] || []; | ||
| const TypeIcon = getTypeIcon(type); | ||
| const typeLabel = getTypeLabel(type); | ||
| const isObjectType = type === 'object' || type === 'objects'; | ||
|
|
@@ -408,9 +468,66 @@ export function AppSidebar({ | |
|
|
||
| {/* ── System ── */} | ||
| <SidebarGroup> | ||
| <SidebarGroupLabel>System</SidebarGroupLabel> | ||
| <SidebarGroupLabel> | ||
| <Settings className="mr-1.5 h-3.5 w-3.5" /> | ||
| <span className="flex-1 min-w-0 truncate">System</span> | ||
| {systemObjects.length > 0 && ( | ||
| <span className="shrink-0 text-xs tabular-nums text-sidebar-foreground/50">{systemObjects.length}</span> | ||
| )} | ||
| </SidebarGroupLabel> | ||
| <SidebarGroupContent> | ||
| <SidebarMenu> | ||
| {/* Dynamic system objects */} | ||
| {systemObjects.length > 0 && ( | ||
| <Collapsible | ||
| open={expandedTypes.has('_system_objects') || !!searchQuery} | ||
| onOpenChange={(open) => { | ||
| const isExpanded = expandedTypes.has('_system_objects'); | ||
| if (open && !isExpanded) toggleTypeExpanded('_system_objects'); | ||
| if (!open && isExpanded) toggleTypeExpanded('_system_objects'); | ||
| }} | ||
| asChild | ||
| > | ||
| <SidebarMenuItem> | ||
| <CollapsibleTrigger asChild> | ||
| <SidebarMenuButton tooltip={`System Objects (${systemObjects.length})`}> | ||
| <Database className="h-4 w-4" /> | ||
| <span className="flex-1 min-w-0 truncate">System Objects</span> | ||
| <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{systemObjects.length}</span> | ||
| <ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 ${(expandedTypes.has('_system_objects') || !!searchQuery) ? 'rotate-90' : ''}`} /> | ||
| </SidebarMenuButton> | ||
| </CollapsibleTrigger> | ||
| <CollapsibleContent> | ||
| <SidebarMenuSub> | ||
| {systemObjects | ||
| .filter((item: any) => matchesSearch(item.label || item.name || '', item.name || '')) | ||
| .map((item: any) => { | ||
| const itemName = item.name || item.id || 'unknown'; | ||
| const itemLabel = item.label || item.name || 'Untitled'; | ||
|
|
||
| return ( | ||
| <SidebarMenuSubItem key={itemName}> | ||
| <SidebarMenuSubButton | ||
| isActive={selectedObject === itemName} | ||
| onClick={() => onSelectObject(itemName)} | ||
| > | ||
| <span className="truncate"> | ||
| {isSystemObject(item) && ( | ||
| <span className="text-muted-foreground font-mono text-xs">{SYSTEM_NAMESPACE}:</span> | ||
| )} | ||
| {itemLabel} | ||
|
Comment on lines
+514
to
+518
|
||
| </span> | ||
| </SidebarMenuSubButton> | ||
| </SidebarMenuSubItem> | ||
| ); | ||
| })} | ||
| </SidebarMenuSub> | ||
| </CollapsibleContent> | ||
| </SidebarMenuItem> | ||
| </Collapsible> | ||
| )} | ||
|
|
||
| {/* Static system items */} | ||
| <SidebarMenuItem> | ||
| <SidebarMenuButton | ||
| tooltip="API Console" | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -261,7 +261,7 @@ Default view when entering the Object Designer | |||||
| | :--- | :--- | :--- | :--- | | ||||||
| | **package** | `string` | optional | Filter by owning package | | ||||||
| | **tags** | `string[]` | optional | Filter by object tags | | ||||||
| | **includeSystem** | `boolean` | ✅ | Include system-level objects | | ||||||
| | **includeSystem** | `boolean` | ✅ | Include system-level objects (default: `true`) | | ||||||
|
||||||
| | **includeSystem** | `boolean` | ✅ | Include system-level objects (default: `true`) | | |
| | **includeSystem** | `boolean` | ✅ | Include system-level objects (default: `false`) | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -307,7 +307,7 @@ describe('ObjectSortFieldSchema', () => { | |
| describe('ObjectFilterSchema', () => { | ||
| it('should accept empty object with defaults', () => { | ||
| const result = ObjectFilterSchema.parse({}); | ||
| expect(result.includeSystem).toBe(false); | ||
| expect(result.includeSystem).toBe(true); | ||
| expect(result.includeAbstract).toBe(false); | ||
| expect(result.package).toBeUndefined(); | ||
| expect(result.tags).toBeUndefined(); | ||
|
Comment on lines
308
to
313
|
||
|
|
@@ -448,6 +448,7 @@ describe('ObjectDesignerConfigSchema', () => { | |
| expect(result.erDiagram.enabled).toBe(true); | ||
| expect(result.objectManager).toBeDefined(); | ||
| expect(result.objectManager.defaultDisplayMode).toBe('table'); | ||
| expect(result.objectManager.defaultFilter.includeSystem).toBe(true); | ||
| expect(result.objectPreview).toBeDefined(); | ||
| expect(result.objectPreview.tabs.length).toBe(8); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -328,7 +328,7 @@ export const ObjectFilterSchema = z.object({ | |||||||||||
| tags: z.array(z.string()).optional().describe('Filter by object tags'), | ||||||||||||
|
|
||||||||||||
| /** Show system objects */ | ||||||||||||
| includeSystem: z.boolean().default(false).describe('Include system-level objects'), | ||||||||||||
| includeSystem: z.boolean().default(true).describe('Include system-level objects'), | ||||||||||||
|
|
||||||||||||
| /** Show abstract objects */ | ||||||||||||
| includeAbstract: z.boolean().default(false).describe('Include abstract base objects'), | ||||||||||||
|
|
@@ -361,7 +361,7 @@ export const ObjectManagerConfigSchema = z.object({ | |||||||||||
|
|
||||||||||||
| /** Default filters */ | ||||||||||||
| defaultFilter: ObjectFilterSchema.default({ | ||||||||||||
| includeSystem: false, | ||||||||||||
| includeSystem: true, | ||||||||||||
| includeAbstract: false, | ||||||||||||
| }).describe('Default filter configuration'), | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+364
to
367
|
||||||||||||
| includeSystem: true, | |
| includeAbstract: false, | |
| }).describe('Default filter configuration'), | |
| includeAbstract: false, | |
| }).describe('Default filter configuration'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The system-object filter toggle button relies on
titleonly. For accessibility, add anaria-label(and considertype="button"to avoid accidental form submission if this component is ever rendered inside a<form>).