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
53 changes: 34 additions & 19 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { HistoryDropdown } from "./History/HistoryDropdown";
import { ZPLOutput } from "./Output/ZPLOutput";
import { ZplImportModal } from "./Output/ZplImportModal";
import { VariableMappingModal } from "./Variables/VariableMappingModal";
import { ConnectDataWizard } from "./Variables/ConnectDataWizard";
import { CsvImportConfirmDialog } from "./Variables/CsvImportConfirmDialog";
import { ExcelSheetModal } from "./Variables/ExcelSheetModal";
import { PrintToZebraDialog } from "./Output/PrintToZebraDialog";
import {
DropdownMenu,
Expand Down Expand Up @@ -43,7 +43,8 @@ import {
} from "@heroicons/react/16/solid";
import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectPreviewLocksEditor, selectBatchPrintCount } from "../store/labelStore";
import { datasetTimestamp } from "@zplab/core/types/DataSource";
import { isCurrentDataContext } from "../store/datasetActions";
import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions";
import { ConfirmDialog } from "./ui/ConfirmDialog";
import { formatTemplate } from "../lib/formatTemplate";
import { buildMenuModel, type MenuItemId } from "../lib/menuModel";
import { NativeMenuBridge } from "./NativeMenuBridge";
Expand All @@ -63,7 +64,6 @@ import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts";
import { useDesignFileActions } from "../hooks/useDesignFileActions";
import { useMcpBridge } from "../hooks/useMcpBridge";
import { useCsvImportActions } from "../hooks/useCsvImportActions";
import { useExcelImportActions } from "../hooks/useExcelImportActions";
import { useZplImportExport } from "../hooks/useZplImportExport";
import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel";
import { useCollapsiblePanel } from "../hooks/useCollapsiblePanel";
Expand Down Expand Up @@ -107,7 +107,7 @@ const MENU_ICONS: Partial<Record<MenuItemId, ComponentType<SVGProps<SVGSVGElemen
openDesign: FolderOpenIcon,
saveDesign: DocumentArrowDownIcon,
importCsv: TableCellsIcon,
importExcel: TableCellsIcon,
connectData: TableCellsIcon,
print: PrinterIcon,
sendToZebra: PaperAirplaneIcon,
undo: ArrowUturnLeftIcon,
Expand Down Expand Up @@ -178,9 +178,19 @@ export function AppShell() {
confirmPendingImport,
cancelPendingImport,
} = useCsvImportActions();
const { openExcelPicker, pendingExcel, loadSheet, cancelExcelImport } =
useExcelImportActions();
const mappingModalOpen = useLabelStore((s) => s.mappingModalOpen);
const connectWizardOpen = useLabelStore((s) => s.connectWizardOpen);
const openConnectWizard = useLabelStore((s) => s.openConnectWizard);
const pendingDatasetReplace = useLabelStore((s) => s.pendingDatasetReplace);
const datasetFetchToken = useLabelStore((s) => s.datasetFetchToken);
// A context bump while the replace confirm is open (doc swap, foreign load)
// supersedes it; drop actively so the deferred fetch promise settles instead
// of leaking behind the hidden dialog.
useEffect(() => {
if (pendingDatasetReplace && pendingDatasetReplace.token !== datasetFetchToken) {
settleDatasetReplace(false);
}
}, [pendingDatasetReplace, datasetFetchToken]);
const closeMappingModal = useLabelStore((s) => s.closeMappingModal);
// Remount the mapping modal when the dataset changes (e.g. importing from its
// own empty state) so its open-time draft re-seeds with the new data. The
Expand Down Expand Up @@ -212,7 +222,7 @@ export function AppShell() {
canBatchExport,
batchRowCount,
batchPrintCount,
includeExcelImport: isDesktopShell,
connectDataWizard: isDesktopShell,
labelaryEnabled,
canUndo,
canRedo,
Expand All @@ -229,7 +239,7 @@ export function AppShell() {
openDesign: handleOpen,
saveDesign: handleSave,
importCsv: openCsvPicker,
importExcel: openExcelPicker,
connectData: openConnectWizard,
// Print routes through Labelary; clicking before the notice has been
// acknowledged opens the disclosure first, then prints.
print: () => (noticeRequired ? setShowPrintNotice(true) : void handlePrint()),
Expand Down Expand Up @@ -520,32 +530,37 @@ export function AppShell() {
</div>

{showZplImport && <ZplImportModal onClose={closeZplImport} />}
{mappingModalOpen && (
{mappingModalOpen && !connectWizardOpen && (
<VariableMappingModal
key={datasetKey ?? "none"}
onClose={closeMappingModal}
onImportCsv={openCsvPicker}
/>
)}
{connectWizardOpen && <ConnectDataWizard />}
{/* Token gate: a doc/context swap leaves the local pending state stale, so
hide the dialog rather than let it hover over the new document. */}
{pendingExcel && isCurrentDataContext(pendingExcel.token) && (
<ExcelSheetModal
// Remount on a fresh pick so the sheet select re-seeds instead of
// keeping a previous file's stale selection.
key={pendingExcel.path}
pending={pendingExcel}
onLoad={loadSheet}
onCancel={cancelExcelImport}
/>
)}
{pendingImport && isCurrentDataContext(pendingImport.token) && (
<CsvImportConfirmDialog
pending={pendingImport}
onConfirm={confirmPendingImport}
onCancel={cancelPendingImport}
/>
)}
{/* Replace confirm for fetched sources (excel/db); same token gate as the
sibling pending dialogs, and settleDatasetReplace re-checks on click. */}
{pendingDatasetReplace && isCurrentDataContext(pendingDatasetReplace.token) && (
<ConfirmDialog
message={formatTemplate(t.variables.csvReplaceCsvBodyFmt, {
old: pendingDatasetReplace.oldName,
new: pendingDatasetReplace.newName,
})}
confirmLabel={t.variables.csvReplaceAction}
cancelLabel={t.variables.cancel}
onConfirm={() => settleDatasetReplace(true)}
onCancel={() => settleDatasetReplace(false)}
/>
)}
{showZebraPrint && (
<PrintToZebraDialog zpl={currentZpl()} onClose={closeZebraPrint} />
)}
Expand Down
99 changes: 31 additions & 68 deletions src/components/PrinterSettings/DataSourcesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { CircleStackIcon, PlusIcon, TrashIcon } from '@heroicons/react/16/solid';
import { CircleStackIcon, PlusIcon, TableCellsIcon, TrashIcon } from '@heroicons/react/16/solid';
import { useLabelStore } from '../../store/labelStore';
import { useT } from '../../hooks/useT';
import { useDbConnectActions } from '../../hooks/useDbConnectActions';
Expand All @@ -8,6 +8,7 @@ import {
dbPasswordCred,
dbSetPassword,
enqueueCredWrite,
grantedSqlitePaths,
pickSqliteFile,
revokeSqlitePath,
} from '../../lib/db';
Expand All @@ -16,7 +17,8 @@ import { formatTemplate } from '../../lib/formatTemplate';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { Select } from '../ui/Select';
import { inputCls } from '../Properties/styles';
import type { DbProfile, DbSslMode } from '../../lib/db';
import { NetworkDbFields } from '../db/NetworkDbFields';
import type { DbProfile } from '../../lib/db';

import { newId } from "@zplab/core/lib/ids";
type Driver = DbProfile['driver'];
Expand All @@ -33,6 +35,7 @@ export function DataSourcesTab() {
const removeDbProfile = useLabelStore((s) => s.removeDbProfile);
const dataSourceRef = useLabelStore((s) => s.dataSourceRef);
const setPrinterSettingsTab = useLabelStore((s) => s.setPrinterSettingsTab);
const openConnectWizard = useLabelStore((s) => s.openConnectWizard);
const { loadFromDb } = useDbConnectActions();

const [selectedId, setSelectedId] = useState<string>(() => {
Expand Down Expand Up @@ -119,10 +122,7 @@ export function DataSourcesTab() {
// Call AFTER the profile mutation so the keep list reflects the new state
// (revokeSqlitePath documents the shared-grant semantics).
const revokeOrphanedGrant = (path: string) => {
const keep = useLabelStore
.getState()
.dbProfiles.flatMap((p) => (p.driver === 'sqlite' && p.path ? [p.path] : []));
void revokeSqlitePath(path, keep);
void revokeSqlitePath(path, grantedSqlitePaths(useLabelStore.getState().dbProfiles));
};

const handleDriverChange = (driver: Driver) => {
Expand Down Expand Up @@ -220,6 +220,18 @@ export function DataSourcesTab() {

return (
<div className="flex flex-col gap-3 font-mono text-xs max-w-md">
{/* Secondary entry into the guided flow; the wizard replaces the modal. */}
<button
onClick={() => {
setPrinterSettingsTab(null);
openConnectWizard();
}}
className="self-start flex items-center gap-1.5 px-2 py-1.5 rounded text-xs border border-dashed border-border text-muted hover:text-text hover:border-border-2 transition-colors"
>
<TableCellsIcon className="w-3.5 h-3.5" />
{tv.connectData}
</button>

<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<label className={fieldLabel}>{tv.dbProfileLabel}</label>
Expand Down Expand Up @@ -308,51 +320,17 @@ export function DataSourcesTab() {
</div>
</div>
) : (
<>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<label className={fieldLabel}>{tv.dbHostLabel}</label>
<input
className={inputCls}
value={profile.host}
onChange={(e) => updateDbProfile({ ...profile, host: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1 w-20 shrink-0">
<label className={fieldLabel}>{tv.dbPortLabel}</label>
<input
type="number"
className={inputCls}
placeholder={profile.driver === 'postgres' ? '5432' : '3306'}
value={profile.port ?? ''}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
updateDbProfile({
...profile,
// Rust deserializes into u16; clamp instead of an
// opaque serde error at fetch time.
port: Number.isNaN(n) ? undefined : Math.min(65535, Math.max(1, n)),
});
}}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className={fieldLabel}>{tv.dbDatabaseLabel}</label>
<input
className={inputCls}
value={profile.database}
onChange={(e) => updateDbProfile({ ...profile, database: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1">
<label className={fieldLabel}>{tv.dbUserLabel}</label>
<input
className={inputCls}
value={profile.user}
onChange={(e) => updateDbProfile({ ...profile, user: e.target.value })}
/>
</div>
<NetworkDbFields
driver={profile.driver}
value={{
host: profile.host,
port: profile.port,
database: profile.database,
user: profile.user,
sslMode: profile.sslMode ?? 'prefer',
}}
onChange={(patch) => updateDbProfile({ ...profile, ...patch })}
>
<div className="flex flex-col gap-1">
<label className={fieldLabel}>{tv.dbPasswordLabel}</label>
<input
Expand All @@ -373,28 +351,13 @@ export function DataSourcesTab() {
{tv.dbPasswordClear}
</button>
</div>

<div className="flex flex-col gap-1">
<label className={fieldLabel}>{tv.dbSslLabel}</label>
<Select<DbSslMode>
value={profile.sslMode ?? 'prefer'}
onChange={(sslMode) => updateDbProfile({ ...profile, sslMode })}
groups={[
{
options: (['prefer', 'require', 'verify-full', 'disable'] as const).map(
(m) => ({ value: m, label: m }),
),
},
]}
/>
</div>
</>
</NetworkDbFields>
)}

<div className="flex flex-col gap-1">
<label className={fieldLabel}>{tv.dbTableLabel}</label>
{tablesError ? (
<p className="text-[10px] text-amber-400 break-words">
<p className="text-[10px] text-amber-400 wrap-break-word">
{formatTemplate(tv.dbTablesErrorFmt, { error: tablesError })}
</p>
) : (
Expand Down
61 changes: 61 additions & 0 deletions src/components/Variables/ConnectDataWizard.excel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup, fireEvent, act, waitFor } from '@testing-library/react';
import { useLabelStore } from '../../store/labelStore';
import { fallbackTranslations as en } from '../../locales';
import { EXCEL_DATASET } from './wizardTestFixtures';
import type * as platform from '../../lib/platform';

vi.mock('../../lib/platform', async (orig) => ({
...(await orig<typeof platform>()),
isDesktopShell: true,
}));
vi.mock('../../lib/excel', () => ({
pickExcelFile: vi.fn(async () => '/data/f.xlsx'),
excelListSheets: vi.fn(async () => ['Sheet1', 'Sheet2']),
excelFetchDataset: vi.fn(async (_path: string, filename: string, sheet: string) => ({
headers: ['sku', 'price'],
rows: [['A1', '9.99']],
source: { kind: 'excel', filename, sheet, importedAt: '2026-01-01T00:00:00Z', rowCount: 1, truncated: false },
})),
}));

const { ConnectDataWizard } = await import('./ConnectDataWizard');


afterEach(cleanup);

describe('ConnectDataWizard Excel (desktop)', () => {
it('advances to mapping after picking a worksheet', async () => {
act(() => {
useLabelStore.setState({ dataset: null, variables: [], columnMapping: null } as never);
});
const { getByText, findByText } = render(<ConnectDataWizard />);
await act(async () => {
fireEvent.click(getByText(en.connectData.excel));
});
await findByText(en.variables.excelSheetLabel);
await act(async () => {
fireEvent.click(getByText(en.variables.dbLoad));
});
await findByText(en.connectData.finish);
});

it('cancelling the sheet modal keeps the old dataset and stays on source despite the token bump', async () => {
// cancelExcelImport bumps the token (invalidateDatasetFetches); the bump
// must not read as a wizard load against this pre-existing dataset.
act(() => {
useLabelStore.setState({ dataset: EXCEL_DATASET, variables: [], columnMapping: null } as never);
});
const { getByText, queryByText } = render(<ConnectDataWizard />);
await act(async () => {
fireEvent.click(getByText(en.connectData.excel));
});
await waitFor(() => getByText(en.variables.excelSheetLabel));
await act(async () => {
fireEvent.click(getByText(en.variables.cancel));
});
getByText(en.connectData.chooseSource);
expect(queryByText(en.connectData.finish)).toBeNull();
});
});
Loading