diff --git a/src/app/(rucio)/subscription/list/ListSubscriptionClient.tsx b/src/app/(rucio)/subscription/list/ListSubscriptionClient.tsx new file mode 100644 index 00000000..a9c6a8b5 --- /dev/null +++ b/src/app/(rucio)/subscription/list/ListSubscriptionClient.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { ListSubscription } from '@/component-library/pages/Subscriptions/list/ListSubscription'; +import { useRouter, usePathname } from 'next/navigation'; +import { useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { SiteHeaderViewModel } from '@/lib/infrastructure/data/view-model/site-header'; + +export interface ListSubscriptionClientProps { + accountFilter?: string; + autoSearch?: boolean; +} + +export const ListSubscriptionClient = (props: ListSubscriptionClientProps) => { + const router = useRouter(); + const pathname = usePathname(); + const hasUpdatedUrl = useRef(false); + + // Fetch site header to get active account if accountFilter is not provided + const querySiteHeader = async () => { + const res = await fetch('/api/feature/get-site-header'); + return res.json(); + }; + + const { + data: siteHeader, + isFetching: isSiteHeaderFetching, + } = useQuery({ + queryKey: ['subscription-account-client'], + queryFn: querySiteHeader, + retry: false, + refetchOnWindowFocus: false, + enabled: !props.accountFilter, // Only fetch if accountFilter is not provided + }); + + // Determine the account to use + const account = props.accountFilter || siteHeader?.activeAccount?.rucioAccount; + + // Update URL to include account and autoSearch parameters + useEffect(() => { + if (!hasUpdatedUrl.current && account && !isSiteHeaderFetching) { + hasUpdatedUrl.current = true; + + // Build URL parameters + const urlParams = new URLSearchParams(); + urlParams.set('account', account); + urlParams.set('autoSearch', 'true'); + + // Update the URL using router.replace (doesn't add to history) + const newUrl = `${pathname}?${urlParams.toString()}`; + router.replace(newUrl); + } + }, [account, isSiteHeaderFetching, pathname, router]); + + return ; +}; diff --git a/src/app/(rucio)/subscription/list/page.tsx b/src/app/(rucio)/subscription/list/page.tsx index 198e1483..8d7be42b 100644 --- a/src/app/(rucio)/subscription/list/page.tsx +++ b/src/app/(rucio)/subscription/list/page.tsx @@ -1,6 +1,25 @@ -'use client'; -import { ListSubscription } from '@/component-library/pages/Subscriptions/list/ListSubscription'; +import { ListSubscriptionClient } from './ListSubscriptionClient'; -export default function Page() { - return ; +export default async function Page({ searchParams }: { searchParams?: Promise<{ [key: string]: string | string[] | undefined }> }) { + const params = await searchParams; + const accountParam = typeof params?.['account'] === 'string' ? params['account'] : undefined; + const autoSearch = params?.['autoSearch'] === 'true'; + + return ( +
+
+
+

Subscriptions

+

View and manage subscriptions.

+
+
+ +
+
+
+ ); } + +export const metadata = { + title: 'Subscriptions List - Rucio', +}; diff --git a/src/app/(rucio)/subscription/page/[account]/[name]/page.tsx b/src/app/(rucio)/subscription/page/[account]/[name]/page.tsx index a68d9f7e..3d212234 100644 --- a/src/app/(rucio)/subscription/page/[account]/[name]/page.tsx +++ b/src/app/(rucio)/subscription/page/[account]/[name]/page.tsx @@ -1,77 +1,10 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { PageSubscription as PageSubscriptionStory } from '@/component-library/pages/legacy/Subscriptions/PageSubscription'; -import { SubscriptionViewModel } from '@/lib/infrastructure/data/view-model/subscriptions'; -import { Loading } from '@/component-library/pages/legacy/Helpers/Loading'; - -async function updateSubscription(id: string, filter: string, replicationRules: string) { - const req: any = { - method: 'PUT', - url: new URL(`${process.env.NEXT_PUBLIC_WEBUI_HOST}/api/feature/mock-update-subscription`), - headers: { - 'Content-Type': 'application/json', - }, - params: { - subscriptionID: id, - }, - body: { - filter: filter, - replicationRules: replicationRules, - }, - }; - const res = await fetch(req.url, { - method: 'PUT', - headers: new Headers({ - 'Content-Type': 'application/json', - } as HeadersInit), - body: JSON.stringify(req.body) as BodyInit, - }); - - return await res.json(); -} +import { use } from 'react'; +import { DetailsSubscription } from '@/component-library/pages/Subscriptions/details/DetailsSubscription'; export default function PageSubscription({ params }: { params: Promise<{ account: string; name: string }> }) { const { account, name } = use(params); - const [subscriptionViewModel, setSubscriptionViewModel] = useState({ status: 'pending' } as SubscriptionViewModel); - useEffect(() => { - subscriptionQuery(account, name).then(setSubscriptionViewModel); - }, [account, name]); - async function subscriptionQuery(account: string, name: string): Promise { - const req: any = { - method: 'GET', - url: new URL(`${process.env.NEXT_PUBLIC_WEBUI_HOST}/api/feature/get-subscription`), - params: { - account: account, - name: name, - }, - headers: new Headers({ - 'Content-Type': 'application/json', - } as HeadersInit), - }; - - const res = await fetch(req.url, { - method: 'GET', - headers: new Headers({ - 'Content-Type': 'application/json', - } as HeadersInit), - }); - return await res.json(); - } - if (subscriptionViewModel.status === 'success') { - return ( - { - updateSubscription(subscriptionViewModel.id, s, subscriptionViewModel.replication_rules).then(setSubscriptionViewModel); - }} - editReplicationRules={(r: string) => { - updateSubscription(subscriptionViewModel.id, subscriptionViewModel.filter, r).then(setSubscriptionViewModel); - }} - /> - ); - } else { - return ; - } + return ; } diff --git a/src/app/api/feature/list-subscription-rule-states/route.ts b/src/app/api/feature/list-subscription-rule-states/route.ts index 90be3b13..5bb44579 100644 --- a/src/app/api/feature/list-subscription-rule-states/route.ts +++ b/src/app/api/feature/list-subscription-rule-states/route.ts @@ -9,8 +9,8 @@ import { getSessionUser } from '@/lib/infrastructure/auth/nextauth-session-utils /** * GET /api/feature/list-subscription-rule-states - * Query params: account, name - * Returns rule states for subscriptions of the authenticated user's account + * Query params: account (optional - defaults to authenticated user's account), name + * Returns rule states for subscriptions of the specified account */ export async function GET(request: NextRequest) { try { @@ -20,7 +20,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const account = sessionUser.rucioAccount; + // Check for account query parameter first, fall back to session user's account + const { searchParams } = new URL(request.url); + const accountParam = searchParams.get('account'); + const account = accountParam || sessionUser.rucioAccount; + if (!account) { return NextResponse.json({ error: 'Could not determine account name. Are you logged in?' }, { status: 400 }); } diff --git a/src/component-library/atoms/legacy/input/TimeInput/TimeInput.stories.tsx b/src/component-library/atoms/legacy/input/TimeInput/TimeInput.stories.tsx index 66126aee..24266f5e 100644 --- a/src/component-library/atoms/legacy/input/TimeInput/TimeInput.stories.tsx +++ b/src/component-library/atoms/legacy/input/TimeInput/TimeInput.stories.tsx @@ -107,11 +107,15 @@ export const InForm: Story = { return (
- +
- +
diff --git a/src/component-library/features/badges/Subscription/SubscriptionStateBadge.tsx b/src/component-library/features/badges/Subscription/SubscriptionStateBadge.tsx new file mode 100644 index 00000000..6b7a48ee --- /dev/null +++ b/src/component-library/features/badges/Subscription/SubscriptionStateBadge.tsx @@ -0,0 +1,36 @@ +import { SubscriptionState } from '@/lib/core/entity/rucio'; +import React from 'react'; +import { Badge } from '@/component-library/atoms/misc/Badge'; + +const stateString: Record = { + [SubscriptionState.ACTIVE]: 'Active', + [SubscriptionState.INACTIVE]: 'Inactive', + [SubscriptionState.NEW]: 'New', + [SubscriptionState.UPDATED]: 'Updated', + [SubscriptionState.BROKEN]: 'Broken', + [SubscriptionState.UNKNOWN]: 'Unknown', +}; + +/** + * Maps subscription states to semantic badge variants from the design system. + * + * Semantic color assignments: + * - UPDATED: Success (green) - Most common state, subscription has created rules + * - ACTIVE: Info (brand purple) - Subscription is active and running + * - NEW: Info (brand purple) - New subscription (tied to retroactive option) + * - INACTIVE: Neutral (gray) - Subscription is disabled + * - BROKEN: Error (red) - Subscription is in error state + * - UNKNOWN: Neutral (gray) - Undefined state + */ +const stateVariants: Record = { + [SubscriptionState.UPDATED]: 'success', + [SubscriptionState.ACTIVE]: 'info', + [SubscriptionState.NEW]: 'info', + [SubscriptionState.INACTIVE]: 'neutral', + [SubscriptionState.BROKEN]: 'error', + [SubscriptionState.UNKNOWN]: 'neutral', +}; + +export const SubscriptionStateBadge = (props: { value: SubscriptionState; className?: string }) => { + return ; +}; diff --git a/src/component-library/features/command-palette/CommandPalette.tsx b/src/component-library/features/command-palette/CommandPalette.tsx index c0c1f014..0a6e38f2 100644 --- a/src/component-library/features/command-palette/CommandPalette.tsx +++ b/src/component-library/features/command-palette/CommandPalette.tsx @@ -19,6 +19,8 @@ import { getCards } from '@/lib/utils/hotbar-storage'; import { getNavigationCommands, getActionCommands, getHelpCommands } from '@/lib/infrastructure/command-palette/command-registry'; import { navigateToSearch } from '@/lib/infrastructure/utils/navigation'; import { ClockIcon, BookmarkIcon } from '@heroicons/react/24/outline'; +import { useQuery } from '@tanstack/react-query'; +import { SiteHeaderViewModel } from '@/lib/infrastructure/data/view-model/site-header'; export interface CommandPaletteProps { /** Whether the palette is open */ @@ -32,6 +34,22 @@ export const CommandPalette: React.FC = ({ open, onOpenChan const [searchQuery, setSearchQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); + // Fetch site header to get active account + const querySiteHeader = async () => { + const res = await fetch('/api/feature/get-site-header'); + return res.json(); + }; + + const { data: siteHeader } = useQuery({ + queryKey: ['command-palette-site-header'], + queryFn: querySiteHeader, + retry: false, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const account = siteHeader?.activeAccount?.rucioAccount; + // Build all sections with filtering const sections = useMemo(() => { const allSections: CommandSection[] = []; @@ -80,7 +98,7 @@ export const CommandPalette: React.FC = ({ open, onOpenChan } // Navigation Section - const navigationItems = getNavigationCommands(); + const navigationItems = getNavigationCommands(account); allSections.push({ id: 'navigation', title: 'Navigation', @@ -122,7 +140,7 @@ export const CommandPalette: React.FC = ({ open, onOpenChan } return allSections; - }, [searchQuery]); + }, [searchQuery, account]); // Calculate total items count for keyboard navigation const totalItems = useMemo(() => { diff --git a/src/component-library/features/json/JSONTreeView.tsx b/src/component-library/features/json/JSONTreeView.tsx new file mode 100644 index 00000000..ccc7ae1b --- /dev/null +++ b/src/component-library/features/json/JSONTreeView.tsx @@ -0,0 +1,321 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { HiChevronRight, HiChevronDown, HiClipboard, HiClipboardCheck } from 'react-icons/hi'; +import { toast } from '@/lib/infrastructure/hooks/useToast'; +import { twMerge } from 'tailwind-merge'; + +export type JSONTreeViewProps = { + value: string; + expandDepth?: number; + showCopyButton?: boolean; + maxHeight?: string; + className?: string; +}; + +type JSONNodeProps = { + data: any; + keyName?: string; + level: number; + expandDepth: number; + isLast?: boolean; +}; + +/** + * Recursive component that renders a JSON node (object, array, or primitive value). + * Handles expand/collapse state and styling for different value types. + */ +const JSONNode: React.FC = ({ data, keyName, level, expandDepth, isLast = false }) => { + const [isExpanded, setIsExpanded] = useState(level < expandDepth); + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + // Render primitive values (string, number, boolean, null) + const renderPrimitive = (value: any) => { + if (value === null) { + return null; + } + + switch (typeof value) { + case 'string': + return "{value}"; + case 'number': + return {value}; + case 'boolean': + return {value.toString()}; + default: + return {String(value)}; + } + }; + + // Handle objects + if (data !== null && typeof data === 'object' && !Array.isArray(data)) { + const keys = Object.keys(data); + const isEmpty = keys.length === 0; + + return ( +
+
+ {/* Key name */} + {keyName !== undefined && ( + <> + + + + )} + + {/* Opening brace and summary */} + {isEmpty ? ( + {'{}'} + ) : !isExpanded ? ( + + ) : ( + {'{'} + )} +
+ + {/* Children */} + {isExpanded && !isEmpty && ( +
+ {keys.map((key, index) => ( + + ))} +
+ )} + + {/* Closing brace */} + {isExpanded && !isEmpty &&
{'}'}{!isLast && ','}
} +
+ ); + } + + // Handle arrays + if (Array.isArray(data)) { + const isEmpty = data.length === 0; + + return ( +
+
+ {/* Key name */} + {keyName !== undefined && ( + <> + + + + )} + + {/* Opening bracket and summary */} + {isEmpty ? ( + [] + ) : !isExpanded ? ( + + ) : ( + [ + )} +
+ + {/* Children */} + {isExpanded && !isEmpty && ( +
+ {data.map((item, index) => ( + + ))} +
+ )} + + {/* Closing bracket */} + {isExpanded && !isEmpty &&
{']'}{!isLast && ','}
} +
+ ); + } + + // Handle primitives + return ( +
+ {keyName !== undefined && ( + <> + {keyName} + + + )} + {renderPrimitive(data)} + {!isLast && ,} +
+ ); +}; + +/** + * JSONTreeView component for displaying JSON with interactive expand/collapse tree structure. + * + * Features: + * - Interactive expand/collapse for nested objects and arrays + * - Syntax highlighting with dark mode support + * - Copy to clipboard functionality + * - Keyboard navigation (click to expand/collapse) + * - Design system compliant styling + * - Fully custom implementation with no external dependencies + * + * @example + * ```tsx + * + * ``` + */ +export const JSONTreeView: React.FC = ({ + value, + expandDepth = 2, + showCopyButton = true, + maxHeight = '600px', + className, +}) => { + const [isCopied, setIsCopied] = useState(false); + const [isValidJSON, setIsValidJSON] = useState(true); + const [parsedValue, setParsedValue] = useState(null); + + // Parse JSON on mount or when value changes + useEffect(() => { + try { + const parsed = JSON.parse(value); + setParsedValue(parsed); + setIsValidJSON(true); + } catch { + setParsedValue(null); + setIsValidJSON(false); + } + }, [value]); + + // Copy to clipboard handler + const handleCopy = async () => { + try { + const formatted = JSON.stringify(parsedValue, null, 2); + await navigator.clipboard.writeText(formatted); + setIsCopied(true); + toast({ + title: 'Copied to clipboard', + description: 'JSON content has been copied successfully', + variant: 'success', + }); + setTimeout(() => setIsCopied(false), 2000); + } catch { + toast({ + title: 'Copy failed', + description: 'Failed to copy JSON content to clipboard', + variant: 'error', + }); + } + }; + + const containerClasses = twMerge( + 'relative rounded border', + 'bg-neutral-100 dark:bg-neutral-800', + 'border-neutral-200 dark:border-neutral-700', + className, + ); + + const headerClasses = + 'flex items-center justify-between gap-2 px-3 py-2 border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900'; + + const buttonBaseClasses = 'inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded transition-colors'; + + const copyButtonClasses = twMerge( + buttonBaseClasses, + isCopied + ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' + : 'bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-300 dark:hover:bg-neutral-600', + ); + + const treeWrapperClasses = 'overflow-auto p-3 font-mono text-sm leading-relaxed'; + + const warningBadgeClasses = + 'inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400 border border-yellow-300 dark:border-yellow-700'; + + if (!isValidJSON) { + return ( +
+
+ Invalid JSON - cannot display tree view +
+
+
{value}
+
+
+ ); + } + + return ( +
+ {/* Header with controls */} + {showCopyButton && ( +
+
Interactive Tree View
+ +
+ )} + + {/* JSON tree content */} +
+ +
+
+ ); +}; diff --git a/src/component-library/features/json/JSONViewer.stories.tsx b/src/component-library/features/json/JSONViewer.stories.tsx new file mode 100644 index 00000000..11046521 --- /dev/null +++ b/src/component-library/features/json/JSONViewer.stories.tsx @@ -0,0 +1,658 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { JSONViewer } from './JSONViewer'; + +const meta: Meta = { + title: 'Features/JSON/JSONViewer', + component: JSONViewer, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Sample JSON data +const simpleJSON = JSON.stringify({ name: 'John Doe', age: 30, active: true }); + +const nestedJSON = JSON.stringify({ + user: { + id: 12345, + name: 'John Doe', + email: 'john@example.com', + preferences: { + theme: 'dark', + notifications: true, + language: 'en', + }, + }, + metadata: { + created: '2024-01-15T10:30:00Z', + updated: '2024-01-20T14:45:00Z', + }, +}); + +const subscriptionFilter = JSON.stringify({ + scope: ['data15_13TeV', 'data15_14TeV'], + account: ['root'], + datatype: 'AOD', + excluded_pattern: 'calibration', + asynchronous: false, +}); + +const replicationRules = JSON.stringify([ + { + copies: 1, + rse_expression: 'tier=1&disk=1', + weight: null, + lifetime: 604800, + locked: false, + subscription_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source_replica_expression: null, + activity: 'User Subscriptions', + notify: 'N', + }, + { + copies: 2, + rse_expression: 'cloud=US', + weight: null, + lifetime: null, + locked: true, + subscription_id: 'a67bc20c-69dd-5483-b678-1f13c3d4e580', + source_replica_expression: 'tier=0', + activity: 'Data Brokering', + notify: 'Y', + }, +]); + +const arrayJSON = JSON.stringify(['apple', 'banana', 'cherry', 'date', 'elderberry']); + +const largeJSON = JSON.stringify({ + dataset: { + name: 'data15_13TeV.00276262.physics_Main.merge.AOD.r7562_p2521', + scope: 'data15_13TeV', + type: 'DATASET', + bytes: 3487234987, + length: 1543, + did_type: 'DATASET', + is_open: false, + monotonic: false, + obsolete: false, + hidden: false, + suppressed: false, + purge_replicas: true, + metadata: { + project: 'data15_13TeV', + run_number: 276262, + stream_name: 'physics_Main', + prod_step: 'merge', + datatype: 'AOD', + provenance: 'merge', + }, + rules: [ + { id: 'rule1', rse: 'CERN-PROD_DATADISK', state: 'OK', copies: 1 }, + { id: 'rule2', rse: 'LBNL_ATLAS_DATADISK', state: 'REPLICATING', copies: 2 }, + { id: 'rule3', rse: 'TOKYO-LCG2_DATADISK', state: 'OK', copies: 1 }, + ], + }, +}); + +const invalidJSON = '{ invalid json, missing quotes: true }'; + +// Basic examples +export const SimpleObject: Story = { + args: { + value: simpleJSON, + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const NestedObject: Story = { + args: { + value: nestedJSON, + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const ArrayData: Story = { + args: { + value: arrayJSON, + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const InvalidJSON: Story = { + args: { + value: invalidJSON, + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const EmptyObject: Story = { + args: { + value: JSON.stringify({}), + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const EmptyArray: Story = { + args: { + value: JSON.stringify([]), + showCopyButton: true, + showRawToggle: true, + }, +}; + +// Feature variations +export const WithoutCopyButton: Story = { + args: { + value: nestedJSON, + showCopyButton: false, + showRawToggle: true, + }, +}; + +export const WithoutRawToggle: Story = { + args: { + value: nestedJSON, + showCopyButton: true, + showRawToggle: false, + }, +}; + +export const MinimalControls: Story = { + args: { + value: nestedJSON, + showCopyButton: false, + showRawToggle: false, + }, +}; + +export const WithMaxHeight: Story = { + args: { + value: largeJSON, + showCopyButton: true, + showRawToggle: true, + maxHeight: '300px', + }, +}; + +// Real-world examples from Rucio +export const SubscriptionFilter: Story = { + args: { + value: subscriptionFilter, + showCopyButton: true, + showRawToggle: true, + }, + parameters: { + docs: { + description: { + story: 'Example of a subscription filter JSON used in Rucio subscriptions.', + }, + }, + }, +}; + +export const ReplicationRules: Story = { + args: { + value: replicationRules, + showCopyButton: true, + showRawToggle: true, + }, + parameters: { + docs: { + description: { + story: 'Example of replication rules JSON used in Rucio subscriptions.', + }, + }, + }, +}; + +export const DIDMetadata: Story = { + args: { + value: largeJSON, + showCopyButton: true, + showRawToggle: true, + maxHeight: '500px', + }, + parameters: { + docs: { + description: { + story: 'Example of DID metadata with scrollable content.', + }, + }, + }, +}; + +// Dark mode showcase +export const DarkMode: Story = { + args: { + value: nestedJSON, + showCopyButton: true, + showRawToggle: true, + }, + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +// Edge cases +export const VeryLongString: Story = { + args: { + value: JSON.stringify({ + longString: + 'This is a very long string that should wrap properly within the JSON viewer component without breaking the layout or causing horizontal scroll issues when the content is too wide for the container', + anotherProperty: 'normal value', + }), + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const SpecialCharacters: Story = { + args: { + value: JSON.stringify({ + unicode: 'δ½ ε₯½δΈ–η•Œ', + emoji: 'πŸš€ πŸŽ‰ ⚑', + quotes: 'She said "Hello"', + backslash: 'C:\\Users\\Documents', + newlines: 'Line 1\nLine 2\nLine 3', + tabs: 'Column1\tColumn2\tColumn3', + }), + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const BooleanAndNull: Story = { + args: { + value: JSON.stringify({ + trueValue: true, + falseValue: false, + nullValue: null, + undefinedBehavior: undefined, // Will be omitted in JSON + zeroValue: 0, + emptyString: '', + }), + showCopyButton: true, + showRawToggle: true, + }, +}; + +export const MixedDataTypes: Story = { + args: { + value: JSON.stringify({ + string: 'text', + number: 42, + float: 3.14159, + boolean: true, + null: null, + array: [1, 2, 3], + object: { nested: 'value' }, + largeNumber: 9007199254740991, + negativeNumber: -273.15, + scientificNotation: 6.022e23, + }), + showCopyButton: true, + showRawToggle: true, + }, +}; + +// Comparison showcase +export const ComparisonAllVariations: Story = { + render: () => ( +
+
+

All Features Enabled

+ +
+ +
+

Copy Only

+ +
+ +
+

Toggle Only

+ +
+ +
+

Minimal (No Controls)

+ +
+
+ ), +}; + +// ============================================================================ +// INTERACTIVE MODE STORIES +// ============================================================================ + +// Sample data for interactive mode +const deeplyNestedJSON = JSON.stringify({ + level1: { + level2: { + level3: { + level4: { + level5: { + deepValue: 'You found me!', + moreData: [1, 2, 3, 4, 5], + }, + anotherBranch: { + data: 'Additional content', + }, + }, + }, + }, + }, + topLevel: 'Easy to see', +}); + +const largeArrayJSON = JSON.stringify( + Array.from({ length: 50 }, (_, i) => ({ + id: `item-${i}`, + name: `Item ${i}`, + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'pending' : 'inactive', + metadata: { + created: new Date(2024, 0, i + 1).toISOString(), + score: Math.random() * 100, + }, + })), +); + +const complexSubscriptionJSON = JSON.stringify({ + subscription: { + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + account: 'root', + name: 'data15_13TeV_subscription', + filter: { + scope: ['data15_13TeV', 'data15_14TeV', 'data16_13TeV'], + account: ['root', 'dataprep'], + datatype: 'AOD', + excluded_pattern: 'calibration.*', + project: ['data15_13TeV'], + asynchronous: false, + split_rule: true, + }, + replication_rules: [ + { + copies: 1, + rse_expression: 'tier=1&disk=1', + weight: null, + lifetime: 604800, + locked: false, + subscription_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source_replica_expression: null, + activity: 'User Subscriptions', + notify: 'N', + comments: 'Primary copy for T1 sites', + grouping: 'DATASET', + purge_replicas: false, + }, + { + copies: 2, + rse_expression: 'cloud=US&type=DATADISK', + weight: null, + lifetime: null, + locked: true, + subscription_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source_replica_expression: 'tier=0', + activity: 'Data Brokering', + notify: 'Y', + comments: 'US cloud distribution', + grouping: 'ALL', + purge_replicas: true, + }, + { + copies: 1, + rse_expression: 'cloud=CERN&tape=1', + weight: 10, + lifetime: null, + locked: true, + subscription_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + source_replica_expression: null, + activity: 'Data Consolidation', + notify: 'N', + comments: 'Long-term tape storage', + grouping: 'DATASET', + purge_replicas: false, + }, + ], + state: 'ACTIVE', + created_at: '2024-01-15T10:30:00Z', + updated_at: '2024-01-20T14:45:00Z', + lifetime: null, + comments: 'Automatic subscription for data15 datasets', + retroactive: false, + priority: 3, + }, +}); + +// Interactive mode - explicit usage +export const InteractiveModeExplicit: Story = { + args: { + value: replicationRules, + mode: 'interactive', + expandDepth: 2, + showCopyButton: true, + }, + parameters: { + docs: { + description: { + story: 'Explicitly using interactive mode with expandable tree structure. Click on chevrons to expand/collapse nested objects.', + }, + }, + }, +}; + +export const InteractiveDeepNesting: Story = { + args: { + value: deeplyNestedJSON, + mode: 'interactive', + expandDepth: 3, + showCopyButton: true, + }, + parameters: { + docs: { + description: { + story: 'Interactive mode with deeply nested objects (5 levels). Initial expand depth is 3 levels.', + }, + }, + }, +}; + +export const InteractiveLargeArray: Story = { + args: { + value: largeArrayJSON, + mode: 'interactive', + expandDepth: 1, + showCopyButton: true, + maxHeight: '400px', + }, + parameters: { + docs: { + description: { + story: 'Interactive mode with large array (50 items). Scrollable content with expand/collapse for each item.', + }, + }, + }, +}; + +export const InteractiveComplexSubscription: Story = { + args: { + value: complexSubscriptionJSON, + mode: 'interactive', + expandDepth: 2, + showCopyButton: true, + maxHeight: '500px', + }, + parameters: { + docs: { + description: { + story: 'Real-world example: Complex subscription with filter and multiple replication rules.', + }, + }, + }, +}; + +// Auto-detection mode stories +export const AutoModeSimpleObject: Story = { + args: { + value: simpleJSON, + mode: 'auto', + showCopyButton: true, + }, + parameters: { + docs: { + description: { + story: 'Auto mode detects simple JSON and uses static syntax highlighting view.', + }, + }, + }, +}; + +export const AutoModeComplexObject: Story = { + args: { + value: replicationRules, + mode: 'auto', + showCopyButton: true, + }, + parameters: { + docs: { + description: { + story: 'Auto mode detects complex JSON (array with 2+ items) and uses interactive tree view.', + }, + }, + }, +}; + +export const AutoModeSubscriptionFilter: Story = { + args: { + value: subscriptionFilter, + mode: 'auto', + showCopyButton: true, + }, + parameters: { + docs: { + description: { + story: 'Auto mode with subscription filter - likely uses static view due to flat structure.', + }, + }, + }, +}; + +export const AutoModeNestedData: Story = { + args: { + value: largeJSON, + mode: 'auto', + showCopyButton: true, + maxHeight: '400px', + }, + parameters: { + docs: { + description: { + story: 'Auto mode with nested DID metadata - uses interactive view due to depth and complexity.', + }, + }, + }, +}; + +// Interactive dark mode +export const InteractiveDarkMode: Story = { + args: { + value: complexSubscriptionJSON, + mode: 'interactive', + expandDepth: 2, + showCopyButton: true, + maxHeight: '500px', + }, + parameters: { + backgrounds: { default: 'dark' }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +// Side-by-side comparison +export const ComparisonStaticVsInteractive: Story = { + render: () => ( +
+
+

