Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions .changeset/gantt-hide-qr-context-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@object-ui/plugin-gantt": patch
"@object-ui/i18n": patch
---

feat(plugin-gantt)!: remove the 移动端二维码 (mobile QR share) context-menu item

The QR-share feature is removed outright: the context-menu item, the QR dialog,
the `taskUrl` prop on `GanttView`, the URL wiring in `ObjectGantt`, the
`gantt.menu.qrcode` / `gantt.qr.*` i18n keys (en/zh) and the `qrcode`
dependency are all deleted. It baked one consumer's app-specific requirement
(scan-to-open on mobile) into the generic gantt renderer, and what it encoded —
the desktop console record URL — was not even the right target for that
requirement. Apps that need scan-to-mobile flows should implement them
app-side against their own mobile surface.
8 changes: 0 additions & 8 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,14 +478,6 @@ const en = {
removeDependency: 'Remove dependency',
noCandidates: 'No available tasks',
searchTasks: 'Search tasks…',
qrcode: 'Mobile QR code',
},
qr: {
title: 'Open on mobile',
hint: 'Scan with a phone to open the details',
copy: 'Copy link',
copied: 'Copied',
close: 'Close',
},
delete: {
title: 'Delete this task?',
Expand Down
8 changes: 0 additions & 8 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,14 +554,6 @@ const zh = {
removeDependency: '移除依赖',
noCandidates: '无可用任务',
searchTasks: '搜索任务…',
qrcode: '移动端二维码',
},
qr: {
title: '在手机上打开',
hint: '用手机扫码打开详情页',
copy: '复制链接',
copied: '已复制',
close: '关闭',
},
delete: {
title: '删除此任务?',
Expand Down
2 changes: 0 additions & 2 deletions packages/plugin-gantt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,13 @@
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^15.1.1",
"lucide-react": "^1.24.0",
"qrcode": "^1.5.4",
"sonner": "^2.0.7"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.3",
Expand Down
85 changes: 3 additions & 82 deletions packages/plugin-gantt/src/GanttView.enhancements.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
/**
* Tests for the #2460 interaction batch: row click model (单击=Focus 定位,
* 双击/「→」=详情), day-snap dragging in coarse granularities (周/月拖拽按日吸附),
* collapse-state persistence in 保存布局, the locked-row tooltip hint
* (无编辑权限), and the 移动端二维码 context-menu flow.
* collapse-state persistence in 保存布局, and the locked-row tooltip hint
* (无编辑权限).
*
* Conventions match the other suites: innerWidth=1280 → columnWidth 110,
* rowHeight 40; window pointer events dispatched inside act().
*/
import React from 'react';
import { render, fireEvent, act, waitFor } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GanttView, type GanttTask, type GanttLayout } from './GanttView';

// jsdom/happy-dom has no canvas; stub the encoder so the dialog resolves a src.
vi.mock('qrcode', () => ({
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,STUB'),
}));

beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
window.localStorage.clear();
Expand Down Expand Up @@ -200,77 +195,3 @@ describe('GanttView locked-row tooltip hint (无编辑权限)', () => {
expect(container.querySelector('[data-testid="gantt-tooltip-locked-b"]')).toBeNull();
});
});

