diff --git a/.changeset/gantt-hide-qr-context-menu.md b/.changeset/gantt-hide-qr-context-menu.md new file mode 100644 index 000000000..716574840 --- /dev/null +++ b/.changeset/gantt-hide-qr-context-menu.md @@ -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. diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index a00bab6bf..5bd089564 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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?', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index dfcf02bdc..c5682b6ac 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -554,14 +554,6 @@ const zh = { removeDependency: '移除依赖', noCandidates: '无可用任务', searchTasks: '搜索任务…', - qrcode: '移动端二维码', - }, - qr: { - title: '在手机上打开', - hint: '用手机扫码打开详情页', - copy: '复制链接', - copied: '已复制', - close: '关闭', }, delete: { title: '删除此任务?', diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json index f8698d849..82b71d6cf 100644 --- a/packages/plugin-gantt/package.json +++ b/packages/plugin-gantt/package.json @@ -40,7 +40,6 @@ "@object-ui/types": "workspace:*", "@objectstack/spec": "^15.1.1", "lucide-react": "^1.24.0", - "qrcode": "^1.5.4", "sonner": "^2.0.7" }, "peerDependencies": { @@ -48,7 +47,6 @@ "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", diff --git a/packages/plugin-gantt/src/GanttView.enhancements.test.tsx b/packages/plugin-gantt/src/GanttView.enhancements.test.tsx index 9ab3bce75..33242294d 100644 --- a/packages/plugin-gantt/src/GanttView.enhancements.test.tsx +++ b/packages/plugin-gantt/src/GanttView.enhancements.test.tsx @@ -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(); @@ -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'); - }); - }); -}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index 9d00766ac..cf2f67c82 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -30,11 +30,7 @@ import { Lock, RefreshCw, ArrowRight, - QrCode, - Copy, - Check, } from "lucide-react" -import { toDataURL as qrToDataURL } from "qrcode" import { cn, Button, @@ -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>) => void onTaskDelete?: (task: GanttTask) => void /** Notified when the user switches granularity from the toolbar. */ @@ -660,7 +649,6 @@ export function GanttView({ endDate: endDateProp, markers: markersProp, onTaskClick, - taskUrl, onTaskUpdate: onTaskUpdateProp, onTaskDelete: onTaskDeleteProp, onViewChange, @@ -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(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. @@ -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) => { @@ -4708,21 +4664,6 @@ export function GanttView({ {t('gantt.menu.view')} )} - {taskUrl && (() => { - const url = taskUrl(task); - if (!url) return null; - return ( - - ); - })()} {inlineEdit && onTaskUpdate && row && !row.isSummary && !task.locked && ( - - - - - )} - {/* Dependency link context menu (类型选择 + 移除) — fixed-position. */} {linkCtxMenu && (() => { const source = tasks.find((tk) => String(tk.id) === String(linkCtxMenu.sourceId)); diff --git a/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx b/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx index 0ad68302a..074836c64 100644 --- a/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.persistfilters.test.tsx @@ -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. */ @@ -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) => (
t.id).join(',')}> {tasks.map((t: any) => (
{(t.fields ?? []).map((f: any) => `${f.label}=${f.value}`).join('|')} - {taskUrl ? String(taskUrl(t)) : 'no-fn'}
))} {onLayoutChange && ( @@ -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(); - await waitFor(() => expect(gv(container).getAttribute('data-count')).toBe('2')); - expect(getByTestId('gv-url-grp').textContent).toBe('null'); - }); -}); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 09d6eb2a8..6617ff664 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -1019,11 +1019,10 @@ export const ObjectGantt: React.FC = ({ // row opened under the bound object's route otherwise builds a 404 URL. // deriveRecordPageHref needs the routed object's segment in the current path // (a foreign row object never appears there), so derive from the routed - // object and swap the segment. Shared by the drawer's 整页 link and the - // context menu's 移动端二维码. + // object and swap the segment. Used by the drawer's 整页 link. // With objectField configured, a row without a value is a synthetic group // header composed by the endpoint (its id isn't a real record id) — no - // detail page, drawer or QR link exists for it. + // detail page or drawer exists for it. const isSyntheticRow = useCallback( (rec: Record | undefined): boolean => !!ganttConfig?.objectField && !String(rec?.[ganttConfig.objectField] ?? '').trim(), @@ -1411,15 +1410,6 @@ export const ObjectGantt: React.FC = ({ } onTaskClick?.(task.data); }} - taskUrl={(task) => { - const rec = task.data as Record | undefined; - if (!rec || isSyntheticRow(rec)) return null; - const href = recordDetailHref(rec)?.href; - if (!href) return null; - // Absolute URL: the QR is scanned on another device, so a - // path-relative href would be useless there. - return typeof window === 'undefined' ? href : new URL(href, window.location.href).toString(); - }} onTaskUpdate={handleTaskUpdateDefault} onTaskDelete={requestDelete} onDependencyCreate={ganttConfig?.dependenciesField ? handleDependencyCreate : undefined} diff --git a/packages/plugin-gantt/src/useGanttTranslation.ts b/packages/plugin-gantt/src/useGanttTranslation.ts index ec9db7565..17638863e 100644 --- a/packages/plugin-gantt/src/useGanttTranslation.ts +++ b/packages/plugin-gantt/src/useGanttTranslation.ts @@ -50,12 +50,6 @@ export const GANTT_DEFAULT_TRANSLATIONS: Record = { 'gantt.aria.taskList': 'Task list', 'gantt.tooltip.days': 'd', 'gantt.menu.view': 'View details', - 'gantt.menu.qrcode': 'Mobile QR code', - 'gantt.qr.title': 'Open on mobile', - 'gantt.qr.hint': 'Scan with a phone to open the details', - 'gantt.qr.copy': 'Copy link', - 'gantt.qr.copied': 'Copied', - 'gantt.qr.close': 'Close', 'gantt.menu.edit': 'Edit inline', 'gantt.menu.delete': 'Delete', 'gantt.menu.addPredecessor': 'Add predecessor…', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64540ddb5..14a2f3c9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1898,9 +1898,6 @@ importers: lucide-react: specifier: ^1.24.0 version: 1.24.0(react@19.2.7) - qrcode: - specifier: ^1.5.4 - version: 1.5.4 react: specifier: 19.2.7 version: 19.2.7 @@ -1911,9 +1908,6 @@ importers: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) devDependencies: - '@types/qrcode': - specifier: ^1.5.6 - version: 1.5.6 '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -7899,8 +7893,8 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isbot@5.2.1: - resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + isbot@5.2.0: + resolution: {integrity: sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw==} engines: {node: '>=18'} isexe@2.0.0: @@ -13358,7 +13352,7 @@ snapshots: '@tanstack/history': 1.162.0 '@tanstack/react-store': 0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/router-core': 1.171.6 - isbot: 5.2.1 + isbot: 5.2.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optional: true @@ -16282,7 +16276,7 @@ snapshots: isarray@1.0.0: {} - isbot@5.2.1: + isbot@5.2.0: optional: true isexe@2.0.0: {}