Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions react/src/components/DashboardPanels/DashboardEditSider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ const DashboardEditSider: React.FC<DashboardEditSiderProps> = ({
<IconButton
variant="ghost"
size="sm"
label={t('dashboard.settings.CardSettingsOf', { name: label })}
tooltip={t('dashboard.settings.CardSettings')}
label={t('dashboard.settings.DefaultSettingsOf', { name: label })}
tooltip={t('dashboard.settings.DefaultCardSettings')}
icon={<Settings2 size="1em" />}
onClick={() => onRequestSettings(id)}
/>
Expand Down
40 changes: 12 additions & 28 deletions react/src/components/DashboardPanels/DashboardPanelBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import DashboardFilterBar from './DashboardFilterBar';
import DashboardPanelModal from './DashboardPanelModal';
import PanelSettingsModal from './PanelSettingsModal';
import { boardSortsAtom } from './ResourceTablePanel';
import { resourceGroupScopeOf } from './dashboardScope';
import { panelSettingOverridesAtom } from './panelSettingOverrides';
import type {
DashboardPageQueryRefs,
DashboardScope,
PanelDefinition,
PanelPage,
PersistedPanel,
Expand All @@ -23,25 +24,11 @@ import {
DashboardFilterContext,
useDashboardFilters,
} from './useDashboardFilters';
import {
BAIBoard,
BAIFlex,
BAIUnmountAfterClose,
type BAIScopedResourceGroupSelectProps,
} from 'backend.ai-ui';
import { useResetOnLeavingPath } from './useResetOnLeavingPath';
import { BAIBoard, BAIFlex, BAIUnmountAfterClose } from 'backend.ai-ui';
import { useAtom, useSetAtom } from 'jotai';
import * as _ from 'lodash-es';
import React, { useEffect, useRef, useState } from 'react';

/** The resource groups a card's setting can pick from: the page's projects, else the domain's. */
const resourceGroupScopeOf = (
scope: DashboardScope,
): BAIScopedResourceGroupSelectProps['scope'] =>
scope.kind === 'mine'
? { project: scope.projectId ? [{ value: scope.projectId }] : [] }
: scope.projectIds === 'all'
? { domain: [{ value: scope.domain.id }] }
: { project: scope.projectIds.map((value) => ({ value })) };
import React, { useRef, useState } from 'react';

export interface DashboardPanelBoardProps {
page: PanelPage;
Expand Down Expand Up @@ -69,24 +56,21 @@ const DashboardPanelBoard: React.FC<DashboardPanelBoardProps> = ({
'use memo';
const [editMode, setEditMode] = useAtom(dashboardEditModeAtom);
const setBoardSorts = useSetAtom(boardSortsAtom);
const setSettingOverrides = useSetAtom(panelSettingOverridesAtom);
const [panelModalState, setPanelModalState] = useState<{
open: boolean;
panel?: PersistedPanel;
}>({ open: false });
const [settingsPanelId, setSettingsPanelId] = useState<string | null>(null);
const boardContainerRef = useRef<HTMLDivElement>(null);

// The edit-mode atom is global: close it when leaving the page, but not when a
// These atoms are global: clear them when leaving the page, but not when a
// suspending query remounts the board on the same path (that would drop an early click).
useEffect(() => {
const mountedPath = window.location.pathname;
return () => {
if (window.location.pathname !== mountedPath) {
setEditMode(false);
setBoardSorts({});
}
};
}, [setEditMode, setBoardSorts]);
useResetOnLeavingPath(() => {
setEditMode(false);
setBoardSorts({});
setSettingOverrides({});
});

const {
boardItems,
Expand Down
42 changes: 32 additions & 10 deletions react/src/components/DashboardPanels/PanelFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
*/
import PanelDetailLinkButton from './PanelDetailLinkButton';
import PanelEditControls from './PanelEditControls';
import PanelSettingsHeaderControls, {
PanelHeaderSettingsContext,
} from './PanelSettingsHeaderControls';
import { Heading } from '@lablup/ui-common/Heading';
import { useTheme } from '@lablup/ui-common/theme';
import { BAIBoardItemTitle, BAIFlex } from 'backend.ai-ui';
import React from 'react';
import { BAIBoardItemTitle, BAIFlex, BAIIconWithTooltip } from 'backend.ai-ui';
import { CircleHelp } from 'lucide-react';
import React, { use } from 'react';

export interface PanelFrameProps {
title: string;
Expand Down Expand Up @@ -38,6 +42,19 @@ const PanelFrame: React.FC<PanelFrameProps> = ({
}) => {
'use memo';
const { token } = useTheme();
const hasHeaderSettings = !!use(PanelHeaderSettingsContext);
const heading = icon ? (
<BAIFlex align="center" gap="xs">
{icon}
<Heading level={5} maxLines={1}>
{title}
</Heading>
</BAIFlex>
) : hasHeaderSettings ? (
<Heading level={5}>{title}</Heading>
) : (
title
);

return (
<BAIFlex
Expand All @@ -47,18 +64,23 @@ const PanelFrame: React.FC<PanelFrameProps> = ({
>
<BAIBoardItemTitle
title={
icon ? (
<BAIFlex align="center" gap="xs">
{icon}
<Heading level={5} maxLines={1}>
{title}
</Heading>
hasHeaderSettings ? (
// The card's own controls follow the title and its help glyph; the row's end keeps the actions.
<BAIFlex align="center" gap="xs" wrap="wrap">
{heading}
{tooltip ? (
<BAIIconWithTooltip
icon={<CircleHelp size="1em" />}
content={tooltip}
/>
) : null}
<PanelSettingsHeaderControls />
</BAIFlex>
) : (
title
heading
)
}
tooltip={tooltip}
tooltip={hasHeaderSettings ? undefined : tooltip}
extra={
<BAIFlex align="center" gap="xxs">
{extra}
Expand Down
132 changes: 132 additions & 0 deletions react/src/components/DashboardPanels/PanelSettingsHeaderControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import { resourceGroupScopeOf } from './dashboardScope';
import type { PanelSettingOverride } from './panelSettingOverrides';
import {
DEFAULT_RANGE,
RANGE_LABEL_KEYS,
RANGE_PRESETS,
hasSetting,
isRangePreset,
} from './panelSettings';
import type { PanelDefinition, PanelSettings } from './types';
import { useDashboardFilterContext } from './useDashboardFilters';
import { IconButton } from '@lablup/ui-common/IconButton';
import {
BAIFlex,
BAIScopedResourceGroupSelect,
BAISelector,
} from 'backend.ai-ui';
import { RotateCcw } from 'lucide-react';
import React, { Suspense, createContext, use } from 'react';
import { useTranslation } from 'react-i18next';

export interface PanelHeaderSettings {
definition: Pick<PanelDefinition, 'settings' | 'kind'>;
saved: PanelSettings | undefined;
/** What the card reads now: the header's pick, else the saved setting. */
effective: PanelSettings | undefined;
overridden: { resourceGroup: boolean; range: boolean };
onChange: (change: PanelSettingOverride) => void;
onReset: () => void;
}

/** Set per card by the board; the shared card header renders it as compact controls. */
export const PanelHeaderSettingsContext =
createContext<PanelHeaderSettings | null>(null);

/**
* A card's resource group and time range in its header. A pick here is
* temporary; the edit-mode card settings keep the saved default.
*/
const PanelSettingsHeaderControls: React.FC = () => {
'use memo';
const { t } = useTranslation();
const settings = use(PanelHeaderSettingsContext);
const filters = useDashboardFilterContext();
if (!settings || !filters) return null;
const { definition, saved, effective, overridden, onChange, onReset } =
settings;
const resourceGroups = effective?.resourceGroups ?? [];
const savedRange = saved?.range;
const defaultResourceGroupLabel =
definition.kind === 'preset'
? t('dashboard.settings.AllResourceGroups')
: (filters.currentResourceGroup ??
t('dashboard.settings.CurrentResourceGroup'));
const savedLabel = [
hasSetting(definition, 'resourceGroup')
? (saved?.resourceGroups?.join(', ') ?? defaultResourceGroupLabel)
: null,
hasSetting(definition, 'range')
? t(
RANGE_LABEL_KEYS[
isRangePreset(savedRange) ? savedRange : DEFAULT_RANGE
],
)
: null,
]
.filter(Boolean)
.join(' · ');
const resetLabel = t('dashboard.settings.BackToSavedSetting', {
value: savedLabel,
});

return (
<BAIFlex align="center" gap="xxs" data-testid="panel-header-settings">
{hasSetting(definition, 'resourceGroup') ? (
<Suspense fallback={null}>
<BAIScopedResourceGroupSelect
size="sm"
variant={overridden.resourceGroup ? 'secondary' : 'ghost'}
label={t('general.ResourceGroup')}
isLabelHidden
scope={resourceGroupScopeOf(filters.scope)}
// A saved choice of several groups stays a label; the header picks one or the default.
value={resourceGroups.length === 1 ? resourceGroups[0] : null}
placeholder={
resourceGroups.length > 1
? resourceGroups.join(', ')
: defaultResourceGroupLabel
}
allowClear={resourceGroups.length === 1}
onChange={(value) =>
onChange({
resourceGroups: typeof value === 'string' ? [value] : [],
})
}
/>
</Suspense>
) : null}
{hasSetting(definition, 'range') ? (
<BAISelector
size="sm"
variant={overridden.range ? 'secondary' : 'ghost'}
aria-label={t('dashboard.settings.TimeRange')}
options={RANGE_PRESETS.map((preset) => ({
value: preset,
label: t(RANGE_LABEL_KEYS[preset]),
}))}
value={effective?.range ?? DEFAULT_RANGE}
onChange={(value) => {
if (value) onChange({ range: value });
}}
/>
) : null}
{overridden.resourceGroup || overridden.range ? (
<IconButton
variant="ghost"
size="sm"
label={resetLabel}
tooltip={resetLabel}
icon={<RotateCcw size="1em" />}
onClick={onReset}
/>
) : null}
</BAIFlex>
);
};

export default PanelSettingsHeaderControls;
Loading
Loading