Skip to content
2 changes: 1 addition & 1 deletion src/components/settings/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5328,7 +5328,7 @@ const dokanSettingsSchema: SettingsElement[] = [
"dependency_key": "vendor_onboarding.vendor_setup_wizard_logo",
"dependencies": [],
"validations": [],
"variant": "file_upload",
"variant": "wp_media_upload",
"value": "",
"default": "",
"placeholder": "+ Choose File",
Expand Down
16 changes: 16 additions & 0 deletions src/components/settings/field-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
RichTextField,
GoogleAnalyticsField,
CombineInputField,
WpMediaUploadField,
WpMediaUploadMultipleField,
} from './fields';

// ============================================
Expand Down Expand Up @@ -224,6 +226,20 @@ export function FieldRenderer({
mergedElement
);

case 'wp_media_upload':
return applyFilters(
`${filterPrefix}_settings_wp_media_upload_field`,
<WpMediaUploadField {...fieldProps} />,
mergedElement
);

case 'wp_media_upload_multiple':
return applyFilters(
`${filterPrefix}_settings_wp_media_upload_multiple_field`,
<WpMediaUploadMultipleField {...fieldProps} />,
mergedElement
);

default:
// Unknown variant — consumer must handle via applyFilters
return applyFilters(
Expand Down
35 changes: 35 additions & 0 deletions src/components/settings/fields.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { WpMediaUpload, WpMediaUploadMultiple } from '../wordpress/WpMediaUpload';
import { cn } from "@/lib/utils";
import * as LucideIcons from "lucide-react";
import { FileText, Info, Eye, EyeOff, ArrowUpRight, RefreshCcw } from "lucide-react";
Expand Down Expand Up @@ -931,6 +932,40 @@ export function CombineInputField({ element, onChange, ...rest }: FieldComponent
);
}

// ============================================
// WP Media Upload Fields
// ============================================

export function WpMediaUploadField({ element, onChange, ...rest }: FieldComponentProps) {
return (
<FieldWrapper element={element} layout={element.layout ?? 'horizontal'} {...rest}>
<WpMediaUpload
value={String(element.value ?? '')}
onChange={(url) => onChange(element.dependency_key!, url)}
btnText={element.placeholder ? String(element.placeholder) : undefined}
disabled={element.disabled}
/>
</FieldWrapper>
);
}

export function WpMediaUploadMultipleField({ element, onChange, ...rest }: FieldComponentProps) {
const currentValue = Array.isArray(element.value)
? (element.value as string[])
: element.value ? [String(element.value)] : [];

return (
<FieldWrapper element={element} layout={element.layout ?? 'horizontal'} {...rest}>
<WpMediaUploadMultiple
value={currentValue}
onChange={(urls) => onChange(element.dependency_key!, urls)}
btnText={element.placeholder ? String(element.placeholder) : undefined}
disabled={element.disabled}
/>
</FieldWrapper>
);
}

// ============================================
// Fallback Field (for unknown variants)
// ============================================
Expand Down
10 changes: 9 additions & 1 deletion src/components/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FieldRenderer } from './field-renderer';
import { cn } from '@/lib/utils';
import { FileText, Info } from "lucide-react";
import { ScrollArea, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui";
import { Button } from "@/components/ui/button";
import { RawHTML } from "@wordpress/element";

// ============================================
Expand Down Expand Up @@ -137,7 +138,14 @@ export function SettingsContent({ className }: { className?: string }) {
>
{renderSaveButton
? renderSaveButton({ scopeId, dirty, hasErrors, onSave: handleSave })
: null}
: (
<Button
onClick={handleSave}
disabled={!dirty || hasErrors}
>
Save Changes
</Button>
)}
</div>
)}

Expand Down
141 changes: 141 additions & 0 deletions src/components/wordpress/WpMediaUpload.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from 'storybook/test';
import { useState } from 'react';
import { WpMediaUpload, WpMediaUploadMultiple } from './WpMediaUpload';

// ── Mock wp.media for Storybook (not available outside WordPress) ─────────────

const SAMPLE_IMAGE = 'https://placehold.co/400x400/e2e8f0/475569?text=Logo';
const SAMPLE_IMAGES = [
'https://placehold.co/400x400/e2e8f0/475569?text=Image+1',
'https://placehold.co/400x400/dbeafe/1d4ed8?text=Image+2',
'https://placehold.co/400x400/dcfce7/15803d?text=Image+3',
];

function mockWpMedia( multiple = false ) {
( window as any ).wp = {

Check warning on line 16 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
media: ( _options: object ) => {
let selectCb: (() => void) | null = null;
const urls = multiple ? SAMPLE_IMAGES.slice( 0, 2 ) : [ SAMPLE_IMAGE ];
return {
on: ( event: string, cb: () => void ) => {
if ( event === 'select' ) selectCb = cb;
},
once: ( event: string, cb: () => void ) => {
if ( event === 'select' ) selectCb = cb;
},
open: () => {
// Simulate async media selection
setTimeout( () => selectCb?.(), 300 );
},
state: () => ( {
get: ( key: string ) => {
if ( key !== 'selection' ) return null;
if ( multiple ) {
const items = urls.map( ( url ) => ( { toJSON: () => ( { url } ) } ) );
return {
map: ( fn: ( a: any ) => any ) => items.map( fn ),

Check warning on line 37 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type

Check warning on line 37 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
each: ( fn: ( a: any ) => void ) => items.forEach( fn ),

Check warning on line 38 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected any. Specify a different type
pop: () => items.pop(),
};
}
const item = { toJSON: () => ( { url: urls[0] } ) };
return {
map: ( fn: ( a: any ) => any ) => [ item ].map( fn ),
each: ( fn: ( a: any ) => void ) => [ item ].forEach( fn ),
first: () => item,
pop: () => item.toJSON(),
};
Comment on lines +42 to +48

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how media selection is consumed and whether pop() is expected to return a model (with toJSON)
# Expected:
# - If consumer calls pop().toJSON(), mock should return model objects consistently.
# - If consumer uses pop() as plain JSON, current shape may be fine.

set -euo pipefail

echo "== Locate relevant files =="
fd -i 'WpMedia*' src

echo
echo "== Inspect selection method usage (pop/first/map/each) =="
rg -nP --type=ts --type=tsx 'selection\.(pop|first|map|each)\s*\(|get\(\s*[\"\x27]selection[\"\x27]\s*\)' \
  src/lib/WpMedia.ts src/components/wordpress/WpMediaUpload.tsx src/components/wordpress/WpMediaUpload.stories.tsx

echo
echo "== Inspect toJSON call sites around media selection =="
rg -nP --type=ts --type=tsx '\.toJSON\s*\(' \
  src/lib/WpMedia.ts src/components/wordpress/WpMediaUpload.tsx src/components/wordpress/WpMediaUpload.stories.tsx

Repository: getdokan/plugin-ui

Length of output: 306


🏁 Script executed:

#!/bin/bash
# Corrected script: search without problematic file type flags

echo "== Locate WpMedia-related files =="
fd -i 'WpMedia' src

echo
echo "== Check selection method usage in production code =="
rg -A 3 -B 3 'selection\.(pop|first|map|each)' src/

echo
echo "== Check toJSON usage patterns =="
rg -A 2 -B 2 'toJSON' src/

echo
echo "== Inspect WpMediaUpload.stories.tsx lines 35-50 (context around the issue) =="
sed -n '35,50p' src/components/wordpress/WpMediaUpload.stories.tsx

Repository: getdokan/plugin-ui

Length of output: 4760


Align mock selection.pop() return shape between single and multiple modes

Line 39 returns an item object (with toJSON method) in multiple mode, while line 47 returns a plain serialized object in single mode. Though the current consumers in WpMedia.ts and WpMediaUpload.tsx only use map() and each(), this inconsistency can cause regressions if future code calls pop() expecting a model object with a toJSON method. Normalize both modes to return item objects consistently.

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

In `@src/components/wordpress/WpMediaUpload.stories.tsx` around lines 42 - 48, The
mock selection in WpMediaUpload.stories.tsx returns different shapes for pop()
between multiple and single modes; change the single-mode pop to return the same
item model (the object with toJSON method) instead of a plain serialized object
so selection.pop() consistently returns the model with toJSON; update the pop
implementation in the story (the block that defines item and returns { map,
each, first, pop }) to return item (not item.toJSON()), ensuring compatibility
with consumers like WpMedia.ts and WpMediaUpload.tsx that may expect a model
object.

},
} ),
};
},
};
}

