Skip to content
Open
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
6 changes: 5 additions & 1 deletion includes/Admin/Dashboard/Pages/Withdraw.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace WeDevs\Dokan\Admin\Dashboard\Pages;

use WeDevs\Dokan\REST\WithdrawExportController;

class Withdraw extends AbstractPage {

/**
Expand Down Expand Up @@ -32,7 +34,9 @@ public function menu( string $capability, string $position ): array {
* @inheritDoc
*/
public function settings(): array {
return [];
return [
'columns' => ( new WithdrawExportController() )->get_export_columns(),
];
}

/**
Expand Down
6 changes: 3 additions & 3 deletions includes/REST/WithdrawExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,19 @@
}

if ( ! empty( $request['status'] ) ) {
$args['status'] = $request['status'];
$args['status'] = dokan()->withdraw->get_status_code( $request['status'] );
}
Comment on lines 90 to 92
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Consider validating the status conversion result.

If an invalid status somehow bypasses the enum validation or if get_status_code returns null, assigning null to $args['status'] might produce unexpected query behavior. Consider only setting the status if the conversion succeeds:

🔎 Suggested defensive check
 if ( ! empty( $request['status'] ) ) {
-    $args['status'] = dokan()->withdraw->get_status_code( $request['status'] );
+    $status_code = dokan()->withdraw->get_status_code( $request['status'] );
+    if ( null !== $status_code ) {
+        $args['status'] = $status_code;
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ( ! empty( $request['status'] ) ) {
$args['status'] = $request['status'];
$args['status'] = dokan()->withdraw->get_status_code( $request['status'] );
}
if ( ! empty( $request['status'] ) ) {
$status_code = dokan()->withdraw->get_status_code( $request['status'] );
if ( null !== $status_code ) {
$args['status'] = $status_code;
}
}
🤖 Prompt for AI Agents
In includes/REST/WithdrawExportController.php around lines 90 to 92, the code
assigns $args['status'] directly from
dokan()->withdraw->get_status_code($request['status']) which may return null;
update this to capture the conversion result in a variable, check that it is not
null (or not false) before setting $args['status'], and if the conversion fails
either skip setting the status filter or handle the invalid value (e.g., return
a validation error or use a safe default) to avoid passing null into the query.


if ( ! empty( $request['payment_method'] ) ) {
$args['method'] = $request['payment_method'];
}

if ( ! empty( $request['after'] ) ) {
$args['after'] = $request['after'];
$args['start_date'] = $request['after'];
}

if ( ! empty( $request['before'] ) ) {
$args['before'] = $request['before'];
$args['end_date'] = $request['before'];
}

// Set pagination
Expand Down Expand Up @@ -197,7 +197,7 @@
* @return \WP_Error|boolean
*/
public function get_items_permissions_check( $request ) {
if ( ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'dokan_view_reports' ) ) {

Check warning on line 200 in includes/REST/WithdrawExportController.php

View workflow job for this annotation

GitHub Actions / Run PHPCS inspection

Found unknown capability "dokan_view_reports" in function call to current_user_can(). Please check the spelling of the capability. If this is a custom capability, please verify the capability is registered with WordPress via a call to WP_Role(s)->add_cap(). Custom capabilities can be made known to this sniff by setting the "custom_capabilities" property in the PHPCS ruleset.
return new \WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot list resources.', 'dokan-lite' ), array( 'status' => rest_authorization_required_code() ) );
}

Expand Down
101 changes: 75 additions & 26 deletions src/admin/dashboard/pages/withdraw/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { DokanTooltip as Tooltip } from '@dokan/components';
import * as LucideIcons from 'lucide-react';
import { dateI18n, getSettings } from '@wordpress/date';
import { useToast } from '@getdokan/dokan-ui';
// Import Dokan components
import {
AdminDataViews as DataViews,
Expand All @@ -21,7 +22,16 @@ import {
DokanModal,
} from '@dokan/components';

import { Trash, ArrowDown, Home, Calendar, CreditCard } from 'lucide-react';
import { Trash, ArrowDown, Home, Calendar, CreditCard, Loader2 } from 'lucide-react';
import { directDownloadCSV } from '@src/utils/download-csv';

// Define withdraw CSV headers
const WITHDRAW_CSV_HEADERS = Object.entries(
dokanAdminDashboardSettings.withdraw.columns
).map( ( [ key, value ] ) => ( {
key,
label: value,
} ) );
Comment on lines +28 to +34
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

No defensive check on dokanAdminDashboardSettings.withdraw.columns.

This runs at module-load time. If withdraw or columns is missing from the global settings object (e.g., a different page context, a race, or a plugin conflict), Object.entries(...) will throw and the entire page will crash.

Proposed fix
 // Define withdraw CSV headers
 const WITHDRAW_CSV_HEADERS = Object.entries(
-    dokanAdminDashboardSettings.withdraw.columns
+    dokanAdminDashboardSettings?.withdraw?.columns ?? {}
 ).map( ( [ key, value ] ) => ( {
     key,
     label: value,
 } ) );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Define withdraw CSV headers
const WITHDRAW_CSV_HEADERS = Object.entries(
dokanAdminDashboardSettings.withdraw.columns
).map( ( [ key, value ] ) => ( {
key,
label: value,
} ) );
// Define withdraw CSV headers
const WITHDRAW_CSV_HEADERS = Object.entries(
dokanAdminDashboardSettings?.withdraw?.columns ?? {}
).map( ( [ key, value ] ) => ( {
key,
label: value,
} ) );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/admin/dashboard/pages/withdraw/index.tsx` around lines 28 - 34,
WITHDRAW_CSV_HEADERS is computed at module load using
Object.entries(dokanAdminDashboardSettings.withdraw.columns) which will throw if
withdraw or columns is missing; change this to safely access the path and
provide a fallback empty object, e.g. replace the top-level computation with a
guarded expression using optional chaining and a default:
Object.entries(dokanAdminDashboardSettings?.withdraw?.columns ?? {}) and map
that result, and to avoid module-load crashes compute it lazily where used (e.g.
inside the component or a useMemo) rather than at top-level; update references
to WITHDRAW_CSV_HEADERS accordingly or generate headers via a
getWithdrawCsvHeaders() helper that performs the same guarded access.


// Define withdraw statuses for tab filtering
const WITHDRAW_STATUSES = [
Expand Down Expand Up @@ -88,6 +98,7 @@ const WithdrawPage = () => {
approved: 0,
cancelled: 0,
} );
const [ isExporting, setIsExporting ] = useState( false );
const [ filterArgs, setFilterArgs ] = useState( {} );
const [ activeStatus, setActiveStatus ] = useState( 'pending' );
const [ vendorFilter, setVendorFilter ] = useState< VendorSelect | null >(
Expand All @@ -98,6 +109,7 @@ const WithdrawPage = () => {
const [ before, setBefore ] = useState( '' );
const [ beforeText, setBeforeText ] = useState( '' );
const [ focusInput, setFocusInput ] = useState( 'startDate' );
const toast = useToast();

const [ paymentMethod, setPaymentMethod ] = useState< {
value: string | number;
Expand Down Expand Up @@ -512,16 +524,9 @@ const WithdrawPage = () => {
titleField: 'vendor',
status: 'pending',
layout: { density: 'comfortable' },
fields: [
'amount',
'status',
'method',
'charge',
'payable',
'date',
'details',
'note',
],
fields: fields.map( ( field ) =>
field.id !== 'vendor' ? field.id : ''
),
Comment on lines +527 to +529
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Empty string in fields array — use filter instead of mapping to ''.

Mapping the vendor field to an empty string leaves a bogus entry in the array. If the DataViews component iterates over fields to render columns, it may produce an empty/broken column or a console warning.

Proposed fix
-        fields: fields.map( ( field ) =>
-            field.id !== 'vendor' ? field.id : ''
-        ),
+        fields: fields
+            .map( ( field ) => field.id )
+            .filter( ( id ) => id !== 'vendor' ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fields: fields.map( ( field ) =>
field.id !== 'vendor' ? field.id : ''
),
fields: fields
.map( ( field ) => field.id )
.filter( ( id ) => id !== 'vendor' ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/admin/dashboard/pages/withdraw/index.tsx` around lines 527 - 529, The
current mapping of fields produces an empty string for the vendor field (fields:
fields.map(... field.id !== 'vendor' ? field.id : '')) which leaves a bogus
entry; replace this with a filter-first (or filter-after) approach so only real
ids are included—for example use fields.filter(f => f.id !== 'vendor').map(f =>
f.id) (or map then .filter(Boolean)) so the fields array passed to DataViews
contains no empty strings; update the code that builds fields (the fields
variable/mapping) accordingly.

} );

