mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 15:15:18 +00:00
feat(web-shell): add cell value dialog on double-click in markdown tables (#6530)
* feat(web-shell): add cell value dialog on double-click in markdown tables Double-clicking a table cell opens a modal dialog showing the cell's full content with copy and close actions. The dialog clears any active selection or row details on open, supports Escape to dismiss, and click-outside to close. Adds EN/ZH i18n keys and four unit tests covering open, copy, dismiss, and state-clearing behaviour. * fix(web-shell): improve table cell dialog interactions * fix(web-shell): portal table cell dialog * fix(web-shell): block shortcuts behind cell dialog --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
b19ebd8fc6
commit
fc77764f83
7 changed files with 845 additions and 47 deletions
|
|
@ -96,6 +96,7 @@ const {
|
|||
},
|
||||
mockStore: {
|
||||
dispatch: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
appendLocalUserMessage: vi.fn(),
|
||||
appendLocalAssistantMessage: vi.fn(),
|
||||
},
|
||||
|
|
@ -155,8 +156,8 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@qwen-code/sdk/daemon')>()),
|
||||
vi.mock('@qwen-code/sdk/daemon', () => ({
|
||||
DAEMON_GOAL_STATUS_SENTINEL_PREFIX: 'qwen-goal-status:',
|
||||
isDaemonTurnError: () => false,
|
||||
}));
|
||||
|
||||
|
|
@ -221,6 +222,27 @@ vi.mock('./components/ChatEditor', async () => {
|
|||
|
||||
vi.mock('./components/MessageList', async () => {
|
||||
const React = await import('react');
|
||||
const { useInteractionBlocker } = await import('./interactionBlockContext');
|
||||
function InteractionBlockerProbe() {
|
||||
const registerInteractionBlocker = useInteractionBlocker();
|
||||
const releaseRef = React.useRef<(() => void) | null>(null);
|
||||
return React.createElement(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'interaction-blocker',
|
||||
onClick: () => {
|
||||
if (releaseRef.current) {
|
||||
releaseRef.current();
|
||||
releaseRef.current = null;
|
||||
} else {
|
||||
releaseRef.current = registerInteractionBlocker();
|
||||
}
|
||||
},
|
||||
type: 'button',
|
||||
},
|
||||
releaseRef.current ? 'release blocker' : 'register blocker',
|
||||
);
|
||||
}
|
||||
return {
|
||||
MessageList: React.forwardRef(function MessageList(
|
||||
props: { showRetryHint?: boolean; onRetryClick?: () => void },
|
||||
|
|
@ -230,6 +252,7 @@ vi.mock('./components/MessageList', async () => {
|
|||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'messages' },
|
||||
React.createElement(InteractionBlockerProbe),
|
||||
props.showRetryHint
|
||||
? React.createElement(
|
||||
'button',
|
||||
|
|
@ -659,6 +682,8 @@ beforeEach(() => {
|
|||
mockSessionActions.sendShellCommand.mockResolvedValue(undefined);
|
||||
mockSessionActions.getStats.mockResolvedValue({});
|
||||
mockSessionActions.loadSession.mockResolvedValue(undefined);
|
||||
mockStore.reset.mockClear();
|
||||
mockStore.dispatch.mockClear();
|
||||
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] });
|
||||
mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null });
|
||||
mockWorkspaceActions.loadPreflight.mockResolvedValue(null);
|
||||
|
|
@ -2076,6 +2101,43 @@ describe('App session callbacks', () => {
|
|||
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks app-level shortcuts while an external modal is registered', async () => {
|
||||
const { container } = renderApp();
|
||||
await flush();
|
||||
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="interaction-blocker"]')
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: 'l',
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
ctrlKey: true,
|
||||
key: 'y',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockStore.reset).not.toHaveBeenCalled();
|
||||
expect(mockStore.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores composer focus after an approval resolves following a panel auto-close', async () => {
|
||||
// Regression: on panel auto-close the editor focus is intentionally skipped
|
||||
// (the approval owns the keyboard); when the approval later resolves with no
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ import {
|
|||
type TodoSnapshotDiff,
|
||||
} from './utils/todos';
|
||||
import { ThemeProvider } from './themeContext';
|
||||
import { InteractionBlockContext } from './interactionBlockContext';
|
||||
import {
|
||||
WebShellThemeId,
|
||||
THEME_SETTING_KEY,
|
||||
|
|
@ -2178,6 +2179,17 @@ export function App({
|
|||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
|
||||
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
|
||||
const [externalInteractionBlockCount, setExternalInteractionBlockCount] =
|
||||
useState(0);
|
||||
const registerInteractionBlocker = useCallback(() => {
|
||||
let released = false;
|
||||
setExternalInteractionBlockCount((count) => count + 1);
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
setExternalInteractionBlockCount((count) => Math.max(0, count - 1));
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Refresh commands when extensions change (install/uninstall/update).
|
||||
const workspaceEventSignals = useWorkspaceEventSignals();
|
||||
|
|
@ -2468,6 +2480,7 @@ export function App({
|
|||
agentsDialogMode !== null ||
|
||||
showMemoryDialog ||
|
||||
showAuthDialog ||
|
||||
externalInteractionBlockCount > 0 ||
|
||||
// The Settings / Daemon Status panel replaces the chat surface, so — like a
|
||||
// modal — it must suppress chat-only global shortcuts (Ctrl+L/O/Y, the
|
||||
// Shift+Tab mode cycle, the btw hotkey). Escape is intercepted earlier and
|
||||
|
|
@ -5626,19 +5639,22 @@ export function App({
|
|||
timeline={todoTimeline}
|
||||
details={todoDetails}
|
||||
>
|
||||
{(() => {
|
||||
const contentClassName = [
|
||||
styles.content,
|
||||
showFloatingTodos ||
|
||||
displayMessages.length > 0 ||
|
||||
pendingApproval
|
||||
? styles.contentHasMessages
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
<InteractionBlockContext.Provider
|
||||
value={registerInteractionBlocker}
|
||||
>
|
||||
{(() => {
|
||||
const contentClassName = [
|
||||
styles.content,
|
||||
showFloatingTodos ||
|
||||
displayMessages.length > 0 ||
|
||||
pendingApproval
|
||||
? styles.contentHasMessages
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const messageList = (
|
||||
const messageList = (
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
messages={displayMessages}
|
||||
|
|
@ -5688,11 +5704,11 @@ export function App({
|
|||
onOpenArtifact={openArtifactPanel}
|
||||
onOpenScheduledTask={openScheduledTaskPanel}
|
||||
/>
|
||||
);
|
||||
);
|
||||
|
||||
const btwPanel =
|
||||
!showMobileWelcomeFooterMiddle &&
|
||||
btwMessage?.role === 'btw' ? (
|
||||
const btwPanel =
|
||||
!showMobileWelcomeFooterMiddle &&
|
||||
btwMessage?.role === 'btw' ? (
|
||||
<div className={styles.btwPanel}>
|
||||
<BtwMessage
|
||||
question={btwMessage.question}
|
||||
|
|
@ -5700,36 +5716,37 @@ export function App({
|
|||
isPending={btwMessage.isPending}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
) : null;
|
||||
|
||||
if (showMobileWelcomeFooterMiddle) {
|
||||
if (showMobileWelcomeFooterMiddle) {
|
||||
return (
|
||||
<div className={styles.mobileWelcomeGroup}>
|
||||
<div
|
||||
style={contentStyle}
|
||||
className={contentClassName}
|
||||
>
|
||||
{messageList}
|
||||
{btwPanel}
|
||||
</div>
|
||||
<div
|
||||
className={styles.mobileWelcomeFooterMiddle}
|
||||
>
|
||||
{welcomeFooter}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.mobileWelcomeGroup}>
|
||||
<div
|
||||
style={contentStyle}
|
||||
className={contentClassName}
|
||||
>
|
||||
{messageList}
|
||||
{btwPanel}
|
||||
</div>
|
||||
<div
|
||||
className={styles.mobileWelcomeFooterMiddle}
|
||||
>
|
||||
{welcomeFooter}
|
||||
</div>
|
||||
<div
|
||||
style={contentStyle}
|
||||
className={contentClassName}
|
||||
>
|
||||
{messageList}
|
||||
{btwPanel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={contentStyle}
|
||||
className={contentClassName}
|
||||
>
|
||||
{messageList}
|
||||
{btwPanel}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
</InteractionBlockContext.Provider>
|
||||
</TodoContextsProvider>
|
||||
</CompactModeContext.Provider>
|
||||
|
||||
|
|
|
|||
|
|
@ -631,6 +631,141 @@ tr:hover .frozenCell.selectedCell {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.themeDark {
|
||||
--background: #0d0d0d;
|
||||
--foreground: #fafafa;
|
||||
--muted-foreground: #808598;
|
||||
--border: #2a2a2a;
|
||||
--card: #161616;
|
||||
--agent-blue-500: #6785ff;
|
||||
}
|
||||
|
||||
.themeLight {
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0b;
|
||||
--muted-foreground: #a3a3a5;
|
||||
--border: #e3e4e6;
|
||||
--card: #ffffff;
|
||||
--agent-blue-500: #0033ff;
|
||||
}
|
||||
|
||||
.cellDialogBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: color-mix(in srgb, var(--background) 62%, transparent);
|
||||
}
|
||||
|
||||
.cellDialog {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
min-height: 190px;
|
||||
padding: 20px 24px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--card) 96%, white 4%),
|
||||
color-mix(in srgb, var(--card) 92%, black 8%)
|
||||
);
|
||||
box-shadow: 0 18px 56px rgb(0 0 0 / 38%);
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cellDialogCloseIcon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 14px;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cellDialogCloseIcon:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cellDialogTitle {
|
||||
margin-bottom: 16px;
|
||||
font-size: inherit;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cellDialogValue {
|
||||
min-height: 100px;
|
||||
max-height: min(32vh, 200px);
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--background) 72%, transparent);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cellDialogFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.cellDialogActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cellDialogButton {
|
||||
min-width: 64px;
|
||||
padding: 6px 14px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cellDialogButton:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
}
|
||||
|
||||
.cellDialogPrimaryButton {
|
||||
background: var(--agent-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cellDialogPrimaryButton:hover {
|
||||
background: color-mix(in srgb, var(--agent-blue-500) 86%, white 14%);
|
||||
}
|
||||
|
||||
.emptyValue {
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
|
|
@ -642,3 +777,34 @@ tr:hover .frozenCell.selectedCell {
|
|||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cellDialogBackdrop {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cellDialog {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 20px 14px 14px;
|
||||
}
|
||||
|
||||
.cellDialogTitle {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.cellDialogFooter {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.cellDialogActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cellDialogButton {
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,12 @@ function click(el: Element): void {
|
|||
});
|
||||
}
|
||||
|
||||
function doubleClick(el: Element): void {
|
||||
act(() => {
|
||||
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function rightClick(el: Element): MouseEvent {
|
||||
const event = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
|
|
@ -191,6 +197,10 @@ function textButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|||
return el!;
|
||||
}
|
||||
|
||||
function cellDialog(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
}
|
||||
|
||||
function textButtonContaining(
|
||||
container: HTMLElement,
|
||||
text: string,
|
||||
|
|
@ -722,6 +732,297 @@ describe('EnhancedMarkdownTable', () => {
|
|||
).toBe('');
|
||||
});
|
||||
|
||||
it('opens a selectable cell value dialog on double click', () => {
|
||||
const container = renderTable();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
|
||||
const dialog = cellDialog();
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(dialog?.textContent).toContain('Current field value');
|
||||
expect(dialog?.textContent).toContain('Alpha');
|
||||
});
|
||||
|
||||
it('copies the current cell value from the dialog', async () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = renderTable();
|
||||
|
||||
doubleClick(dataCell(container, 1, 0));
|
||||
await act(async () => {
|
||||
textButton(document.body, 'Copy').click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('Beta');
|
||||
expect(document.body.textContent).toContain('Copied!');
|
||||
});
|
||||
|
||||
it('sanitizes the current cell value copied from the dialog', async () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = renderTableContent([
|
||||
<thead key="head">
|
||||
<tr>
|
||||
<th>Formula</th>
|
||||
</tr>
|
||||
</thead>,
|
||||
<tbody key="body">
|
||||
<tr>
|
||||
<td>=IMPORTXML("https://example.com")</td>
|
||||
</tr>
|
||||
</tbody>,
|
||||
]);
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
await act(async () => {
|
||||
textButton(document.body, 'Copy').click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
'\'=IMPORTXML("https://example.com")',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the cell value dialog in sync with table updates', async () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const render = (value: string) => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<EnhancedMarkdownTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{value}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</EnhancedMarkdownTable>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
mounted.push({ root, container });
|
||||
|
||||
render('Alpha');
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
expect(cellDialog()?.textContent).toContain('Alpha');
|
||||
await act(async () => {
|
||||
textButton(document.body, 'Copy').click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(cellDialog()?.textContent).toContain('Copied!');
|
||||
|
||||
render('Beta');
|
||||
expect(cellDialog()?.textContent).not.toContain('Alpha');
|
||||
expect(cellDialog()?.textContent).toContain('Beta');
|
||||
expect(cellDialog()?.textContent).not.toContain('Copied!');
|
||||
|
||||
await act(async () => {
|
||||
textButton(document.body, 'Copy').click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('Beta');
|
||||
});
|
||||
|
||||
it('copies an empty cell value from the dialog', async () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = renderTableContent([
|
||||
<thead key="head">
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
</tr>
|
||||
</thead>,
|
||||
<tbody key="body">
|
||||
<tr>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>,
|
||||
]);
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
await act(async () => {
|
||||
textButton(document.body, 'Copy').click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('closes the cell value dialog with Escape', () => {
|
||||
const container = renderTable();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
expect(cellDialog()).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(cellDialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the cell value dialog from the backdrop and buttons', () => {
|
||||
const container = renderTable();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
const backdrop = cellDialog()?.parentElement;
|
||||
expect(backdrop).not.toBeNull();
|
||||
act(() => {
|
||||
backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
});
|
||||
expect(cellDialog()).toBeNull();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
click(button(document.body, 'Close'));
|
||||
expect(cellDialog()).toBeNull();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
click(textButton(document.body, 'Close'));
|
||||
expect(cellDialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('restores focus when closing the cell value dialog', () => {
|
||||
const container = renderTable();
|
||||
const scroller = container.querySelector<HTMLElement>('[tabindex="0"]');
|
||||
expect(scroller).not.toBeNull();
|
||||
act(() => {
|
||||
scroller!.focus();
|
||||
});
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
expect(document.activeElement).not.toBe(scroller);
|
||||
|
||||
click(textButton(document.body, 'Close'));
|
||||
|
||||
expect(document.activeElement).toBe(scroller);
|
||||
});
|
||||
|
||||
it('traps focus inside the cell value dialog', () => {
|
||||
const container = renderTable();
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
const iconCloseButton = button(document.body, 'Close');
|
||||
const footerCloseButton = textButton(document.body, 'Close');
|
||||
|
||||
expect(document.activeElement).toBe(iconCloseButton);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(footerCloseButton);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(iconCloseButton);
|
||||
|
||||
const dialog = cellDialog();
|
||||
expect(dialog).not.toBeNull();
|
||||
act(() => {
|
||||
dialog!.focus();
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).toBe(iconCloseButton);
|
||||
});
|
||||
|
||||
it('keeps table Escape handling from running behind the cell dialog', () => {
|
||||
const container = renderTable();
|
||||
const teamHandle = button(container, 'Move Team');
|
||||
|
||||
click(button(container, 'Sort by Team'));
|
||||
expect(teamHandle.className).toContain('reorderHandleVisible');
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'Escape',
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(cellDialog()).toBeNull();
|
||||
expect(teamHandle.className).toContain('reorderHandleVisible');
|
||||
});
|
||||
|
||||
it('clears table selection and row details when opening a cell dialog', () => {
|
||||
const container = renderTable();
|
||||
|
||||
dragCells(dataCell(container, 0, 0), dataCell(container, 0, 0));
|
||||
expect(container.textContent).toContain('1 cell selected');
|
||||
|
||||
click(button(container, 'View details for row 1'));
|
||||
expect(container.textContent).toContain('Row details');
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
|
||||
expect(container.textContent).not.toContain('1 cell selected');
|
||||
expect(container.textContent).not.toContain('Row details');
|
||||
expect(cellDialog()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes an open filter menu when opening a cell dialog', () => {
|
||||
const container = renderTable();
|
||||
|
||||
click(button(container, 'Filter Team'));
|
||||
expect(container.textContent).toContain('Custom filter');
|
||||
|
||||
doubleClick(dataCell(container, 0, 0));
|
||||
|
||||
expect(container.textContent).not.toContain('Custom filter');
|
||||
expect(cellDialog()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not open the cell dialog when double clicking an interactive target', () => {
|
||||
const container = renderTableContent([
|
||||
<thead key="head">
|
||||
<tr>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>,
|
||||
<tbody key="body">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="#details">Open details</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>,
|
||||
]);
|
||||
const link = container.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
|
||||
doubleClick(link!);
|
||||
|
||||
expect(cellDialog()).toBeNull();
|
||||
});
|
||||
|
||||
it('quick copies the visible sorted table', () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = renderTable();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ import {
|
|||
type RefObject,
|
||||
type TouchEvent as ReactTouchEvent,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useInteractionBlocker } from '../../interactionBlockContext';
|
||||
import { useTheme, WebShellThemeId } from '../../themeContext';
|
||||
import styles from './EnhancedMarkdownTable.module.css';
|
||||
|
||||
type TableElement = ReactElement<{
|
||||
|
|
@ -96,6 +99,11 @@ interface ColumnResizeState {
|
|||
startWidth: number;
|
||||
}
|
||||
|
||||
interface CellDialogState {
|
||||
rowKey: string;
|
||||
columnIndex: number;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
|
@ -143,6 +151,8 @@ function clampColumnWidth(width: number): number {
|
|||
|
||||
const FOCUSABLE_FILTER_MENU_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const FOCUSABLE_CELL_DIALOG_SELECTOR =
|
||||
'a[href]:not([hidden]), button:not([disabled]):not([hidden]), input:not([disabled]):not([hidden]), select:not([disabled]):not([hidden]), textarea:not([disabled]):not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])';
|
||||
|
||||
function getFocusableFilterMenuElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
|
|
@ -150,6 +160,15 @@ function getFocusableFilterMenuElements(container: HTMLElement): HTMLElement[] {
|
|||
).filter((element) => !element.hasAttribute('hidden'));
|
||||
}
|
||||
|
||||
function getFocusableCellDialogElements(
|
||||
container: HTMLElement | null,
|
||||
): HTMLElement[] {
|
||||
if (!container) return [];
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_CELL_DIALOG_SELECTOR),
|
||||
);
|
||||
}
|
||||
|
||||
function isInteractiveSelectionTarget(target: EventTarget | null): boolean {
|
||||
return (
|
||||
target instanceof Element &&
|
||||
|
|
@ -1203,6 +1222,8 @@ export function EnhancedTable({
|
|||
toolbarExtra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const theme = useTheme();
|
||||
const registerInteractionBlocker = useInteractionBlocker();
|
||||
const tableId = useId();
|
||||
const [sort, setSort] = useState<SortState | null>(null);
|
||||
const [filters, setFilters] = useState<Record<number, ColumnFilter>>({});
|
||||
|
|
@ -1224,11 +1245,13 @@ export function EnhancedTable({
|
|||
const [resizingColumn, setResizingColumn] =
|
||||
useState<ColumnResizeState | null>(null);
|
||||
const [detailRowKey, setDetailRowKey] = useState<string | null>(null);
|
||||
const [cellDialog, setCellDialog] = useState<CellDialogState | null>(null);
|
||||
const [longTextExpanded, setLongTextExpanded] = useState(false);
|
||||
const [density, setDensity] = useState<TableDensity>('standard');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [copiedVisible, setCopiedVisible] = useState(false);
|
||||
const [copiedSelection, setCopiedSelection] = useState(false);
|
||||
const [copiedCellDialog, setCopiedCellDialog] = useState(false);
|
||||
const draggingRef = useRef(false);
|
||||
const copiedVisibleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
|
|
@ -1236,14 +1259,20 @@ export function EnhancedTable({
|
|||
const copiedSelectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const copiedCellDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const copiedVisibleGenRef = useRef(0);
|
||||
const copiedSelectionGenRef = useRef(0);
|
||||
const copiedCellDialogGenRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const shellRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const columnContextMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const filterTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const cellDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const cellDialogFocusReturnRef = useRef<HTMLElement | null>(null);
|
||||
const focusReturnFrameRef = useRef(0);
|
||||
const pendingSelectionRef = useRef<{
|
||||
rowIndex: number;
|
||||
|
|
@ -1294,6 +1323,15 @@ export function EnhancedTable({
|
|||
setCopiedSelection(false);
|
||||
}, []);
|
||||
|
||||
const resetCopiedCellDialog = useCallback(() => {
|
||||
copiedCellDialogGenRef.current += 1;
|
||||
if (copiedCellDialogTimerRef.current) {
|
||||
clearTimeout(copiedCellDialogTimerRef.current);
|
||||
copiedCellDialogTimerRef.current = null;
|
||||
}
|
||||
setCopiedCellDialog(false);
|
||||
}, []);
|
||||
|
||||
const flushPendingSelection = useCallback(() => {
|
||||
if (selectionFrameRef.current) {
|
||||
cancelAnimationFrame(selectionFrameRef.current);
|
||||
|
|
@ -1354,15 +1392,18 @@ export function EnhancedTable({
|
|||
setFreezeFirstColumn(false);
|
||||
setResizingColumn(null);
|
||||
setDetailRowKey(null);
|
||||
setCellDialog(null);
|
||||
setLongTextExpanded(false);
|
||||
setDensity('standard');
|
||||
resetCopiedVisible();
|
||||
resetCopiedSelection();
|
||||
resetCopiedCellDialog();
|
||||
draggingRef.current = false;
|
||||
draggingColumnRef.current = null;
|
||||
setIsDragging(false);
|
||||
}, [
|
||||
resetCopiedSelection,
|
||||
resetCopiedCellDialog,
|
||||
resetCopiedVisible,
|
||||
table.columnCount,
|
||||
tableStructureKey,
|
||||
|
|
@ -1386,6 +1427,10 @@ export function EnhancedTable({
|
|||
clearTimeout(copiedSelectionTimerRef.current);
|
||||
copiedSelectionTimerRef.current = null;
|
||||
}
|
||||
if (copiedCellDialogTimerRef.current) {
|
||||
clearTimeout(copiedCellDialogTimerRef.current);
|
||||
copiedCellDialogTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -1491,7 +1536,7 @@ export function EnhancedTable({
|
|||
}
|
||||
};
|
||||
const clearActiveColumnOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || openFilterMenu || columnContextMenu) return;
|
||||
if (event.defaultPrevented || openFilterMenu || cellDialog || columnContextMenu) return;
|
||||
if (event.key === 'Escape') setActiveColumn(null);
|
||||
};
|
||||
document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown);
|
||||
|
|
@ -1503,7 +1548,7 @@ export function EnhancedTable({
|
|||
);
|
||||
document.removeEventListener('keydown', clearActiveColumnOnEscape);
|
||||
};
|
||||
}, [columnContextMenu, openFilterMenu]);
|
||||
}, [cellDialog, columnContextMenu, openFilterMenu]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => applyFilters(table.rows, filters),
|
||||
|
|
@ -1539,6 +1584,14 @@ export function EnhancedTable({
|
|||
const frozenColumnIndex = freezeFirstColumn
|
||||
? orderedVisibleColumnIndexes[0]
|
||||
: undefined;
|
||||
const currentCellDialogCell = useMemo(() => {
|
||||
if (!cellDialog) return null;
|
||||
const row = visibleRows.find((item) => item.key === cellDialog.rowKey);
|
||||
return row?.cells[cellDialog.columnIndex] ?? null;
|
||||
}, [cellDialog, visibleRows]);
|
||||
const currentCellDialogText = currentCellDialogCell?.text;
|
||||
const cellDialogThemeClass =
|
||||
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
|
||||
|
||||
useEffect(() => {
|
||||
resetCopiedVisible();
|
||||
|
|
@ -1593,7 +1646,78 @@ export function EnhancedTable({
|
|||
if (detailRowKey && !visibleRows.some((row) => row.key === detailRowKey)) {
|
||||
setDetailRowKey(null);
|
||||
}
|
||||
}, [detailRowKey, visibleRows]);
|
||||
if (
|
||||
cellDialog &&
|
||||
!visibleRows.some(
|
||||
(row) =>
|
||||
row.key === cellDialog.rowKey && row.cells[cellDialog.columnIndex],
|
||||
)
|
||||
) {
|
||||
setCellDialog(null);
|
||||
}
|
||||
}, [cellDialog, detailRowKey, visibleRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellDialog) return;
|
||||
return registerInteractionBlocker();
|
||||
}, [cellDialog, registerInteractionBlocker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellDialog) return;
|
||||
resetCopiedCellDialog();
|
||||
}, [cellDialog, currentCellDialogText, resetCopiedCellDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cellDialog) return;
|
||||
const dialog = cellDialogRef.current;
|
||||
const focusableElements = getFocusableCellDialogElements(dialog);
|
||||
(focusableElements[0] ?? dialog)?.focus();
|
||||
|
||||
const handleDialogKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCellDialog(null);
|
||||
resetCopiedCellDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const nextFocusableElements = getFocusableCellDialogElements(
|
||||
cellDialogRef.current,
|
||||
);
|
||||
if (nextFocusableElements.length === 0) {
|
||||
event.preventDefault();
|
||||
cellDialogRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const first = nextFocusableElements[0];
|
||||
const last = nextFocusableElements[nextFocusableElements.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
const currentIndex =
|
||||
activeElement instanceof HTMLElement
|
||||
? nextFocusableElements.indexOf(activeElement)
|
||||
: -1;
|
||||
if (event.shiftKey && (activeElement === first || currentIndex === -1)) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(activeElement === last || currentIndex === -1)
|
||||
) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleDialogKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleDialogKeyDown);
|
||||
const focusReturn = cellDialogFocusReturnRef.current;
|
||||
cellDialogFocusReturnRef.current = null;
|
||||
if (focusReturn?.isConnected) focusReturn.focus();
|
||||
};
|
||||
}, [cellDialog, resetCopiedCellDialog]);
|
||||
|
||||
const setColumnFilter = (
|
||||
columnIndex: number,
|
||||
|
|
@ -1727,9 +1851,53 @@ export function EnhancedTable({
|
|||
|
||||
const toggleRowDetail = (rowKey: string) => {
|
||||
setSelection(null);
|
||||
setCellDialog(null);
|
||||
resetCopiedCellDialog();
|
||||
setDetailRowKey((current) => (current === rowKey ? null : rowKey));
|
||||
};
|
||||
|
||||
const openCellDialog = (rowKey: string, columnIndex: number) => {
|
||||
cellDialogFocusReturnRef.current =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
setSelection(null);
|
||||
setOpenFilterMenu(null);
|
||||
setDetailRowKey(null);
|
||||
resetCopiedCellDialog();
|
||||
setCellDialog({
|
||||
rowKey,
|
||||
columnIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const closeCellDialog = () => {
|
||||
setCellDialog(null);
|
||||
resetCopiedCellDialog();
|
||||
};
|
||||
|
||||
const copyCellDialogValue = () => {
|
||||
if (currentCellDialogText == null || !navigator.clipboard) return;
|
||||
const copyGeneration = copiedCellDialogGenRef.current;
|
||||
void navigator.clipboard
|
||||
.writeText(sanitizeForClipboard(currentCellDialogText))
|
||||
.then(() => {
|
||||
if (!mountedRef.current) return;
|
||||
if (copiedCellDialogGenRef.current !== copyGeneration) return;
|
||||
if (copiedCellDialogTimerRef.current) {
|
||||
clearTimeout(copiedCellDialogTimerRef.current);
|
||||
}
|
||||
setCopiedCellDialog(true);
|
||||
copiedCellDialogTimerRef.current = setTimeout(
|
||||
() => setCopiedCellDialog(false),
|
||||
2000,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
console.warn('[web-shell] clipboard write failed:', error),
|
||||
);
|
||||
};
|
||||
|
||||
const selectionRowBounds = useMemo(
|
||||
() => (selection ? getSelectionRowBounds(selection) : null),
|
||||
[selection],
|
||||
|
|
@ -2367,6 +2535,12 @@ export function EnhancedTable({
|
|||
onTouchMove={extendTouchSelection}
|
||||
onTouchEnd={stopDragging}
|
||||
onTouchCancel={stopDragging}
|
||||
onDoubleClick={(event) => {
|
||||
if (isInteractiveSelectionTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
openCellDialog(row.key, columnIndex);
|
||||
}}
|
||||
>
|
||||
{renderCellContent(cell, longTextExpanded)}
|
||||
</td>
|
||||
|
|
@ -2462,6 +2636,65 @@ export function EnhancedTable({
|
|||
onSort={setColumnSort}
|
||||
/>
|
||||
)}
|
||||
{cellDialog &&
|
||||
currentCellDialogCell &&
|
||||
createPortal(
|
||||
<div
|
||||
className={`${styles.cellDialogBackdrop} ${cellDialogThemeClass}`}
|
||||
onMouseDown={closeCellDialog}
|
||||
>
|
||||
<div
|
||||
ref={cellDialogRef}
|
||||
className={styles.cellDialog}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${tableId}-cell-dialog-title`}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.cellDialogCloseIcon}
|
||||
type="button"
|
||||
onClick={closeCellDialog}
|
||||
aria-label={t('markdownTable.close')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div
|
||||
id={`${tableId}-cell-dialog-title`}
|
||||
className={styles.cellDialogTitle}
|
||||
>
|
||||
{t('markdownTable.cellDialogTitle')}
|
||||
</div>
|
||||
<div className={styles.cellDialogValue}>
|
||||
{currentCellDialogCell.content}
|
||||
</div>
|
||||
<div className={styles.cellDialogFooter}>
|
||||
<div className={styles.cellDialogActions}>
|
||||
<button
|
||||
className={styles.cellDialogButton}
|
||||
type="button"
|
||||
onClick={copyCellDialogValue}
|
||||
>
|
||||
{copiedCellDialog
|
||||
? t('code.copied')
|
||||
: t('markdownTable.copyCell')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.cellDialogButton} ${
|
||||
styles.cellDialogPrimaryButton
|
||||
}`}
|
||||
type="button"
|
||||
onClick={closeCellDialog}
|
||||
>
|
||||
{t('markdownTable.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,9 @@ const EN: Messages = {
|
|||
'markdownTable.closeRowDetailsAria': (v) =>
|
||||
`Hide details for row ${v?.index ?? ''}`,
|
||||
'markdownTable.detailsHeader': 'Row details',
|
||||
'markdownTable.cellDialogTitle': 'Current field value',
|
||||
'markdownTable.copyCell': 'Copy',
|
||||
'markdownTable.close': 'Close',
|
||||
'markdownTable.actions': 'Actions',
|
||||
'markdownTable.sortByColumn': (v) => `Sort by ${v?.column ?? ''}`,
|
||||
'markdownTable.sortByColumnAsc': (v) =>
|
||||
|
|
@ -2111,6 +2114,9 @@ const ZH: Messages = {
|
|||
'markdownTable.rowDetailsAria': (v) => `查看第 ${v?.index ?? ''} 行详情`,
|
||||
'markdownTable.closeRowDetailsAria': (v) => `收起第 ${v?.index ?? ''} 行详情`,
|
||||
'markdownTable.detailsHeader': '单行详情',
|
||||
'markdownTable.cellDialogTitle': '当前字段值',
|
||||
'markdownTable.copyCell': '复制',
|
||||
'markdownTable.close': '关闭',
|
||||
'markdownTable.actions': '操作',
|
||||
'markdownTable.sortByColumn': (v) => `按 ${v?.column ?? ''} 排序`,
|
||||
'markdownTable.sortByColumnAsc': (v) => `${v?.column ?? ''} 已升序排序`,
|
||||
|
|
|
|||
13
packages/web-shell/client/interactionBlockContext.ts
Normal file
13
packages/web-shell/client/interactionBlockContext.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type RegisterInteractionBlocker = () => () => void;
|
||||
|
||||
const noopRelease = () => {};
|
||||
const noopRegisterInteractionBlocker = () => noopRelease;
|
||||
|
||||
export const InteractionBlockContext =
|
||||
createContext<RegisterInteractionBlocker>(noopRegisterInteractionBlocker);
|
||||
|
||||
export function useInteractionBlocker(): RegisterInteractionBlocker {
|
||||
return useContext(InteractionBlockContext);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue