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
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function EntityDeleteDialog<TEntity>({
await deleteMutation(entity);
toast({
title: translations.successMessage,
variant: 'success',
});
onClose();
onSuccess?.();
Expand Down
54 changes: 19 additions & 35 deletions services/platform/app/components/ui/feedback/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva } from 'class-variance-authority';
import { X, CheckCircle2, XCircle } from 'lucide-react';
import { X, CheckCircle2, XCircle, Info } from 'lucide-react';

import { useToast } from '@/app/hooks/use-toast';
import { cn } from '@/lib/utils/cn';
Expand Down Expand Up @@ -34,7 +34,9 @@ function VariantIcon({ variant }: { variant?: ToastVariant }) {
case 'destructive':
return <XCircle className="text-destructive size-5" aria-hidden="true" />;
default:
return null;
return (
<Info className="text-muted-foreground size-5" aria-hidden="true" />
);
}
}

Expand All @@ -45,47 +47,29 @@ export function Toaster() {
<ToastPrimitives.Provider duration={3500}>
{toasts.map(
({ id, title, description, action, variant, className, ...props }) => {
const icon = <VariantIcon variant={variant} />;
const hasIcon = variant === 'success' || variant === 'destructive';

return (
<ToastPrimitives.Root
key={id}
className={cn(toastVariants({ variant }), className)}
{...props}
>
{hasIcon ? (
<div className="flex items-start space-x-3">
{icon}
<div className="flex-1 pr-4">
<div className="grid gap-1">
{title && (
<ToastPrimitives.Title className="text-sm font-semibold">
{title}
</ToastPrimitives.Title>
)}
{description && (
<ToastPrimitives.Description className="text-sm opacity-90">
{description}
</ToastPrimitives.Description>
)}
</div>
<div className="flex items-start space-x-3">
<VariantIcon variant={variant} />
<div className="flex-1 pr-4">
<div className="grid gap-1">
{title && (
<ToastPrimitives.Title className="text-sm font-semibold">
{title}
</ToastPrimitives.Title>
)}
{description && (
<ToastPrimitives.Description className="text-sm opacity-90">
{description}
</ToastPrimitives.Description>
)}
</div>
</div>
) : (
<div className="grid gap-1">
{title && (
<ToastPrimitives.Title className="text-sm font-semibold">
{title}
</ToastPrimitives.Title>
)}
{description && (
<ToastPrimitives.Description className="text-sm opacity-90">
{description}
</ToastPrimitives.Description>
)}
</div>
)}
</div>
{action}
<ToastPrimitives.Close
className="text-foreground/50 hover:text-foreground absolute top-2.5 right-2.5 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-2 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export function AgentDeleteDialog({
});
toast({
title: t('agents.agentDeleted'),
variant: 'success',
});
onOpenChange(false);
onDeleted?.();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export function CustomerImportForm({
</FileUpload.Root>
<Text as="div" variant="caption" className="leading-relaxed">
<ul className="list-outside list-disc space-y-2 pl-4">
<li>{t('importForm.expectedColumns')}</li>
<li>{t('importForm.localeHint')}</li>
<li className="text-blue-600">{t('importForm.statusNote')}</li>
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ export function ProductsImportDialog({
[],
);

const excelMapper = useCallback(
const recordMapper = useCallback(
(record: Record<string, unknown>): ParsedProduct | null => {
const result = productMappers.excel(record);
const result = productMappers.record(record);
if (!result) return null;

return {
Expand All @@ -90,22 +90,9 @@ export function ProductsImportDialog({
[validateStatus],
);

const csvMapper = useCallback(
(row: string[], index: number): ParsedProduct | null => {
const result = productMappers.csv(row, index);
if (!result) return null;

return {
...result,
status: validateStatus(row[7]),
};
},
[validateStatus],
);

const { parseFile } = useFileImport<ParsedProduct>({
csvMapper,
excelMapper,
csvMapper: productMappers.csv,
excelMapper: recordMapper,
});

const resetForm = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export function ApiKeyRevokeDialog({
onSuccess: () => {
toast({
title: tSettings('apiKeys.keyRevoked'),
variant: 'success',
});
onOpenChange(false);
onSuccess?.();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function ProvidersTable({ organizationId }: ProvidersTableProps) {
orgSlug: 'default',
providerName: deleteProvider.name,
});
toast({ title: t('providers.deleted'), variant: 'success' });
toast({ title: t('providers.deleted') });
setDeleteProvider(null);
invalidateProviders();
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ export function TeamDeleteDialog({

toast({
title: tSettings('teams.teamDeleted'),
variant: 'success',
});
onOpenChange(false);
onSuccess?.();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function VendorImportForm({
</FileUpload.Root>
<Text as="div" variant="caption" className="leading-relaxed">
<ul className="list-outside list-disc space-y-2 pl-4">
<li>{t('importForm.formatHint')}</li>
<li>{t('importForm.expectedColumns')}</li>
<li>{t('importForm.localeHint')}</li>
</ul>
</Text>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
'use client';

import { useNavigate } from '@tanstack/react-router';
import type { Row } from '@tanstack/react-table';
import type { Row, RowSelectionState } from '@tanstack/react-table';
import { Store } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';

import { DataTable } from '@/app/components/ui/data-table/data-table';
import { BulkDeleteBar } from '@/app/components/ui/data-table/data-table-bulk-actions';
import { useListPage } from '@/app/hooks/use-list-page';
import type { Doc } from '@/convex/_generated/dataModel';
import { useT } from '@/lib/i18n/client';

import { useDeleteVendor } from '../hooks/mutations';
import {
useApproxVendorCount,
useListVendorsPaginated,
Expand Down Expand Up @@ -126,11 +128,26 @@ export function VendorsTable({
);

const [viewingVendor, setViewingVendor] = useState<Vendor | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const deleteVendor = useDeleteVendor();

const handleRowClick = useCallback((row: Row<Vendor>) => {
setViewingVendor(row.original);
}, []);

const handleClearSelection = useCallback(() => {
setRowSelection({});
}, []);

const handleDeleteItem = useCallback(
async (id: string) => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Convex Id type from row selection key
const vendorId = id as Doc<'vendors'>['_id'];
await deleteVendor.mutateAsync({ vendorId });
},
[deleteVendor],
);

const list = useListPage<Vendor>({
dataSource: {
type: 'paginated',
Expand Down Expand Up @@ -159,12 +176,23 @@ export function VendorsTable({
columns={columns}
stickyLayout={stickyLayout}
onRowClick={handleRowClick}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
actionMenu={<VendorsActionMenu organizationId={organizationId} />}
emptyState={{
icon: Store,
title: tEmpty('vendors.title'),
description: tEmpty('vendors.description'),
}}
footer={
<BulkDeleteBar
rowSelection={rowSelection}
onClearSelection={handleClearSelection}
onDeleteItem={handleDeleteItem}
onDeleteComplete={handleClearSelection}
/>
}
{...list.tableProps}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const useVendorsTableConfig = createTableConfigHook<'vendors'>(
defaultSort: '_creationTime',
},
({ tTables, builders }) => [
builders.createSelectColumn(),
{
accessorKey: 'name',
header: tTables('headers.name'),
Expand Down
12 changes: 6 additions & 6 deletions services/platform/app/hooks/__tests__/use-file-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ describe('customerMappers.excel', () => {
});
});

describe('productMappers.excel', () => {
describe('productMappers.record', () => {
it('parses record with lowercase keys', () => {
const result = productMappers.excel({
const result = productMappers.record({
name: 'Widget',
description: 'A fine widget',
price: 9.99,
Expand All @@ -178,17 +178,17 @@ describe('productMappers.excel', () => {
});

it('falls back to title when name is missing', () => {
const result = productMappers.excel({ title: 'Gadget', price: 5 });
const result = productMappers.record({ title: 'Gadget', price: 5 });
expect(result).toMatchObject({ name: 'Gadget' });
});

it('returns null when name and title are missing', () => {
const result = productMappers.excel({ description: 'orphan' });
const result = productMappers.record({ description: 'orphan' });
expect(result).toBeNull();
});

it('defaults stock to 0, price to 0, currency to USD', () => {
const result = productMappers.excel({ name: 'Minimal' });
const result = productMappers.record({ name: 'Minimal' });
expect(result).toMatchObject({
stock: 0,
price: 0,
Expand All @@ -197,7 +197,7 @@ describe('productMappers.excel', () => {
});

it('resolves imageurl key (normalized from ImageUrl/imageUrl)', () => {
const result = productMappers.excel({
const result = productMappers.record({
name: 'Pic',
imageurl: 'https://example.com/pic.png',
});
Expand Down
35 changes: 20 additions & 15 deletions services/platform/app/hooks/use-file-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,22 +227,27 @@ export const productMappers = {
const lowerValue = value.toLowerCase();
return validStatuses.find((s) => s === lowerValue) ?? defaultStatus;
},
csv: (row: string[], _index: number) => {
const name = row[0]?.trim();
if (!name) return null;

return {
name,
description: row[1]?.trim() || undefined,
imageUrl: row[2]?.trim() || undefined,
stock: row[3] ? parseInt(row[3], 10) : 0,
price: row[4] ? parseFloat(row[4]) : 0,
currency: row[5]?.trim() || 'USD',
category: row[6]?.trim() || undefined,
source: 'manual_import' as const,
};
/** Expected header names for product imports */
expectedHeaders: [
'name',
'description',
'imageurl',
'stock',
'price',
'currency',
'category',
'status',
] as const,
/**
* CSV fallback mapper — only runs when headers are NOT detected.
* Returns null for every row so the import fails with a clear error
* instead of silently misaligning columns by position.
*/
csv: (_row: string[], _index: number) => {
return null;
},
excel: (record: Record<string, unknown>) => {
/** Record-based mapper used by both CSV (with headers) and Excel imports */
record: (record: Record<string, unknown>) => {
Comment on lines +246 to +250
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This breaks useFileImport().parseCSV() for valid product CSVs.

Line 247 makes the CSV mapper reject every row, but parseCSV() in this hook still calls parseCSVWithMapper(csvText, csvMapper) without a recordMapper. So a valid headered product CSV parses correctly from a File, but the same CSV text comes back empty through parseCSV().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/hooks/use-file-import.ts` around lines 246 - 250, The
csv row mapper in useFileImport currently returns null (csv: (_row...) => {
return null; }) which causes parseCSV to drop all rows because
parseCSVWithMapper is invoked without a recordMapper; fix by having the csv
mapper translate a headered CSV row into a record and forward to the existing
record mapper (or simply delegate to record: (r)=>...): replace the csv mapper
implementation so it builds a Record<string, unknown> from the header/row pair
and calls the existing record mapper or returns its result, ensuring parseCSV
and parseCSVWithMapper produce the same parsed output as file-based CSV imports.

const name = getString(record.name) || getString(record.title);
if (!name) return null;

Expand Down
Loading
Loading