// ── Single ────────────────────────────────────────────────────────────────────

const SingleMeta = {
title: 'WordPress/WpMediaUpload',
component: WpMediaUpload,
parameters: { layout: 'centered' },
tags: ['autodocs'],
args: {
onChange: fn(),
},
argTypes: {
value: { control: 'text' },
btnText: { control: 'text' },
disabled: { control: 'boolean' },
},
decorators: [
( Story: any ) => {
mockWpMedia( false );
return <Story />;
},
],
} satisfies Meta<typeof WpMediaUpload>;

export default SingleMeta;

type SingleStory = StoryObj<typeof SingleMeta>;

export const Empty: SingleStory = {
args: {
btnText: 'Upload Logo',
},
};

export const WithPreview: SingleStory = {
args: {
value: SAMPLE_IMAGE,
btnText: 'Upload Logo',
},
};

export const Disabled: SingleStory = {
args: {
value: SAMPLE_IMAGE,
btnText: 'Upload Logo',
disabled: true,
},
};

/** Interactive — click "Upload Image" to simulate a media library selection. */
export const Interactive: SingleStory = {
render: ( args ) => {
const [ value, setValue ] = useState( args.value ?? '' );

Check failure on line 107 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
return <WpMediaUpload { ...args } value={ value } onChange={ setValue } />;
},
args: {},
};

// ── Multiple ──────────────────────────────────────────────────────────────────

export const Multiple: StoryObj<typeof WpMediaUploadMultiple> = {
render: () => {
const [ values, setValues ] = useState<string[]>( [] );

Check failure on line 117 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
mockWpMedia( true );
return (
<WpMediaUploadMultiple
value={ values }
onChange={ setValues }
btnText="Add Images"
/>
);
},
};

export const MultipleWithExisting: StoryObj<typeof WpMediaUploadMultiple> = {
render: () => {
const [ values, setValues ] = useState<string[]>( SAMPLE_IMAGES );

Check failure on line 131 in src/components/wordpress/WpMediaUpload.stories.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
mockWpMedia( true );
return (
<WpMediaUploadMultiple
value={ values }
onChange={ setValues }
btnText="Add Images"
/>
);
},
};
144 changes: 144 additions & 0 deletions src/components/wordpress/WpMediaUpload.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React from 'react';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import wpMedia from '@/lib/WpMedia';

// ─── Single ───────────────────────────────────────────────────────────────────

export interface WpMediaUploadProps {
value?: string;
onChange: ( url: string ) => void;
btnText?: string;
className?: string;
disabled?: boolean;
}

export function WpMediaUpload( {
value,
onChange,
btnText = 'Upload Image',
className,
disabled,
}: WpMediaUploadProps ) {
const handleUpload = () => {
wpMedia( ( file ) => {
const url = Array.isArray( file ) ? ( file[0]?.url ?? '' ) : ( file as { url: string } ).url;
onChange( url );
} );
};

return (
<div className={ cn( 'flex flex-col items-end gap-2', className ) }>
{ value && (
<div className="relative inline-flex">
<img
src={ value }
alt=""
className="h-16 w-16 rounded-md border border-border bg-muted object-contain p-1"
/>
<button
type="button"
disabled={ disabled }
title="Remove"
onClick={ () => onChange( '' ) }
className="absolute -right-2 -top-2 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={ 10 } strokeWidth={ 3 } />
</button>
</div>
) }
<Button
type="button"
variant="outline"
size="sm"
disabled={ disabled }
onClick={ handleUpload }
className="gap-1.5"
>
<Upload size={ 14 } />
{ value ? 'Change' : btnText }
</Button>
</div>
);
}

// ─── Multiple ─────────────────────────────────────────────────────────────────