describe('GanttView 移动端二维码 (context-menu QR share)', () => {
const URL_A = 'https://app.example.com/app/x/rec/record/a';
const ctxMenu = () => document.querySelector('[data-testid="gantt-context-menu"]');
const qrItem = () => document.querySelector('[data-testid="gantt-context-menu-qrcode"]');
const dialog = () => document.querySelector('[data-testid="gantt-qr-dialog"]');

it('offers the QR item only when taskUrl yields a URL', () => {
// taskUrl → null with other actions present: menu opens, QR item hidden.
const { container, unmount } = renderView([A()], { taskUrl: () => null, onTaskClick: vi.fn() });
fireEvent.contextMenu(container.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
expect(ctxMenu()).toBeTruthy();
expect(qrItem()).toBeNull();
unmount();

// No taskUrl at all: no QR item.
const { container: c2, unmount: u2 } = renderView([A()], { onTaskClick: vi.fn() });
fireEvent.contextMenu(c2.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
expect(qrItem()).toBeNull();
u2();

// taskUrl alone (read-only view) still opens the menu with the QR item.
const { container: c3 } = renderView([A()], { taskUrl: () => URL_A });
fireEvent.contextMenu(c3.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
expect(qrItem()).toBeTruthy();
});

it('opens no empty menu when taskUrl is the only action and yields null', () => {
const { container } = renderView([A()], { taskUrl: () => null });
fireEvent.contextMenu(container.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
expect(ctxMenu()).toBeNull();
});

it('opens the QR dialog with the encoded image and the link, closes cleanly', async () => {
const { container } = renderView([A()], { taskUrl: (t) => `https://app.example.com/rec/${t.id}` });
fireEvent.contextMenu(container.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
fireEvent.click(qrItem()!);

// Menu closes, dialog opens with the target URL and (async) the stub PNG.
expect(ctxMenu()).toBeNull();
expect(dialog()).toBeTruthy();
expect(document.querySelector('[data-testid="gantt-qr-url"]')!.textContent).toBe('https://app.example.com/rec/a');
await waitFor(() => {
const img = document.querySelector('[data-testid="gantt-qr-image"]') as HTMLImageElement;
expect(img?.getAttribute('src')).toBe('data:image/png;base64,STUB');
});

fireEvent.click(document.querySelector('[data-testid="gantt-qr-close"]')!);
expect(dialog()).toBeNull();
});

it('closes the QR dialog on Escape', () => {
const { container } = renderView([A()], { taskUrl: () => URL_A });
fireEvent.contextMenu(container.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
fireEvent.click(qrItem()!);
expect(dialog()).toBeTruthy();
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
expect(dialog()).toBeNull();
});

it('copies the link via the copy button', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true });
const { container } = renderView([A()], { taskUrl: () => URL_A });
fireEvent.contextMenu(container.querySelector('[data-testid="gantt-task-bar-a"]')!, { clientX: 10, clientY: 10 });
fireEvent.click(qrItem()!);
fireEvent.click(document.querySelector('[data-testid="gantt-qr-copy"]')!);
expect(writeText).toHaveBeenCalledWith(URL_A);
// Feedback flips to the copied state.
await waitFor(() => {
expect(document.querySelector('[data-testid="gantt-qr-copy"]')!.textContent).toContain('Copied');
});
});
});
119 changes: 2 additions & 117 deletions packages/plugin-gantt/src/GanttView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,7 @@ import {
Lock,
RefreshCw,
ArrowRight,
QrCode,
Copy,
Check,
} from "lucide-react"
import { toDataURL as qrToDataURL } from "qrcode"
import {
cn,
Button,
Expand Down Expand Up @@ -333,13 +329,6 @@ export interface GanttViewProps {
/** Extra vertical marker lines rendered like the Today marker. */
markers?: GanttMarker[]
onTaskClick?: (task: GanttTask) => void
/**
* Absolute detail-page URL for a task, used by the context menu's
* 「移动端二维码」 item (QR + copy link for opening on a phone). Return
* null/undefined for rows without a detail page (synthetic group rows) to
* hide the item.
*/
taskUrl?: (task: GanttTask) => string | null | undefined
onTaskUpdate?: (task: GanttTask, changes: Partial<Pick<GanttTask, 'title' | 'start' | 'end' | 'progress'>>) => void
onTaskDelete?: (task: GanttTask) => void
/** Notified when the user switches granularity from the toolbar. */
Expand Down Expand Up @@ -660,7 +649,6 @@ export function GanttView({
endDate: endDateProp,
markers: markersProp,
onTaskClick,
taskUrl,
onTaskUpdate: onTaskUpdateProp,
onTaskDelete: onTaskDeleteProp,
onViewChange,
Expand Down Expand Up @@ -1437,36 +1425,6 @@ export function GanttView({
};
}, [ctxMenu]);

// --- 移动端二维码 (share-to-mobile QR) -------------------------------------
// Context-menu item: encode the row's absolute detail URL as a QR so the
// record opens on a phone by scanning, plus a copy-link button. The data URL
// is generated async when the dialog opens; null while pending/failed.
const [qrShare, setQrShare] = React.useState<{ task: GanttTask; url: string } | null>(null);
const [qrDataUrl, setQrDataUrl] = React.useState<string | null>(null);
const [qrCopied, setQrCopied] = React.useState(false);
React.useEffect(() => {
if (!qrShare) { setQrDataUrl(null); setQrCopied(false); return; }
let cancelled = false;
qrToDataURL(qrShare.url, { width: 220, margin: 1 })
.then((d) => { if (!cancelled) setQrDataUrl(d); })
.catch(() => { if (!cancelled) setQrDataUrl(null); });
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setQrShare(null); };
window.addEventListener('keydown', onKey);
return () => { cancelled = true; window.removeEventListener('keydown', onKey); };
}, [qrShare]);
const copyQrLink = React.useCallback(() => {
if (!qrShare) return;
const done = () => {
setQrCopied(true);
window.setTimeout(() => setQrCopied(false), 1500);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(qrShare.url).then(done).catch(() => {
/* clipboard denied — the URL stays visible/selectable in the dialog */
});
}
}, [qrShare]);

// --- Dependency link context menu (依赖增删 + 类型选择) ---------------------
// Right-clicking a dependency link opens a small menu to switch its type
// (FS/SS/FF/SF) or remove it. Closing mirrors the task context menu.
Expand Down Expand Up @@ -1535,11 +1493,9 @@ export function GanttView({
e.preventDefault();
e.stopPropagation();
setSelectedTaskId(task.id);
// 移动端二维码 alone justifies a menu — but only when this row actually
// yields a URL (synthetic group rows return null), so no empty menu opens.
if (!hasTaskMenuActions && !(taskUrl && taskUrl(task))) return;
if (!hasTaskMenuActions) return;
setCtxMenu({ x: e.clientX, y: e.clientY, taskId: task.id });
}, [hasTaskMenuActions, taskUrl]);
}, [hasTaskMenuActions]);

const openLinkContextMenu = React.useCallback(
(sourceId: string | number, targetId: string | number, type: GanttLinkType, e: React.MouseEvent) => {
Expand Down Expand Up @@ -4708,21 +4664,6 @@ export function GanttView({
{t('gantt.menu.view')}
</button>
)}
{taskUrl && (() => {
const url = taskUrl(task);
if (!url) return null;
return (
<button
type="button"
role="menuitem"
className={itemCls}
data-testid="gantt-context-menu-qrcode"
onClick={() => { setCtxMenu(null); setQrShare({ task, url }); }}
>
{t('gantt.menu.qrcode')}
</button>
);
})()}
{inlineEdit && onTaskUpdate && row && !row.isSummary && !task.locked && (
<button
type="button"
Expand Down Expand Up @@ -4790,62 +4731,6 @@ export function GanttView({
);
})()}

{/* 移动端二维码 dialog — scan to open the record's detail page on a phone. */}
{qrShare && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
data-testid="gantt-qr-dialog"
onClick={() => setQrShare(null)}
>
<div
role="dialog"
aria-label={t('gantt.qr.title')}
className="w-[280px] rounded-lg border bg-popover text-popover-foreground p-4 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
<QrCode className="h-4 w-4 shrink-0" />
<span className="truncate">{qrShare.task.title}</span>
</div>
<div className="mb-2 text-xs text-muted-foreground">{t('gantt.qr.hint')}</div>
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={t('gantt.qr.title')}
data-testid="gantt-qr-image"
className="mx-auto block h-[220px] w-[220px] rounded bg-white"
/>
) : (
<div className="flex h-[220px] items-center justify-center text-sm text-muted-foreground">
</div>
)}
<div className="mt-2 break-all text-[11px] text-muted-foreground" data-testid="gantt-qr-url">
{qrShare.url}
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
data-testid="gantt-qr-copy"
onClick={copyQrLink}
>
{qrCopied ? <Check className="mr-1 h-3 w-3" /> : <Copy className="mr-1 h-3 w-3" />}
{qrCopied ? t('gantt.qr.copied') : t('gantt.qr.copy')}
</Button>
<Button
variant="ghost"
size="sm"
data-testid="gantt-qr-close"
onClick={() => setQrShare(null)}
>
{t('gantt.qr.close')}
</Button>
</div>
</div>
</div>
)}

{/* Dependency link context menu (类型选择 + 移除) — fixed-position. */}
{linkCtxMenu && (() => {
const source = tasks.find((tk) => String(tk.id) === String(linkCtxMenu.sourceId));
Expand Down
18 changes: 2 additions & 16 deletions packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* ObjectGantt-side tests for the #2460 batch: quick-filter persistence riding
* on 保存布局 (sibling localStorage key + restore on mount), per-level tooltip
* fields skipping empty values (悬浮分层字段), and the taskUrl passed down for
* the 移动端二维码 item (null for synthetic rows without an objectField value).
* fields skipping empty values (悬浮分层字段).
*
* GanttView is mocked to a thin shell exposing what ObjectGantt feeds it.
*/
Expand All @@ -12,14 +11,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ObjectGantt } from './ObjectGantt';

vi.mock('./GanttView', () => ({
GanttView: ({ tasks, onLayoutChange, taskUrl }: any) => (
GanttView: ({ tasks, onLayoutChange }: any) => (
<div data-testid="gantt-view" data-count={tasks.length} data-ids={tasks.map((t: any) => t.id).join(',')}>
{tasks.map((t: any) => (
<div key={t.id} data-testid={`gv-task-${t.id}`}>
<span data-testid={`gv-fields-${t.id}`}>
{(t.fields ?? []).map((f: any) => `${f.label}=${f.value}`).join('|')}
</span>
<span data-testid={`gv-url-${t.id}`}>{taskUrl ? String(taskUrl(t)) : 'no-fn'}</span>
</div>
))}
{onLayoutChange && (
Expand Down Expand Up @@ -110,15 +108,3 @@ describe('ObjectGantt tooltip fields skip empty values (悬浮分层字段)', ()
});
});

describe('ObjectGantt taskUrl for 移动端二维码', () => {
it('returns null for synthetic rows when objectField is configured but empty', async () => {
const items = [
{ id: 'grp', name: '项目组', start: '2024-01-01', end: '2024-02-10', object_name: '' },
{ id: 'r1', name: '计划', start: '2024-01-01', end: '2024-01-05', object_name: 'plan_obj' },
];
const s = schema({ data: { provider: 'value', items }, quickFilters: undefined, objectField: 'object_name' });
const { container, getByTestId } = render(<ObjectGantt schema={s} />);
await waitFor(() => expect(gv(container).getAttribute('data-count')).toBe('2'));
expect(getByTestId('gv-url-grp').textContent).toBe('null');
});
});
Loading
Loading