feat(ui): simplify fleet cards by moving edit/delete into detail
Hide instance IDs and config/delete controls on overview cards; keep service/system restart only, with edit still available from detail.
This commit is contained in:
@@ -100,24 +100,7 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => {
|
it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
const remove = vi.fn<InstanceDataSource['delete']>().mockResolvedValue(undefined);
|
|
||||||
const instanceDataSource: InstanceDataSource = {
|
|
||||||
get: vi.fn().mockResolvedValue({
|
|
||||||
id: 'bravo',
|
|
||||||
name: 'Bravo',
|
|
||||||
origin: 'http://bravo.example:8080',
|
|
||||||
tags: ['west'],
|
|
||||||
revision: 7,
|
|
||||||
credentialConfigured: true,
|
|
||||||
}),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
testConnection: vi.fn(),
|
|
||||||
delete: remove,
|
|
||||||
};
|
|
||||||
|
|
||||||
const fleetMessagesDataSource: FleetMessagesDataSource = {
|
const fleetMessagesDataSource: FleetMessagesDataSource = {
|
||||||
load: vi.fn(async (instanceId) =>
|
load: vi.fn(async (instanceId) =>
|
||||||
instanceId === 'bravo'
|
instanceId === 'bravo'
|
||||||
@@ -139,7 +122,6 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
pathname="/fleet"
|
pathname="/fleet"
|
||||||
fleetDataSource={source(async () => snapshot)}
|
fleetDataSource={source(async () => snapshot)}
|
||||||
fleetMessagesDataSource={fleetMessagesDataSource}
|
fleetMessagesDataSource={fleetMessagesDataSource}
|
||||||
instanceDataSource={instanceDataSource}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,16 +149,12 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
|
within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'),
|
||||||
).toBe('/instances/bravo/overview');
|
).toBe('/instances/bravo/overview');
|
||||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||||
expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe(
|
expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
|
||||||
'/settings/instances/bravo',
|
expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
|
||||||
);
|
expect(within(card).queryByText('bravo')).toBeNull();
|
||||||
|
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
|
||||||
await user.click(within(card).getByRole('button', { name: '删除 Bravo' }));
|
expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy();
|
||||||
expect(within(card).getByText('删除后无法撤销。')).toBeTruthy();
|
expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy();
|
||||||
await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo');
|
|
||||||
await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' }));
|
|
||||||
expect(remove).toHaveBeenCalledWith('bravo', 7);
|
|
||||||
expect(within(card).getByRole('status').textContent).toContain('删除请求已提交');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps message read failures separate from instance reachability and reports each once', async () => {
|
it('keeps message read failures separate from instance reachability and reports each once', async () => {
|
||||||
@@ -302,8 +280,9 @@ describe('React AppShell and Fleet vertical slice', () => {
|
|||||||
cards = screen.getAllByRole('article');
|
cards = screen.getAllByRole('article');
|
||||||
expect(cards[0]?.textContent).toContain('Bravo');
|
expect(cards[0]?.textContent).toContain('Bravo');
|
||||||
|
|
||||||
await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth');
|
await user.click(screen.getByRole('button', { name: /需认证/ }));
|
||||||
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
|
expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull();
|
||||||
|
expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy();
|
||||||
await user.click(screen.getByRole('button', { name: '全选本页' }));
|
await user.click(screen.getByRole('button', { name: '全选本页' }));
|
||||||
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe(
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ function Page({
|
|||||||
<FleetPage
|
<FleetPage
|
||||||
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
{...(fleetDataSource ? { dataSource: fleetDataSource } : {})}
|
||||||
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
|
{...(fleetMessagesDataSource ? { messagesDataSource: fleetMessagesDataSource } : {})}
|
||||||
{...(instanceDataSource ? { instanceDataSource } : {})}
|
|
||||||
{...(fleetData ? { initialData: fleetData } : {})}
|
{...(fleetData ? { initialData: fleetData } : {})}
|
||||||
refreshSignal={fleetRefreshSignal}
|
refreshSignal={fleetRefreshSignal}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,15 +29,16 @@ const snapshot: FleetSnapshot = {
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('FleetPage card navigation', () => {
|
describe('FleetPage card navigation', () => {
|
||||||
it('makes the whole instance card open its dashboard while admin controls stay separately interactive', () => {
|
it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => {
|
||||||
render(<FleetPage initialData={snapshot} />);
|
render(<FleetPage initialData={snapshot} />);
|
||||||
|
|
||||||
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' });
|
||||||
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
|
const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' });
|
||||||
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
|
expect(entry.getAttribute('href')).toBe('/instances/alpha/overview');
|
||||||
expect(entry.classList.contains('fleet-card-entry')).toBe(true);
|
expect(entry.classList.contains('fleet-card-entry')).toBe(true);
|
||||||
expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy();
|
expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull();
|
||||||
expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy();
|
expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull();
|
||||||
|
expect(within(card).queryByText('alpha')).toBeNull();
|
||||||
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull();
|
||||||
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch(
|
||||||
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
/实例总数\s*1.*在线\s*1.*需处理\s*0/s,
|
||||||
@@ -49,7 +50,9 @@ describe('FleetPage card navigation', () => {
|
|||||||
'51',
|
'51',
|
||||||
);
|
);
|
||||||
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
expect(within(card).getByText('进入仪表盘')).toBeTruthy();
|
||||||
expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy();
|
expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy();
|
||||||
|
expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy();
|
||||||
|
expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type FleetStatus,
|
type FleetStatus,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from './fleet-table-view-model.js';
|
} from './fleet-table-view-model.js';
|
||||||
import type { InstanceDataSource } from '../instances/instance-crud.js';
|
|
||||||
import type {
|
import type {
|
||||||
FleetMessageLoadState,
|
FleetMessageLoadState,
|
||||||
FleetMessagesDataSource,
|
FleetMessagesDataSource,
|
||||||
@@ -35,7 +34,6 @@ export interface FleetDataSource {
|
|||||||
export interface FleetPageProps {
|
export interface FleetPageProps {
|
||||||
readonly dataSource?: FleetDataSource;
|
readonly dataSource?: FleetDataSource;
|
||||||
readonly messagesDataSource?: FleetMessagesDataSource;
|
readonly messagesDataSource?: FleetMessagesDataSource;
|
||||||
readonly instanceDataSource?: InstanceDataSource;
|
|
||||||
readonly initialData?: FleetSnapshot;
|
readonly initialData?: FleetSnapshot;
|
||||||
readonly refreshSignal?: number;
|
readonly refreshSignal?: number;
|
||||||
readonly operationClient?: OperationClient;
|
readonly operationClient?: OperationClient;
|
||||||
@@ -128,7 +126,6 @@ const SYSTEM_REBOOT = {
|
|||||||
export function FleetPage({
|
export function FleetPage({
|
||||||
dataSource,
|
dataSource,
|
||||||
messagesDataSource,
|
messagesDataSource,
|
||||||
instanceDataSource,
|
|
||||||
initialData,
|
initialData,
|
||||||
refreshSignal = 0,
|
refreshSignal = 0,
|
||||||
operationClient,
|
operationClient,
|
||||||
@@ -157,13 +154,6 @@ export function FleetPage({
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [deleteId, setDeleteId] = useState<string>();
|
|
||||||
const [deleteOwner, setDeleteOwner] = useState<Readonly<{ id: string; revision: number }>>();
|
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState('');
|
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
||||||
const deleteRequestRef = useRef(0);
|
|
||||||
const [deleteStatus, setDeleteStatus] =
|
|
||||||
useState<Readonly<{ id: string; kind: 'success' | 'error'; message: string }>>();
|
|
||||||
const messagesOwner = useRef(0);
|
const messagesOwner = useRef(0);
|
||||||
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
const [messageStates, setMessageStates] = useState<ReadonlyMap<string, FleetMessageState>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -382,47 +372,6 @@ export function FleetPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function beginDelete(id: string): Promise<void> {
|
|
||||||
const request = ++deleteRequestRef.current;
|
|
||||||
setDeleteId(id);
|
|
||||||
setDeleteOwner(undefined);
|
|
||||||
setDeleteConfirmation('');
|
|
||||||
setDeleteStatus(undefined);
|
|
||||||
if (!instanceDataSource) {
|
|
||||||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDeleteBusy(true);
|
|
||||||
try {
|
|
||||||
const current = await instanceDataSource.get(id);
|
|
||||||
if (request !== deleteRequestRef.current) return;
|
|
||||||
if (current.id !== id) throw new Error('owner mismatch');
|
|
||||||
setDeleteOwner({ id: current.id, revision: current.revision });
|
|
||||||
} catch {
|
|
||||||
if (request === deleteRequestRef.current) {
|
|
||||||
setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (request === deleteRequestRef.current) setDeleteBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(id: string): Promise<void> {
|
|
||||||
if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return;
|
|
||||||
setDeleteBusy(true);
|
|
||||||
setDeleteStatus(undefined);
|
|
||||||
try {
|
|
||||||
await instanceDataSource.delete(id, deleteOwner.revision);
|
|
||||||
setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' });
|
|
||||||
setDeleteId(undefined);
|
|
||||||
setDeleteConfirmation('');
|
|
||||||
} catch {
|
|
||||||
setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' });
|
|
||||||
} finally {
|
|
||||||
setDeleteBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
|
function selectedTargets(): Array<{ instanceId: string; revision: number }> {
|
||||||
const instances = snapshot?.instances ?? [];
|
const instances = snapshot?.instances ?? [];
|
||||||
return model.selectedIds.flatMap((id) => {
|
return model.selectedIds.flatMap((id) => {
|
||||||
@@ -870,7 +819,6 @@ export function FleetPage({
|
|||||||
aria-label={`打开 ${row.displayName} 实例仪表盘`}
|
aria-label={`打开 ${row.displayName} 实例仪表盘`}
|
||||||
>
|
>
|
||||||
<h2>{row.displayName}</h2>
|
<h2>{row.displayName}</h2>
|
||||||
<code>{row.id}</code>
|
|
||||||
<span className="fleet-card-entry-label">进入仪表盘</span>
|
<span className="fleet-card-entry-label">进入仪表盘</span>
|
||||||
</a>
|
</a>
|
||||||
<span className={`status status-${row.statusKind}`}>
|
<span className={`status status-${row.statusKind}`}>
|
||||||
@@ -971,7 +919,7 @@ export function FleetPage({
|
|||||||
<div
|
<div
|
||||||
className="fleet-card-actions fleet-card-admin-actions"
|
className="fleet-card-actions fleet-card-admin-actions"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="实例管理操作"
|
aria-label="实例运维操作"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -990,20 +938,6 @@ export function FleetPage({
|
|||||||
>
|
>
|
||||||
系统重启
|
系统重启
|
||||||
</button>
|
</button>
|
||||||
<a
|
|
||||||
href={`/settings/instances/${encodeURIComponent(row.id)}`}
|
|
||||||
aria-label={`编辑 ${row.displayName}`}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger-button"
|
|
||||||
aria-label={`删除 ${row.displayName}`}
|
|
||||||
onClick={() => void beginDelete(row.id)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{cardAction?.id === row.id ? (
|
{cardAction?.id === row.id ? (
|
||||||
<p
|
<p
|
||||||
@@ -1013,55 +947,6 @@ export function FleetPage({
|
|||||||
{cardAction.error ?? cardAction.message}
|
{cardAction.error ?? cardAction.message}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{deleteId === row.id ? (
|
|
||||||
<div className="card-delete-confirmation">
|
|
||||||
<p>删除后无法撤销。</p>
|
|
||||||
<label>
|
|
||||||
输入 {row.id} 以确认删除
|
|
||||||
<input
|
|
||||||
value={deleteConfirmation}
|
|
||||||
onChange={(event) => setDeleteConfirmation(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="fleet-card-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="danger-button"
|
|
||||||
disabled={
|
|
||||||
deleteBusy ||
|
|
||||||
deleteOwner?.id !== row.id ||
|
|
||||||
deleteConfirmation !== row.id
|
|
||||||
}
|
|
||||||
aria-label={`确认删除 ${row.displayName}`}
|
|
||||||
onClick={() => void remove(row.id)}
|
|
||||||
>
|
|
||||||
确认删除
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteId(undefined);
|
|
||||||
setDeleteConfirmation('');
|
|
||||||
setDeleteStatus(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{deleteStatus?.id === row.id ? (
|
|
||||||
<p
|
|
||||||
role={deleteStatus.kind === 'error' ? 'alert' : 'status'}
|
|
||||||
className={
|
|
||||||
deleteStatus.kind === 'error'
|
|
||||||
? 'card-operation-error'
|
|
||||||
: 'card-operation-success'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deleteStatus.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ describe('InstanceDetail', () => {
|
|||||||
expect(screen.getByText('已认证')).toBeTruthy();
|
expect(screen.getByText('已认证')).toBeTruthy();
|
||||||
expect(screen.getByText('数据最新')).toBeTruthy();
|
expect(screen.getByText('数据最新')).toBeTruthy();
|
||||||
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
|
expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']);
|
||||||
|
expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe(
|
||||||
|
'/settings/instances/owner',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the business titles for both primary pages', () => {
|
it('uses the business titles for both primary pages', () => {
|
||||||
|
|||||||
+1
-10
@@ -412,15 +412,10 @@ small {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.fleet-card h2 {
|
.fleet-card h2 {
|
||||||
margin: 0 0 0.2rem;
|
margin: 0;
|
||||||
font-size: 1.18rem;
|
font-size: 1.18rem;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.fleet-card code {
|
|
||||||
color: var(--muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.fleet-card-metrics {
|
.fleet-card-metrics {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -950,10 +945,6 @@ tbody th small {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 0.2em;
|
text-underline-offset: 0.2em;
|
||||||
}
|
}
|
||||||
.fleet-card-entry code {
|
|
||||||
display: block;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.fleet-card-admin-actions {
|
.fleet-card-admin-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user