// Handle tab selection for status filtering
Expand Down Expand Up @@ -551,28 +556,72 @@ const WithdrawPage = () => {
const tabsAdditionalContents = [
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-[#575757] hover:bg-[#7047EB] hover:text-white"
disabled={ isExporting }
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-[#575757] hover:bg-[#7047EB] hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
onClick={ async () => {
setIsExporting(true);

try {
// Minimal placeholder; backend export flow may vary.
// Attempt to hit export endpoint via same query params.
const path = addQueryArgs( 'dokan/v2/withdraw', {
const shouldEmail = data && data.length < totalItems;

// If all data is already loaded, download directly
if ( ! shouldEmail && data && data.length > 0 ) {
const csvData = data.map( ( item ) => ( {
...item,
user_id: item.user?.id,
vendor_name: item.user?.store_name,
payment_method: item.method_title,
payable: item.receivable,
date: item.created,
} ) );
directDownloadCSV( 'withdraws', WITHDRAW_CSV_HEADERS, csvData, filterArgs );
Comment on lines +569 to +577
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

details column will export as [object Object] in the CSV.

item.details is a nested object (see processDetails() usage throughout this file). When spread into csvData, the details key retains the raw object, so objectToCSVRows will serialize it as "[object Object]" in the CSV output. You need to flatten it the same way the UI does.

Proposed fix
 const csvData = data.map( ( item ) => ( {
     ...item,
     user_id: item.user?.id,
     vendor_name: item.user?.store_name,
     payment_method: item.method_title,
     payable: item.receivable,
     date: item.created,
+    details: processDetails( item.details, item.method ),
 } ) );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const csvData = data.map( ( item ) => ( {
...item,
user_id: item.user?.id,
vendor_name: item.user?.store_name,
payment_method: item.method_title,
payable: item.receivable,
date: item.created,
} ) );
directDownloadCSV( 'withdraws', WITHDRAW_CSV_HEADERS, csvData, filterArgs );
const csvData = data.map( ( item ) => ( {
...item,
user_id: item.user?.id,
vendor_name: item.user?.store_name,
payment_method: item.method_title,
payable: item.receivable,
date: item.created,
details: processDetails( item.details, item.method ),
} ) );
directDownloadCSV( 'withdraws', WITHDRAW_CSV_HEADERS, csvData, filterArgs );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/admin/dashboard/pages/withdraw/index.tsx` around lines 569 - 577, The CSV
export is including the raw nested details object so the details column becomes
"[object Object]"; update the csvData mapping used before calling
directDownloadCSV so that instead of spreading item.details as-is you
normalize/flatten it the same way the UI does (use the existing
processDetails(item.details) helper or its returned flattened fields) and assign
those flattened/serializable values (or a JSON/string representation) to the
details-related CSV fields; keep WITHDRAW_CSV_HEADERS in sync with those
flattened keys so objectToCSVRows produces readable CSV rows.

return;
Comment on lines 556 to +578
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Direct-download path doesn't account for the !shouldEmail && data.length === 0 edge case.

When data is loaded but empty (data.length === 0 and totalItems === 0), shouldEmail is false and the direct-download guard (data.length > 0) is also false, so execution falls through to the API POST with email: false. This silently kicks off a backend export for zero rows. Consider short-circuiting early:

Proposed fix
             try {
+                    if ( ! data || data.length === 0 && totalItems === 0 ) {
+                        toast( {
+                            type: 'info',
+                            title: __( 'No data to export.', 'dokan-lite' ),
+                        } );
+                        return;
+                    }
+
                     const shouldEmail = data && data.length < totalItems;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const tabsAdditionalContents = [
<button
type="button"
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-[#575757] hover:bg-[#7047EB] hover:text-white"
disabled={ isExporting }
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-[#575757] hover:bg-[#7047EB] hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
onClick={ async () => {
setIsExporting(true);
try {
// Minimal placeholder; backend export flow may vary.
// Attempt to hit export endpoint via same query params.
const path = addQueryArgs( 'dokan/v2/withdraw', {
const shouldEmail = data && data.length < totalItems;
// If all data is already loaded, download directly
if ( ! shouldEmail && data && data.length > 0 ) {
const csvData = data.map( ( item ) => ( {
...item,
user_id: item.user?.id,
vendor_name: item.user?.store_name,
payment_method: item.method_title,
payable: item.receivable,
date: item.created,
} ) );
directDownloadCSV( 'withdraws', WITHDRAW_CSV_HEADERS, csvData, filterArgs );
return;
const tabsAdditionalContents = [
<button
type="button"
disabled={ isExporting }
className="inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-[`#575757`] hover:bg-[`#7047EB`] hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
onClick={ async () => {
setIsExporting(true);
try {
if ( ! data || data.length === 0 && totalItems === 0 ) {
toast( {
type: 'info',
title: __( 'No data to export.', 'dokan-lite' ),
} );
return;
}
const shouldEmail = data && data.length < totalItems;
// If all data is already loaded, download directly
if ( ! shouldEmail && data && data.length > 0 ) {
const csvData = data.map( ( item ) => ( {
...item,
user_id: item.user?.id,
vendor_name: item.user?.store_name,
payment_method: item.method_title,
payable: item.receivable,
date: item.created,
} ) );
directDownloadCSV( 'withdraws', WITHDRAW_CSV_HEADERS, csvData, filterArgs );
return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/admin/dashboard/pages/withdraw/index.tsx` around lines 556 - 578, The
onClick handler in tabsAdditionalContents can fall through and call the backend
export with email: false when there are zero rows; update the handler to
short-circuit early when totalItems === 0 (or when data && data.length === 0 and
totalItems === 0) to avoid kicking off a backend export for zero rows: if no
items, reset isExporting (call setIsExporting(false)), optionally notify the
user, and return before building csvData or calling the export API; keep the
existing directDownloadCSV path (data.length > 0) and the backend POST path for
real exports unchanged.

}

if ( shouldEmail ) {
toast( {
type: 'info',
title: __( 'Your withdraw Report will be emailed to you.', 'dokan-lite' ),
} );
}

// Otherwise, use email delivery via API
const reportArgs = {
...view,
...filterArgs,
is_export: true,
} );
const res = await apiFetch( { path } );
if ( res && res.url ) {
window.location.assign( res.url as string );
};

const exportResponse = await apiFetch({
path: '/dokan/v1/reports/withdraws/export',
method: 'POST',
data: {
report_args: reportArgs,
email: shouldEmail,
},
});

if ( ! exportResponse.export_id ) {
throw new Error(exportResponse.data.message);
}
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( 'Export failed or not supported yet', e );

} catch (e) {
throw new Error( __('Export failed. Please try again.', 'dokan-lite') );
} finally {
setIsExporting(false);
}
Comment on lines 561 to 611
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Export handler has multiple error-handling issues and a dead-end code path.

  1. Unhandled re-throw (Line 618): The catch block re-throws a new Error, but since this is an async onClick handler, the re-thrown error becomes an unhandled promise rejection. The user sees no feedback. Either show a toast/alert here instead of re-throwing, or remove the re-throw.

  2. Potential TypeError (Line 614): If the API response lacks export_id, accessing exportResponse.data.message may throw a TypeError if exportResponse.data is also undefined.

  3. No polling after backend export (Lines 604–615): When shouldEmail is false but the direct-download path at Line 581 is skipped (e.g., empty data), the code obtains an export_id from the API but never calls pollExportStatus to retrieve the file. The handleExportWithdraws function (Line 713) already implements this pattern — consider reusing it or adding polling here.

Proposed fix for error handling
-                } catch (e) {
-                    throw new Error( __('Export failed. Please try again.', 'dokan-lite') );
+                } catch (e) {
+                    toast( {
+                        type: 'error',
+                        title: __( 'Export failed. Please try again.', 'dokan-lite' ),
+                    } );
                 } finally {
                     setIsExporting(false);
                 }
                     if ( ! exportResponse.export_id ) {
-                        throw new Error(exportResponse.data.message);
+                        throw new Error(exportResponse?.data?.message || __('Export failed.', 'dokan-lite'));
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/admin/dashboard/pages/withdraw/index.tsx` around lines 574 - 621, The
export onClick handler mishandles errors and misses the backend-polling path:
update the async handler so the catch block shows a user-facing toast (or other
UI error) instead of re-throwing; when checking exportResponse ensure you safely
read the error message (e.g., fallback to exportResponse?.data?.message ||
exportResponse?.message || a default) before throwing; and after a successful
POST that returns exportResponse.export_id (and when shouldEmail is false and
directDownloadCSV wasn't used) call the existing pollExportStatus helper (or
reuse handleExportWithdraws flow) to poll/download the CSV, ensuring
setIsExporting(false) still runs in finally and toasts are used for
success/failure notifications.

} }
}}
>
<ArrowDown size={ 16 } />
{ __( 'Export', 'dokan-lite' ) }
{ isExporting ? (
<>
<Loader2 className="animate-spin" size={16} />
{ __( 'Exporting...', 'dokan-lite' ) }
</>
) : (
<>
<ArrowDown size={16} />
{ __( 'Export', 'dokan-lite' ) }
</>
) }
</button>,
];

Expand Down
69 changes: 69 additions & 0 deletions src/utils/download-csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Generic CSV Download Utility
*
* Provides a simple, reusable way to directly download data as a CSV file
* in the browser. Can be used by any component across dokan-lite or dokan-pro.
*
* @example
* import { directDownloadCSV } from '@utils/download-csv';
*
* const headers = [
* { key: 'id', label: 'ID' },
* { key: 'name', label: 'Name' },
* ];
* const data = [
* { id: 1, name: 'John' },
* { id: 2, name: 'Jane' },
* ];
* directDownloadCSV( 'users', headers, data );
*/

import {
downloadCSVFile,
generateCSVDataFromTable,
generateCSVFileName,
} from '@woocommerce/csv-export';

/**
* Convert an array of objects into CSV-compatible row arrays,
* based on the header keys.
*
* @param {Array<Object>} data - Array of data objects.
* @param {Array<{key: string, label: string}>} headers - Header definitions (key + label).
* @return {Array<Array<{display: string, value: string | number}>>} Rows suitable for CSV generation.
*/
export const objectToCSVRows = ( data, headers ) => {
return data.map( ( item ) =>
headers.map( ( header ) => {
const value = item[ header.key ];

let stringValue = '';
if ( value !== null && value !== undefined ) {
stringValue =
typeof value === 'object'
? JSON.stringify( value )
: String( value );
}

return { display: stringValue, value: stringValue };
} )
);
};

/**
* Directly download data as a CSV file in the browser.
*
* @param {string} filename - Base name for the CSV file (without extension).
* @param {Array<{key: string, label: string}>} headers - Column header definitions.
* @param {Array<Object>} data - Array of data objects to export.
* @param {Object} [params={}] - Optional query params to include in the filename.
*/
export const directDownloadCSV = ( filename, headers, data, params = {} ) => {
const rows = objectToCSVRows( data, headers );

downloadCSVFile(
generateCSVFileName( filename, params ),
generateCSVDataFromTable( headers, rows )
);
};

12 changes: 12 additions & 0 deletions types/externals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ declare module '@woocommerce/blocks-checkout' {
export = blocksCheckout;
}

declare module '@woocommerce/csv-export' {
export function downloadCSVFile( fileName: string, content: string ): void;
export function generateCSVDataFromTable(
headers: Array< { key: string; label: string } >,
rows: Array<Array<{ display: string; value: string | number }>>
): string;
export function generateCSVFileName(
name: string,
params?: Record< string, string >
): string;
}

// Dokan global packages - mapped to global dokan object
declare module '@dokan/components' {
export const DataViews: any;
Expand Down
Loading