Static Mode (Syntax Highlighting)

+ +
+ +
+

Interactive Mode (Expandable Tree)

+ +
+ +
+

Auto Mode (Detects Best View)

+ +
+
+ ), + parameters: { + docs: { + description: { + story: 'Side-by-side comparison of static, interactive, and auto modes with the same JSON data.', + }, + }, + }, +}; + +// Expand depth variations +export const InteractiveExpandDepthVariations: Story = { + render: () => ( +
+
+

Expand Depth: 1

+ +
+ +
+

Expand Depth: 2

+ +
+ +
+

Expand Depth: 3

+ +
+
+ ), + parameters: { + docs: { + description: { + story: 'Demonstrates different initial expand depths for interactive tree view.', + }, + }, + }, +}; diff --git a/src/component-library/features/json/JSONViewer.tsx b/src/component-library/features/json/JSONViewer.tsx new file mode 100644 index 00000000..9a7af971 --- /dev/null +++ b/src/component-library/features/json/JSONViewer.tsx @@ -0,0 +1,231 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import Highlight from 'react-highlight'; +import 'react-highlight/node_modules/highlight.js/styles/github.css'; +import { HiClipboard, HiClipboardCheck } from 'react-icons/hi'; +import { toast } from '@/lib/infrastructure/hooks/useToast'; +import { twMerge } from 'tailwind-merge'; +import { JSONTreeView } from './JSONTreeView'; +import { useJSONComplexity, type JSONViewerMode } from './useJSONComplexity'; + +export type JSONViewerProps = { + value: string; + mode?: JSONViewerMode; + expandDepth?: number; + defaultExpanded?: boolean; + showLineNumbers?: boolean; + showCopyButton?: boolean; + showRawToggle?: boolean; + maxHeight?: string; + className?: string; +}; + +/** + * JSONViewer component with dual display modes for JSON data. + * + * Features: + * - **Dual modes**: Static (syntax highlighting) and Interactive (expandable tree) + * - **Auto-detection**: Automatically chooses best mode based on JSON complexity + * - Syntax highlighting with dark mode support + * - Interactive expand/collapse for nested structures (interactive mode) + * - Copy to clipboard functionality + * - Toggle between raw and formatted views (static mode) + * - Error handling for invalid JSON + * - Design system compliant styling + * + * Mode selection: + * - `mode="auto"` (default): Automatically detects complexity + * - Uses interactive for: arrays with 2+ items, depth > 2 levels, or 10+ total keys + * - Uses static for: simple flat objects and small structures + * - `mode="static"`: Always use syntax highlighting (best for simple JSON) + * - `mode="interactive"`: Always use expandable tree (best for complex nested JSON) + * + * @example + * ```tsx + * // Auto-detect mode (recommended) + * + * + * // Explicit static mode + * + * + * // Explicit interactive mode + * + * ``` + */ +export const JSONViewer: React.FC = ({ + value, + mode = 'auto', + expandDepth = 2, + showLineNumbers = false, + showCopyButton = true, + showRawToggle = true, + maxHeight = '600px', + className, +}) => { + const resolvedMode = useJSONComplexity(value, mode); + const [dark, setDark] = useState(false); + const [isFormatted, setIsFormatted] = useState(true); + const [isCopied, setIsCopied] = useState(false); + const [isValidJSON, setIsValidJSON] = useState(true); + const [formattedValue, setFormattedValue] = useState(''); + const [displayValue, setDisplayValue] = useState(''); + + // Detect dark mode + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + setDark(mediaQuery.matches); + + const handleChange = (e: MediaQueryListEvent) => { + setDark(e.matches); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + // Parse and format JSON on mount or when value changes + useEffect(() => { + try { + const parsed = JSON.parse(value); + const formatted = JSON.stringify(parsed, null, 2); + setFormattedValue(formatted); + setDisplayValue(formatted); + setIsValidJSON(true); + } catch { + setFormattedValue(value); + setDisplayValue(value); + setIsValidJSON(false); + } + }, [value]); + + // Update display value when toggle changes + useEffect(() => { + if (isValidJSON) { + setDisplayValue(isFormatted ? formattedValue : value); + } + }, [isFormatted, isValidJSON, formattedValue, value]); + + // Copy to clipboard handler + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(displayValue); + setIsCopied(true); + toast({ + title: 'Copied to clipboard', + description: 'JSON content has been copied successfully', + variant: 'success', + }); + setTimeout(() => setIsCopied(false), 2000); + } catch { + toast({ + title: 'Copy failed', + description: 'Failed to copy JSON content to clipboard', + variant: 'error', + }); + } + }; + + // Toggle between raw and formatted + const handleToggleFormat = () => { + setIsFormatted(!isFormatted); + }; + + // GitHub theme styles for syntax highlighting + const styles = () => { + const base = 'pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}'; + const lightTheme = + '.hljs{color:#24292e;background:transparent}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}'; + const darkTheme = + '.hljs{color:#adbac7;background:transparent}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#f47067}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#dcbdfb}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#6cb6ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#96d0ff}.hljs-built_in,.hljs-symbol{color:#f69d50}.hljs-code,.hljs-comment,.hljs-formula{color:#768390}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#8ddb8c}.hljs-subst{color:#adbac7}.hljs-section{color:#316dca;font-weight:700}.hljs-bullet{color:#eac55f}.hljs-emphasis{color:#adbac7;font-style:italic}.hljs-strong{color:#adbac7;font-weight:700}.hljs-addition{color:#b4f1b4;background-color:#1b4721}.hljs-deletion{color:#ffd8d3;background-color:#78191b}'; + + return base + (dark ? darkTheme : lightTheme); + }; + + const containerClasses = twMerge( + 'relative rounded border', + 'bg-neutral-100 dark:bg-neutral-800', + 'border-neutral-200 dark:border-neutral-700', + className, + ); + + const headerClasses = + 'flex items-center justify-between gap-2 px-3 py-2 border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900'; + + const buttonBaseClasses = 'inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded transition-colors'; + + const copyButtonClasses = twMerge( + buttonBaseClasses, + isCopied + ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' + : 'bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-300 dark:hover:bg-neutral-600', + ); + + const toggleButtonClasses = twMerge( + buttonBaseClasses, + 'bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-300 dark:hover:bg-neutral-600', + ); + + const codeWrapperClasses = twMerge('overflow-auto p-3 text-sm font-mono leading-relaxed', showLineNumbers && 'counter-reset-line'); + + const warningBadgeClasses = + 'inline-flex items-center px-2 py-1 text-xs font-medium rounded bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400 border border-yellow-300 dark:border-yellow-700'; + + // Render interactive tree view for complex JSON + if (resolvedMode === 'interactive') { + return ; + } + + // Render static view with syntax highlighting for simple JSON + return ( + <> + +
+ {/* Header with controls */} + {(showCopyButton || showRawToggle || !isValidJSON) && ( +
+
+ {!isValidJSON && Invalid JSON - displaying raw value} +
+
+ {showRawToggle && isValidJSON && ( + + )} + {showCopyButton && ( + + )} +
+
+ )} + + {/* JSON content */} +
+ {isValidJSON && isFormatted ? ( + {displayValue} + ) : ( +
{displayValue}
+ )} +
+
+ + ); +}; diff --git a/src/component-library/features/json/index.ts b/src/component-library/features/json/index.ts new file mode 100644 index 00000000..a8d4d14b --- /dev/null +++ b/src/component-library/features/json/index.ts @@ -0,0 +1,6 @@ +export { JSONViewer } from './JSONViewer'; +export type { JSONViewerProps } from './JSONViewer'; +export { JSONTreeView } from './JSONTreeView'; +export type { JSONTreeViewProps } from './JSONTreeView'; +export { useJSONComplexity, detectJSONComplexity } from './useJSONComplexity'; +export type { JSONViewerMode, ResolvedMode } from './useJSONComplexity'; diff --git a/src/component-library/features/json/useJSONComplexity.ts b/src/component-library/features/json/useJSONComplexity.ts new file mode 100644 index 00000000..dfbb3414 --- /dev/null +++ b/src/component-library/features/json/useJSONComplexity.ts @@ -0,0 +1,108 @@ +import { useMemo } from 'react'; + +export type JSONViewerMode = 'static' | 'interactive' | 'auto'; +export type ResolvedMode = 'static' | 'interactive'; + +/** + * Helper function to calculate the maximum depth of a nested object or array. + * @param obj - The object or array to analyze + * @param currentDepth - Current recursion depth (internal use) + * @returns Maximum depth of the structure + */ +function getMaxDepth(obj: any, currentDepth: number = 0): number { + if (obj === null || typeof obj !== 'object') { + return currentDepth; + } + + if (Array.isArray(obj)) { + if (obj.length === 0) return currentDepth + 1; + return Math.max(...obj.map((item) => getMaxDepth(item, currentDepth + 1))); + } + + const keys = Object.keys(obj); + if (keys.length === 0) return currentDepth + 1; + + return Math.max(...keys.map((key) => getMaxDepth(obj[key], currentDepth + 1))); +} + +/** + * Helper function to count total keys across all nested objects. + * @param obj - The object to analyze + * @returns Total number of keys in the structure + */ +function countAllKeys(obj: any): number { + if (obj === null || typeof obj !== 'object') { + return 0; + } + + if (Array.isArray(obj)) { + return obj.reduce((sum, item) => sum + countAllKeys(item), 0); + } + + const keys = Object.keys(obj); + return keys.length + keys.reduce((sum, key) => sum + countAllKeys(obj[key]), 0); +} + +/** + * Detects JSON complexity and determines the appropriate display mode. + * + * Uses interactive mode when: + * 1. It's an array with multiple items (2+) + * 2. It's an object with depth > 2 levels + * 3. Total keys across all nested objects > 10 + * + * Otherwise uses static mode for simple JSON structures. + * + * @param jsonString - The JSON string to analyze + * @returns 'static' or 'interactive' + */ +export function detectJSONComplexity(jsonString: string): ResolvedMode { + try { + const parsed = JSON.parse(jsonString); + + // Use interactive if it's an array with multiple items + if (Array.isArray(parsed) && parsed.length > 1) { + return 'interactive'; + } + + // Use interactive if it's an object with significant complexity + if (parsed !== null && typeof parsed === 'object') { + const depth = getMaxDepth(parsed); + const totalKeys = countAllKeys(parsed); + + if (depth > 2 || totalKeys > 10) { + return 'interactive'; + } + } + + // Default to static for simple structures + return 'static'; + } catch { + // Invalid JSON, use static mode to display raw value + return 'static'; + } +} + +/** + * Hook to determine the appropriate JSON viewer mode based on complexity. + * + * @param value - The JSON string to analyze + * @param mode - The mode preference ('static', 'interactive', or 'auto') + * @returns The resolved mode ('static' or 'interactive') + * + * @example + * ```tsx + * const resolvedMode = useJSONComplexity(jsonString, 'auto'); + * ``` + */ +export function useJSONComplexity(value: string, mode: JSONViewerMode = 'auto'): ResolvedMode { + return useMemo(() => { + // If explicit mode is specified, use it + if (mode === 'static' || mode === 'interactive') { + return mode; + } + + // Auto-detect based on complexity + return detectJSONComplexity(value); + }, [value, mode]); +} diff --git a/src/component-library/features/layout/HeaderClient.tsx b/src/component-library/features/layout/HeaderClient.tsx index d21ac471..f4e39de0 100644 --- a/src/component-library/features/layout/HeaderClient.tsx +++ b/src/component-library/features/layout/HeaderClient.tsx @@ -14,6 +14,7 @@ import { LoadingElement } from '@/component-library/atoms/loading/LoadingElement import { WarningField } from '@/component-library/features/fields/WarningField'; import { motion } from 'framer-motion'; import { useTips } from '@/lib/infrastructure/hooks/useTips'; +import { buildSubscriptionSearchUrl } from '@/lib/infrastructure/utils/navigation'; type TMenuItem = { title: string; @@ -253,11 +254,14 @@ interface HeaderClientProps { } export const HeaderClient = ({ siteHeader, siteHeaderError, isSiteHeaderFetching }: HeaderClientProps) => { + // Build subscription URL with account parameter + const subscriptionUrl = buildSubscriptionSearchUrl(siteHeader?.activeAccount?.rucioAccount); + const menuItems: TFullMenuItem[] = [ { title: 'Dashboard', path: '/dashboard' }, { title: 'DIDs', path: '/did/list' }, { title: 'RSEs', path: '/rse/list' }, - { title: 'Subscriptions', path: '/subscription/list' }, + { title: 'Subscriptions', path: subscriptionUrl }, { title: 'Rules', children: [ diff --git a/src/component-library/features/tips/TipCard.tsx b/src/component-library/features/tips/TipCard.tsx index e7b700e4..3d9dc936 100644 --- a/src/component-library/features/tips/TipCard.tsx +++ b/src/component-library/features/tips/TipCard.tsx @@ -15,43 +15,40 @@ import { Tip, TipVariant } from '@/lib/infrastructure/tips/tip-registry'; * ``` */ -const tipCardVariants = cva( - cn('relative rounded-lg border', 'flex items-start gap-3', 'transition-colors duration-150'), - { - variants: { - variant: { - info: cn( - 'bg-base-info-50 dark:bg-base-info-950', - 'border-base-info-200 dark:border-base-info-800', - 'text-base-info-900 dark:text-base-info-100', - ), - success: cn( - 'bg-base-success-50 dark:bg-base-success-950', - 'border-base-success-200 dark:border-base-success-800', - 'text-base-success-900 dark:text-base-success-100', - ), - warning: cn( - 'bg-base-warning-50 dark:bg-base-warning-950', - 'border-base-warning-200 dark:border-base-warning-800', - 'text-base-warning-900 dark:text-base-warning-100', - ), - }, - compact: { - true: 'p-3', - false: 'p-4', - }, - dismissed: { - true: 'opacity-50', - false: '', - }, +const tipCardVariants = cva(cn('relative rounded-lg border', 'flex items-start gap-3', 'transition-colors duration-150'), { + variants: { + variant: { + info: cn( + 'bg-base-info-50 dark:bg-base-info-950', + 'border-base-info-200 dark:border-base-info-800', + 'text-base-info-900 dark:text-base-info-100', + ), + success: cn( + 'bg-base-success-50 dark:bg-base-success-950', + 'border-base-success-200 dark:border-base-success-800', + 'text-base-success-900 dark:text-base-success-100', + ), + warning: cn( + 'bg-base-warning-50 dark:bg-base-warning-950', + 'border-base-warning-200 dark:border-base-warning-800', + 'text-base-warning-900 dark:text-base-warning-100', + ), }, - defaultVariants: { - variant: 'info', - compact: false, - dismissed: false, + compact: { + true: 'p-3', + false: 'p-4', + }, + dismissed: { + true: 'opacity-50', + false: '', }, }, -); + defaultVariants: { + variant: 'info', + compact: false, + dismissed: false, + }, +}); const iconVariants = cva('shrink-0', { variants: { diff --git a/src/component-library/features/tips/TipsPanel.tsx b/src/component-library/features/tips/TipsPanel.tsx index dc18b5a3..091c9bc1 100644 --- a/src/component-library/features/tips/TipsPanel.tsx +++ b/src/component-library/features/tips/TipsPanel.tsx @@ -41,9 +41,7 @@ export const TipsPanel: React.FC = ({ open, onOpenChange, tips, // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); - filtered = filtered.filter( - tip => tip.title.toLowerCase().includes(query) || tip.content.toLowerCase().includes(query), - ); + filtered = filtered.filter(tip => tip.title.toLowerCase().includes(query) || tip.content.toLowerCase().includes(query)); } // Filter by dismissed state @@ -127,9 +125,7 @@ export const TipsPanel: React.FC = ({ open, onOpenChange, tips,
- - Tips & Help - + Tips & Help Browse tips and help content for using Rucio WebUI diff --git a/src/component-library/features/tips/tips.stories.tsx b/src/component-library/features/tips/tips.stories.tsx index fa9af44a..52b79db1 100644 --- a/src/component-library/features/tips/tips.stories.tsx +++ b/src/component-library/features/tips/tips.stories.tsx @@ -238,10 +238,7 @@ const TipsPanelDemo = () => { return (
- { const [open, setOpen] = useState(true); const allTips = getAllTips(); - const [dismissedTips, setDismissedTips] = useState>( - new Set(allTips.slice(0, 5).map(t => t.id)) - ); + const [dismissedTips, setDismissedTips] = useState>(new Set(allTips.slice(0, 5).map(t => t.id))); const handleDismiss = (tipId: string) => { setDismissedTips(prev => { @@ -281,10 +276,7 @@ const TipsPanelWithSomeDismissed = () => { return (
- { return (

Contextual Tips Example

-

- Click the help icons below to see contextual tips: -

+

Click the help icons below to see contextual tips:

Search DIDs - handleDismiss(sampleTip.id)} - /> + handleDismiss(sampleTip.id)} />
diff --git a/src/component-library/features/utils/json-formatters.ts b/src/component-library/features/utils/json-formatters.ts new file mode 100644 index 00000000..c533f7e4 --- /dev/null +++ b/src/component-library/features/utils/json-formatters.ts @@ -0,0 +1,123 @@ +/** + * Utility functions for JSON formatting and validation. + * Used by JSONViewer and other components that need to work with JSON data. + */ + +/** + * Formats a JSON string with proper indentation. + * + * @param value - The JSON string to format + * @param indent - Number of spaces for indentation (default: 2) + * @returns Formatted JSON string, or original value if parsing fails + * + * @example + * ```ts + * formatJSON('{"name":"John","age":30}') + * // Returns: + * // { + * // "name": "John", + * // "age": 30 + * // } + * ``` + */ +export const formatJSON = (value: string, indent: number = 2): string => { + try { + const parsed = JSON.parse(value); + return JSON.stringify(parsed, null, indent); + } catch { + return value; // Return original if parsing fails + } +}; + +/** + * Checks if a string is valid JSON. + * + * @param value - The string to validate + * @returns true if valid JSON, false otherwise + * + * @example + * ```ts + * isValidJSON('{"name":"John"}') // true + * isValidJSON('invalid json') // false + * ``` + */ +export const isValidJSON = (value: string): boolean => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +/** + * Minifies a JSON string by removing all whitespace. + * + * @param value - The JSON string to minify + * @returns Minified JSON string, or original value if parsing fails + * + * @example + * ```ts + * minifyJSON('{\n "name": "John",\n "age": 30\n}') + * // Returns: '{"name":"John","age":30}' + * ``` + */ +export const minifyJSON = (value: string): string => { + try { + const parsed = JSON.parse(value); + return JSON.stringify(parsed); + } catch { + return value; // Return original if parsing fails + } +}; + +/** + * Safely parses a JSON string and returns the parsed object. + * + * @param value - The JSON string to parse + * @param fallback - Fallback value to return if parsing fails (default: null) + * @returns Parsed JSON object, or fallback value if parsing fails + * + * @example + * ```ts + * safeJSONParse('{"name":"John"}') // { name: "John" } + * safeJSONParse('invalid', {}) // {} + * safeJSONParse('invalid', null) // null + * ``` + */ +export const safeJSONParse = (value: string, fallback: T | null = null): T | null => { + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +}; + +/** + * Pretty prints a JSON object or string for debugging. + * + * @param value - The value to pretty print (can be object or JSON string) + * @param indent - Number of spaces for indentation (default: 2) + * @returns Formatted JSON string + * + * @example + * ```ts + * prettyPrint({ name: "John", age: 30 }) + * // Returns: + * // { + * // "name": "John", + * // "age": 30 + * // } + * ``` + */ +export const prettyPrint = (value: unknown, indent: number = 2): string => { + try { + if (typeof value === 'string') { + const parsed = JSON.parse(value); + return JSON.stringify(parsed, null, indent); + } + return JSON.stringify(value, null, indent); + } catch { + return String(value); + } +}; diff --git a/src/component-library/pages/DID/details/tables/DetailsDIDSimpleTable.tsx b/src/component-library/pages/DID/details/tables/DetailsDIDSimpleTable.tsx index 837f167a..d84df1a2 100644 --- a/src/component-library/pages/DID/details/tables/DetailsDIDSimpleTable.tsx +++ b/src/component-library/pages/DID/details/tables/DetailsDIDSimpleTable.tsx @@ -63,5 +63,12 @@ export const DetailsDIDSimpleTable = (props: DetailsDIDSimpleTableProps) => { }, ]); - return ; + return ( + + ); }; diff --git a/src/component-library/pages/DID/details/views/DetailsDIDDatasetReplicas.tsx b/src/component-library/pages/DID/details/views/DetailsDIDDatasetReplicas.tsx index 46968e80..b61df996 100644 --- a/src/component-library/pages/DID/details/views/DetailsDIDDatasetReplicas.tsx +++ b/src/component-library/pages/DID/details/views/DetailsDIDDatasetReplicas.tsx @@ -85,7 +85,10 @@ export const DetailsDIDDatasetReplicas: DetailsDIDView = ({ scope, name }: Detai }, cellRenderer: StateCell, filter: true, - filterParams: buildDiscreteFilterParams(Object.values(ReplicaStateDisplayNames), Object.values(ReplicaState).filter(state => state === ReplicaState.AVAILABLE || state === ReplicaState.UNAVAILABLE)), + filterParams: buildDiscreteFilterParams( + Object.values(ReplicaStateDisplayNames), + Object.values(ReplicaState).filter(state => state === ReplicaState.AVAILABLE || state === ReplicaState.UNAVAILABLE), + ), }, { headerName: 'Replication Progress', diff --git a/src/component-library/pages/Rule/create/stage-options/CreateRuleStageOptions.tsx b/src/component-library/pages/Rule/create/stage-options/CreateRuleStageOptions.tsx index 0365e1aa..87521f05 100644 --- a/src/component-library/pages/Rule/create/stage-options/CreateRuleStageOptions.tsx +++ b/src/component-library/pages/Rule/create/stage-options/CreateRuleStageOptions.tsx @@ -203,7 +203,11 @@ const AdvancedInput = ({

Create rule asynchronously in the background

- updateOptionValue('sample', !parameters.sample)} label="Create a sample" /> + updateOptionValue('sample', !parameters.sample)} + label="Create a sample" + />

Create a rule for a random sample of files

{parameters.sample && ( @@ -213,7 +217,13 @@ const AdvancedInput = ({ error={errors.sampleCountInvalid} errorMessage="Number of files should be greater than 1 or not specified" > - + )}
@@ -250,15 +260,22 @@ export const CreateRuleStageOptions = ({ parameters, updateOptionValue, errors } error={errors.copiesInvalid} errorMessage={`Copies should range from 1 to ${parameters.rses.length} (number of chosen RSEs)`} > - + {/* Show contextual warning for quota issues */} {!errors.copiesInvalid && errors.tooManyCopies && ( - There are less than {parameters.copies} chosen RSEs with enough quota left. Please change the number of copies or - mark the rule as needing approval in Step 2. + There are less than {parameters.copies} chosen RSEs with enough quota left. Please change the number of{' '} + copies or mark the rule as needing approval in Step 2. )} @@ -278,13 +295,21 @@ export const CreateRuleStageOptions = ({ parameters, updateOptionValue, errors } error={errors.commentsEmpty} errorMessage="A comment should be specified when asking for approval" > -