export interface WpMediaUploadMultipleProps {
value?: string[];
onChange: ( urls: string[] ) => void;
btnText?: string;
className?: string;
disabled?: boolean;
}

export function WpMediaUploadMultiple( {
value = [],
onChange,
btnText = 'Add Images',
className,
disabled,
}: WpMediaUploadMultipleProps ) {
const handleUpload = () => {
// @ts-expect-error wp.media is not defined in the global scope
const frame = wp.media( {
title: 'Select Images',
button: { text: 'Add' },
multiple: true,
} );

frame.once( 'select', () => {
const selection = frame.state().get( 'selection' );
const newUrls: string[] = [];
selection.each( ( attachment: { toJSON: () => { url: string } } ) => {
newUrls.push( attachment.toJSON().url );
} );
onChange( [ ...value, ...newUrls ] );
} );

frame.open();
};

const remove = ( index: number ) => {
onChange( value.filter( ( _, i ) => i !== index ) );
};

return (
<div className={ cn( 'flex flex-col items-end gap-2', className ) }>
{ value.length > 0 && (
<div className="flex flex-wrap justify-end gap-2">
{ value.map( ( url, i ) => (
<div key={ i } className="relative inline-flex">
<img
src={ url }
alt=""
className="h-14 w-14 rounded-md border border-border bg-muted object-contain p-1"
/>
<button
type="button"
disabled={ disabled }
title="Remove"
onClick={ () => remove( i ) }
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={ 10 } strokeWidth={ 3 } />
</button>
</div>
) ) }
Comment on lines +111 to +128

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 | 🟡 Minor

Avoid using array index as React key when items can be removed.

Using key={i} causes React to incorrectly preserve state when items are removed from the middle of the array. Use a stable identifier like the URL itself.

🔧 Proposed fix
-                    { value.map( ( url, i ) => (
-                        <div key={ i } className="relative inline-flex">
+                    { value.map( ( url ) => (
+                        <div key={ url } className="relative inline-flex">
                             <img
                                 src={ url }
                                 alt=""
                                 className="h-14 w-14 rounded-md border border-border bg-muted object-contain p-1"
                             />
                             <button
                                 type="button"
                                 disabled={ disabled }
                                 title="Remove"
-                                onClick={ () => remove( i ) }
+                                onClick={ () => onChange( value.filter( ( v ) => v !== url ) ) }
                                 className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
                             >
                                 <X size={ 10 } strokeWidth={ 3 } />
                             </button>
                         </div>
                     ) ) }

Note: If duplicate URLs are valid, consider using url + index or generating unique IDs when adding images.

📝 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
{ value.map( ( url, i ) => (
<div key={ i } className="relative inline-flex">
<img
src={ url }
alt=""
className="h-14 w-14 rounded-md border border-border bg-muted object-contain p-1"
/>
<button
type="button"
disabled={ disabled }
title="Remove"
onClick={ () => remove( i ) }
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={ 10 } strokeWidth={ 3 } />
</button>
</div>
) ) }
{ value.map( ( url ) => (
<div key={ url } className="relative inline-flex">
<img
src={ url }
alt=""
className="h-14 w-14 rounded-md border border-border bg-muted object-contain p-1"
/>
<button
type="button"
disabled={ disabled }
title="Remove"
onClick={ () => onChange( value.filter( ( v ) => v !== url ) ) }
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={ 10 } strokeWidth={ 3 } />
</button>
</div>
) ) }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/wordpress/WpMediaUpload.tsx` around lines 111 - 128, The map
callback in WpMediaUpload.tsx uses key={i}, which can break React reconciliation
when items are removed; change the key to a stable identifier such as key={url}
in the value.map( (url, i) => ...) JSX for the div containing the <img> and
remove button (or use a composite key like `${url}-${i}` if duplicate URLs are
allowed), or alternatively generate and store unique IDs when images are added
and use that ID as the key to ensure stable keys for the remove(i) operation.

</div>
) }
<Button
type="button"
variant="outline"
size="sm"
disabled={ disabled }
onClick={ handleUpload }
className="gap-1.5"
>
<Upload size={ 14 } />
{ btnText }
</Button>
</div>
);
}
Loading
Loading