feat(dashboard): add revision-safe interactive widget grid
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import DashboardGrid from './DashboardGrid';
|
||||
import { defaultDashboardLayout } from '../dashboardLayout';
|
||||
import { I18nProvider } from '../i18n/I18nProvider';
|
||||
|
||||
let mockGridProps: any;
|
||||
let mockMeasuredWidth = 1200;
|
||||
jest.mock('react-grid-layout', () => ({
|
||||
...jest.requireActual('react-grid-layout'),
|
||||
__esModule: true,
|
||||
default: (props: any) => { mockGridProps = props; return <div>{props.children}</div>; },
|
||||
useContainerWidth: () => ({ width: mockMeasuredWidth, containerRef: { current: null } }),
|
||||
}));
|
||||
beforeEach(() => { mockMeasuredWidth = 1200; mockGridProps = null; });
|
||||
|
||||
test('library collision handling resolves resize overlaps but leaves deliberate gaps unchanged', () => {
|
||||
render(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled={false} onChange={jest.fn()} childrenFor={() => <p>Report</p>} /></I18nProvider>);
|
||||
const items = [
|
||||
{ i: 'a', x: 0, y: 17, w: 6, h: 10 },
|
||||
{ i: 'b', x: 0, y: 25, w: 6, h: 8 },
|
||||
{ i: 'c', x: 6, y: 25, w: 6, h: 8 },
|
||||
];
|
||||
const original = JSON.stringify(items);
|
||||
const resolved = mockGridProps.compactor.compact(items, 12);
|
||||
const { getAllCollisions } = jest.requireActual('react-grid-layout');
|
||||
expect(resolved.every((item: any) => getAllCollisions(resolved, item).length === 0)).toBe(true);
|
||||
expect(JSON.stringify(items)).toBe(original);
|
||||
const spaced = [{ i: 'a', x: 4, y: 17, w: 8, h: 10 }];
|
||||
expect(mockGridProps.compactor.compact(spaced, 12)).toMatchObject(spaced);
|
||||
});
|
||||
|
||||
test('mount and compaction do not save; completed resize keeps hidden geometry and uses the draft owner revision', () => {
|
||||
const layout = defaultDashboardLayout(); layout.revision = 8;
|
||||
layout.widgets[2] = { ...layout.widgets[2], visible: false, x: 4, y: 17, w: 8, h: 10 };
|
||||
const change = jest.fn();
|
||||
render(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
expect(mockGridProps.onLayoutChange).toBeUndefined();
|
||||
const next = mockGridProps.layout.map((row: any) => row.i === 'attention' ? { ...row, w: 8, h: 12 } : row);
|
||||
mockGridProps.onResizeStop(next);
|
||||
expect(change).toHaveBeenCalledTimes(1);
|
||||
const saved = change.mock.calls[0][0];
|
||||
expect(saved.revision).toBe(8);
|
||||
expect(saved.widgets[1]).toMatchObject({ w: 8, h: 12, size: 'standard' });
|
||||
expect(saved.widgets[2]).toEqual(layout.widgets[2]);
|
||||
});
|
||||
|
||||
test('narrow viewport retains desktop geometry and keyboard height changes are bounded', () => {
|
||||
mockMeasuredWidth = 390;
|
||||
const layout = defaultDashboardLayout();
|
||||
const change = jest.fn();
|
||||
const view = render(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
expect(mockGridProps).toBeNull();
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
const preview = screen.getByRole('region', { name: 'Dashboard preview' });
|
||||
fireEvent.click(within(preview).getByText('Today'));
|
||||
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '25' } });
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText('Height for Today'), { target: { value: '12' } });
|
||||
expect(change.mock.calls[0][0].widgets[1].h).toBe(12);
|
||||
mockMeasuredWidth = 1200;
|
||||
view.rerender(<I18nProvider><DashboardGrid layout={layout} editing disabled={false} onChange={change} childrenFor={widget => <p>{widget.id} content</p>} /></I18nProvider>);
|
||||
expect(change).toHaveBeenCalledTimes(1);
|
||||
expect(mockGridProps.layout[1]).toMatchObject({ x: layout.widgets[1].x, y: layout.widgets[1].y });
|
||||
});
|
||||
|
||||
test('conflict disables pointer operations and ignores a late completion callback', () => {
|
||||
const change = jest.fn();
|
||||
render(<I18nProvider><DashboardGrid layout={defaultDashboardLayout()} editing disabled onChange={change} childrenFor={() => <p>Report</p>} /></I18nProvider>);
|
||||
expect(mockGridProps.dragConfig.enabled).toBe(false);
|
||||
expect(mockGridProps.resizeConfig.enabled).toBe(false);
|
||||
mockGridProps.onDragStop(mockGridProps.layout);
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Box, Button, Stack, TextField, Typography } from '@mui/material';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import GridLayout, { getAllCollisions, noCompactor, verticalCompactor, useContainerWidth, type Compactor, type Layout } from 'react-grid-layout';
|
||||
import { arrangeDashboardWidgets, type DashboardLayout, type DashboardWidget, WIDGETS } from '../dashboardLayout';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
const preserveGaps: Compactor = {
|
||||
...verticalCompactor,
|
||||
compact(layout, cols) {
|
||||
// The library resolves collisions; untouched gaps survive mount and reload.
|
||||
return layout.some(item => getAllCollisions(layout, item).length > 0)
|
||||
? verticalCompactor.compact(layout, cols) : noCompactor.compact(layout, cols);
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
layout: DashboardLayout;
|
||||
editing: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (layout: DashboardLayout) => void;
|
||||
childrenFor: (widget: DashboardWidget) => React.ReactNode;
|
||||
};
|
||||
|
||||
export default function DashboardGrid({ layout, editing, disabled, onChange, childrenFor }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { width, containerRef } = useContainerWidth({ initialWidth: 0 });
|
||||
const wide = width >= 800;
|
||||
const [announcement, announce] = useState('');
|
||||
const focus = useRef<HTMLDivElement>(null);
|
||||
const contents = new Map(layout.widgets.filter(widget => widget.visible).map(widget => [widget.id, childrenFor(widget)]));
|
||||
const visible = layout.widgets.filter(widget => widget.visible && contents.get(widget.id) != null);
|
||||
const update = (id: string, patch: Partial<DashboardWidget>) => {
|
||||
if (disabled) return;
|
||||
const widgets = layout.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget);
|
||||
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
|
||||
};
|
||||
const move = (id: string, direction: number) => {
|
||||
const index = layout.widgets.findIndex(widget => widget.id === id);
|
||||
const target = index + direction;
|
||||
if (disabled || target < 0 || target >= layout.widgets.length) return;
|
||||
const widgets = [...layout.widgets];
|
||||
const [moved] = widgets.splice(index, 1); widgets.splice(target, 0, moved);
|
||||
onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) });
|
||||
announce(t('dashboardMoved', { name: t(WIDGETS.find(widget => widget.id === id)!.titleKey), position: target + 1, total: widgets.length }));
|
||||
requestAnimationFrame(() => focus.current?.querySelector<HTMLButtonElement>(`[data-dashboard-widget="${id}"] button[data-move]:not(:disabled)`)?.focus());
|
||||
};
|
||||
const finish = (next: Layout) => {
|
||||
if (!editing || disabled) return;
|
||||
// Mount, compaction and viewport changes never write preferences.
|
||||
if (next.some(item => item.x < 0 || item.y < 0 || item.x + item.w > 12 || item.y + item.h > 256)) return;
|
||||
const widgets = layout.widgets.map(widget => {
|
||||
const item = next.find(item => item.i === widget.id);
|
||||
return item ? { ...widget, x: item.x, y: item.y, w: item.w, h: item.h, size: item.w === 12 ? 'full' as const : 'standard' as const } : widget;
|
||||
});
|
||||
const ordered = widgets.filter(widget => widget.visible).sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
onChange({ ...layout, widgets: widgets.map(widget => widget.visible ? ordered.shift()! : widget) });
|
||||
announce(t('assetsUnsavedChanges'));
|
||||
};
|
||||
const tiles = visible.map(widget => {
|
||||
const name = t(WIDGETS.find(item => item.id === widget.id)!.titleKey);
|
||||
const index = layout.widgets.indexOf(widget);
|
||||
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size}
|
||||
sx={{ minWidth: 0, display: 'flex', flexDirection: 'column', border: editing ? '1px solid' : undefined, borderColor: 'divider', borderRadius: 2, bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||
{editing ? <Stack direction="row" useFlexGap flexWrap="wrap" spacing={0.5} sx={{ p: 1, flexShrink: 0, maxHeight: '65%', overflow: 'auto' }}>
|
||||
<Button className="dashboard-grid-handle" disabled={disabled || !wide} aria-label={t('dashboardDrag', { name })} aria-describedby="dashboard-grid-help" sx={{ cursor: 'grab', minWidth: 44, minHeight: 44 }}><DragIndicatorIcon /></Button>
|
||||
<Box component="details" sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box component="summary" sx={{ cursor: 'pointer', p: 1, minHeight: 44 }}>{name}</Box>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>
|
||||
{[-1, 1].map(direction => <Button key={direction} data-move={direction} disabled={disabled || index + direction < 0 || index + direction >= layout.widgets.length}
|
||||
aria-label={t(direction < 0 ? 'dashboardMoveEarlierNamed' : 'dashboardMoveLaterNamed', { name })} onClick={() => move(widget.id, direction)}>{t(direction < 0 ? 'dashboardEarlier' : 'dashboardLater')}</Button>)}
|
||||
<TextField select label={t('dashboardSize')} value={widget.size} disabled={disabled || widget.id === 'cards'}
|
||||
slotProps={{ select: { native: true }, htmlInput: { 'aria-label': t('dashboardSizeFor', { name }) } }}
|
||||
onChange={event => update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}>
|
||||
<option value="standard">{t('dashboardSizeStandard')}</option><option value="full">{t('dashboardSizeFull')}</option>
|
||||
</TextField>
|
||||
<TextField type="number" label={t('dashboardHeightFor', { name })} value={widget.h} disabled={disabled} sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { min: 4, max: 24, step: 1 } }} onChange={event => { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} />
|
||||
<Button disabled={disabled} onClick={() => {
|
||||
update(widget.id, { visible: false });
|
||||
announce(t('dashboardWidgetHidden', { name }));
|
||||
requestAnimationFrame(() => focus.current?.focus());
|
||||
}}>{t('dashboardHideWidget')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack> : null}
|
||||
<Box tabIndex={wide ? 0 : undefined} role={wide ? 'group' : undefined} aria-label={wide ? name : undefined}
|
||||
sx={{ minHeight: 0, minWidth: 0, flex: 1, overflow: wide ? 'auto' : 'visible', p: editing ? 1 : 0 }}>{contents.get(widget.id)}</Box>
|
||||
</Box>;
|
||||
});
|
||||
return <Box ref={containerRef} sx={{ minWidth: 0 }}>
|
||||
{editing ? <Typography id="dashboard-grid-help" variant="body2">{t('dashboardGridHelp')}</Typography> : null}
|
||||
<Typography role="status" aria-live="polite" sx={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' }}>{announcement}</Typography>
|
||||
<Box ref={focus} tabIndex={editing ? -1 : undefined} role={editing ? 'region' : undefined} aria-label={editing ? t('dashboardPreview') : undefined}
|
||||
sx={{ '& .react-resizable-handle': { width: 44, height: 44 }, '@media (prefers-reduced-motion: reduce)': { '& .react-grid-item': { transition: 'none' } } }}>
|
||||
{wide ? <GridLayout width={width} compactor={preserveGaps} layout={visible.map(widget => ({ i: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h, minW: widget.id === 'cards' ? 12 : 4, maxW: 12, minH: 4, maxH: 24 }))}
|
||||
gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0], maxRows: 256 }}
|
||||
dragConfig={{ enabled: editing && !disabled, handle: '.dashboard-grid-handle', allowMobileScroll: true }}
|
||||
resizeConfig={{ enabled: editing && !disabled, handles: ['se'] }} onDragStop={finish} onResizeStop={finish}>{tiles}</GridLayout>
|
||||
: <Stack spacing={3}>{tiles}</Stack>}
|
||||
</Box>
|
||||
</Box>;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useBlocker, useBeforeUnload } from "react-router-dom";
|
||||
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, Typography } from "@mui/material";
|
||||
import { arrangeDashboardWidgets, DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
@@ -45,31 +43,10 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError,
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
|
||||
const [announcement, announce] = useState("");
|
||||
useEffect(() => {
|
||||
panel.current?.focus({ preventScroll: true });
|
||||
panel.current?.scrollIntoView?.({ block: "start" });
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const target = pendingFocus.current;
|
||||
if (!target) return;
|
||||
pendingFocus.current = null;
|
||||
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
|
||||
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
|
||||
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
|
||||
}, [draft]);
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
|
||||
const widgets = [...draft.widgets];
|
||||
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
|
||||
onChange({ ...draft, widgets });
|
||||
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
|
||||
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
|
||||
};
|
||||
const drag = useDragReorder(reorder);
|
||||
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
|
||||
const cancel = async () => {
|
||||
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
|
||||
};
|
||||
@@ -91,30 +68,19 @@ export default function DashboardLayoutEditor({ draft, dirty, saving, saveError,
|
||||
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
|
||||
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
|
||||
</Alert> : null}
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
|
||||
{draft.widgets.map((widget, index) => {
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="details">
|
||||
<Box component="summary" sx={{ p: 1, cursor: "pointer", minHeight: 44 }}>{t("dashboardManageWidgets")}</Box>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" role="group" aria-label={t("dashboardManageWidgets")}>
|
||||
{draft.widgets.map(widget => {
|
||||
const entry = WIDGETS.find(item => item.id === widget.id)!;
|
||||
const name = t(entry.titleKey);
|
||||
const gesture = drag.getItemProps(index);
|
||||
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
|
||||
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
|
||||
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
|
||||
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
|
||||
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
|
||||
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
|
||||
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
|
||||
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
|
||||
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
|
||||
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
|
||||
</TextField>
|
||||
</Box>;
|
||||
return <FormControlLabel key={widget.id} sx={{ minWidth: 0, overflowWrap: "anywhere" }} label={name}
|
||||
control={<Checkbox checked={widget.visible} disabled={saving || conflict} onChange={event => onChange({ ...draft,
|
||||
widgets: arrangeDashboardWidgets(draft.widgets.map(item => item.id === widget.id ? { ...item, visible: event.target.checked } : item)) })} />} />;
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
|
||||
</PageSection>
|
||||
</Box>;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
|
||||
import { useDashboardLayout } from "../hooks/useDashboardLayout";
|
||||
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
|
||||
import DashboardGrid from "./DashboardGrid";
|
||||
import { useOptionalToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
@@ -363,16 +364,13 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
|
||||
{reports.salary}
|
||||
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
|
||||
</> : <Box role={layoutState.editing ? "region" : undefined} aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
||||
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
|
||||
</> : <DashboardGrid layout={activeLayout} editing={layoutState.editing} disabled={layoutState.saving || layoutState.conflict || layoutState.loading}
|
||||
onChange={layoutState.setDraft} childrenFor={widget => {
|
||||
const content = reports[widget.id];
|
||||
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
|
||||
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
|
||||
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
|
||||
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>}
|
||||
return content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>;
|
||||
}} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user