diff --git a/.distignore b/.distignore
index e76a78f8d..793bbc9a9 100755
--- a/.distignore
+++ b/.distignore
@@ -32,4 +32,7 @@ phpstan-baseline.neon
AGENTS.md
.wp-env.json
.claude
-
+skills
+classes/Visualizer/Gutenberg/src
+classes/Visualizer/ChartBuilder/src
+classes/Visualizer/D3Renderer/src
diff --git a/.github/workflows/build-dev-artifacts.yml b/.github/workflows/build-dev-artifacts.yml
index b13dae1b0..8a6a1398a 100755
--- a/.github/workflows/build-dev-artifacts.yml
+++ b/.github/workflows/build-dev-artifacts.yml
@@ -28,7 +28,16 @@ jobs:
run: |
composer install --no-dev --prefer-dist --no-progress
- name: Create zip
- run: npm run dist
+ run: |
+ npm ci
+ npm run gutenberg:build
+ npm run chartbuilder:build
+ npm run d3renderer:build
+ CURRENT_VERSION=$(node -p -e "require('./package.json').version")
+ COMMIT_HASH=$(git rev-parse --short HEAD)
+ DEV_VERSION="${CURRENT_VERSION}-dev.${COMMIT_HASH}"
+ npm run grunt version::${DEV_VERSION}
+ npm run dist
- name: Retrieve branch name
id: retrieve-branch-name
run: echo "::set-output name=branch_name::$(REF=${GITHUB_HEAD_REF:-$GITHUB_REF} && echo ${REF#refs/heads/} | sed 's/\//-/g')"
diff --git a/.github/workflows/deploy-wporg.yml b/.github/workflows/deploy-wporg.yml
index 2eba591cb..3283b8e9a 100644
--- a/.github/workflows/deploy-wporg.yml
+++ b/.github/workflows/deploy-wporg.yml
@@ -15,6 +15,9 @@ jobs:
- name: Build
run: |
npm ci
+ npm run gutenberg:build
+ npm run chartbuilder:build
+ npm run d3renderer:build
composer install --no-dev --prefer-dist --no-progress --no-suggest
- name: WordPress Plugin Deploy
uses: 10up/action-wordpress-plugin-deploy@master
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index c44e03ab0..33c6c874a 100755
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -24,6 +24,9 @@ jobs:
- name: Install npm deps
run: |
npm ci
+ npm run gutenberg:build
+ npm run chartbuilder:build
+ npm run d3renderer:build
npm install -g playwright-cli
npx playwright install --with-deps chromium
- name: Install composer deps
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
new file mode 100644
index 000000000..b8c2c5f8e
--- /dev/null
+++ b/.github/workflows/translations.yml
@@ -0,0 +1,45 @@
+name: Translations Diff
+
+on:
+ pull_request_review:
+ pull_request:
+ types: [opened, edited, synchronize, ready_for_review]
+ branches:
+ - development
+ - master
+
+jobs:
+ translation:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Base Branch
+ uses: actions/checkout@master
+ with:
+ ref: ${{ github.base_ref }}
+ path: visualizer-base
+ - name: Setup node 22
+ uses: actions/setup-node@v6
+ with:
+ node-version: 22.x
+ - name: Checkout PR Branch (Head)
+ uses: actions/checkout@master
+ with:
+ path: visualizer-head
+ - name: Build POT for PR Branch
+ run: |
+ chmod +x ./visualizer-head/bin/make-pot.sh
+ ./visualizer-head/bin/make-pot.sh ./visualizer-head ./visualizer-head/languages/visualizer.pot
+ ls ./visualizer-head/languages/
+ - name: Build POT for Base Branch
+ run: |
+ ./visualizer-head/bin/make-pot.sh ./visualizer-base ./visualizer-base/languages/visualizer.pot
+ ls ./visualizer-base/languages/
+ - name: Compare POT files
+ uses: Codeinwp/action-i18n-string-reviewer@main
+ with:
+ fail-on-changes: "false"
+ openrouter-key: ${{ secrets.OPEN_ROUTER_API_KEY }}
+ openrouter-model: "google/gemini-2.5-flash"
+ base-pot-file: "visualizer-base/languages/visualizer.pot"
+ target-pot-file: "visualizer-head/languages/visualizer.pot"
+ github-token: ${{ secrets.BOT_TOKEN }}
diff --git a/.gitignore b/.gitignore
index c4c9a71c3..7ff179d3f 100755
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ vendor
.DS_Store
artifacts
.phpunit.result.cache
+classes/Visualizer/Gutenberg/build
+classes/Visualizer/ChartBuilder/build
+classes/Visualizer/D3Renderer/build
diff --git a/.wp-env.json b/.wp-env.json
index d4d36aa0b..86dd6cef1 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -1,5 +1,5 @@
{
- "core": "WordPress/WordPress#6.5.0",
+ "core": null,
"phpVersion": "7.4",
"plugins": ["."],
"themes": [],
diff --git a/AGENTS.md b/AGENTS.md
index 5e9877b11..dff1caaa8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,14 +33,9 @@ npm run build # Production build → build/block.js
npm run dev # Watch mode for development
```
-### E2E Tests & Environment
-```bash
-npm install # Install root-level JS dependencies
-npm run test:env:start # Start wp-env WordPress environment
-npm run test:env:stop # Stop wp-env
-npm run test:e2e:playwright # Run Playwright E2E tests
-npm run test:e2e:playwright:debug # Playwright UI debug mode
-```
+### E2E & PHPUnit Tests
+
+> Skill files for running tests are in [`skills/`](skills/): use `skills/e2e.md` for E2E and `skills/unit.md` for PHPUnit.
---
diff --git a/bin/cli-setup.sh b/bin/cli-setup.sh
index 550873bcc..502cfc78a 100755
--- a/bin/cli-setup.sh
+++ b/bin/cli-setup.sh
@@ -4,6 +4,7 @@ wp --allow-root core install --url="http://localhost:8889" --admin_user="admin"
mkdir -p /var/www/html/wp-content/uploads
chmod -R 777 /var/www/html/wp-content/uploads/*
wp --allow-root plugin install classic-editor
+wp --allow-root plugin install elementor
wp --allow-root theme install twentytwentyone
# activate
diff --git a/bin/make-pot.sh b/bin/make-pot.sh
new file mode 100644
index 000000000..af19c5d73
--- /dev/null
+++ b/bin/make-pot.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Script to generate POT file via Docker
+# Usage: ./bin/make-pot.sh [plugin-path] [destination-path]
+
+# Set defaults
+PLUGIN_PATH="${1:-.}"
+DESTINATION="${2:-.}"
+
+# Resolve absolute paths
+PLUGIN_PATH="$(cd "$PLUGIN_PATH" 2>/dev/null && pwd)" || {
+ echo "Error: Plugin path '$1' does not exist"
+ exit 1
+}
+
+DESTINATION="$(cd "$(dirname "$DESTINATION")" 2>/dev/null && pwd)/$(basename "$DESTINATION")" || {
+ echo "Error: Unable to resolve destination path"
+ exit 1
+}
+
+# Extract destination filename and directory
+DEST_DIR="$(dirname "$DESTINATION")"
+DEST_FILE="$(basename "$DESTINATION")"
+
+# Ensure destination directory exists
+mkdir -p "$DEST_DIR"
+
+echo "Generating POT file..."
+echo "Plugin Path: $PLUGIN_PATH"
+echo "Destination: $DESTINATION"
+echo ""
+
+# Run Docker container with wp-cli to generate POT
+docker run --user root --rm \
+ --volume "$PLUGIN_PATH:/var/www/html/plugin" \
+ wordpress:cli \
+ bash -c 'php -d memory_limit=512M "$(which wp)" --version --allow-root && wp i18n make-pot plugin ./plugin/languages/'"$DEST_FILE"' --include=admin,includes,libs,assets,views --allow-root --domain=anti-spam'
+
+# Check if the file was created inside the container
+if [ $? -eq 0 ]; then
+ echo ""
+ echo "✓ POT file successfully generated at: $DESTINATION"
+else
+ echo ""
+ echo "✗ Error generating POT file"
+ exit 1
+fi
diff --git a/classes/Visualizer/ChartBuilder/src/AIBuilder/DataSource.js b/classes/Visualizer/ChartBuilder/src/AIBuilder/DataSource.js
new file mode 100644
index 000000000..b7b6ea6d6
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/AIBuilder/DataSource.js
@@ -0,0 +1,664 @@
+/**
+ * DataSource — Manual | File | URL (CSV/XLSX only)
+ */
+import { useState, useRef, useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Box, Flex, Stack, Text } from '@chakra-ui/react';
+import { HotTable } from '@handsontable/react';
+import { uploadCsvString, uploadFile, uploadFileUrl } from './api';
+
+// ── Warm neutral palette ──────────────────────────────────────────────────────
+const C = {
+ bg: '#f6f7f7',
+ sidebar: '#f0f0f1',
+ border: '#dcdcde',
+ dark: '#1d2327',
+ gray1: '#646970',
+ gray2: '#8c8f94',
+ gray3: '#c3c4c7',
+ dim: '#f0f0f1',
+ hover: '#f6f7f7',
+};
+
+// ── Field atoms ───────────────────────────────────────────────────────────────
+
+const FieldHint = ( { children } ) => (
+ { children }
+);
+
+const FieldLabel = ( { children } ) => (
+
+ { children }
+
+);
+
+const inputSx = {
+ '&::placeholder': { color: '#C4C2BC' },
+ '&:focus': { borderColor: '#1C1C1E', boxShadow: '0 0 0 3px rgba(28,28,30,0.07)', outline: 'none' },
+};
+
+const FieldInput = ( props ) => (
+
+);
+
+// ── Source icons ──────────────────────────────────────────────────────────────
+
+function IconManual( { active } ) {
+ const c = active ? '#fff' : C.gray1;
+ return (
+
+ );
+}
+
+function IconSheet( { active } ) {
+ const c = active ? '#fff' : C.gray1;
+ return (
+
+ );
+}
+
+function IconFile( { active } ) {
+ const c = active ? '#fff' : C.gray1;
+ return (
+
+ );
+}
+
+function IconUrl( { active } ) {
+ const c = active ? '#fff' : C.gray1;
+ return (
+
+ );
+}
+
+const SOURCES = [
+ { id: 'manual', Icon: IconManual, label: __( 'Manual', 'visualizer' ) },
+ { id: 'sheet', Icon: IconSheet, label: __( 'Spreadsheet', 'visualizer' ) },
+ { id: 'csv_file', Icon: IconFile, label: __( 'File', 'visualizer' ) },
+ { id: 'url', Icon: IconUrl, label: __( 'URL', 'visualizer' ) },
+];
+
+const SAMPLE_CSV = `Task,Hours per Day\nstring,number\nWork,11\nEat,2\nCommute,2\nWatch TV,2\nSleep,7`;
+const PLACEHOLDER_CSV = `Month,Revenue,Costs\nstring,number,number\nJan,45000,32000\nFeb,52000,38000`;
+
+export default function DataSource( { chartId, uploadNonce, onDataReady, dataLoaded, initialCsvText = '', disabled = false, dataWarning = null, onClearWarning = null } ) {
+ const isPro = !! window?.vizAIBuilder?.isPro;
+ const [ source, setSource ] = useState( 'manual' );
+ const [ loading, setLoading ] = useState( false );
+ const [ error, setError ] = useState( null );
+
+ // Manual
+ const [ csvText, setCsvText ] = useState( '' );
+ const [ sheetRows, setSheetRows ] = useState( [] );
+ const sheetEditRef = useRef( false );
+ const sheetHotRef = useRef( null );
+
+ // File
+ const [ file, setFile ] = useState( null );
+ const fileInputRef = useRef( null );
+ const skipUploadRef = useRef( false );
+
+ // URL
+ const [ fileUrl, setFileUrl ] = useState( '' );
+ const lastCsvRef = useRef( null );
+
+ function parseCsv( text ) {
+ const lines = text.split( /\r?\n/ ).map( ( l ) => l.trim() ).filter( Boolean );
+ if ( lines.length < 3 ) return null;
+ const header = lines[ 0 ];
+ const types = lines[ 1 ];
+ const data = lines.slice( 2 ).join( '\n' );
+ return { header, types, data };
+ }
+
+ function csvToGrid( text ) {
+ const lines = text.split( /\r?\n/ ).filter( ( l ) => l.length );
+ if ( lines.length === 0 ) {
+ return [
+ [ 'Column 1', 'Column 2' ],
+ [ 'string', 'number' ],
+ [ '', '' ],
+ ];
+ }
+ const rows = lines.map( ( line ) => line.split( ',' ) );
+ const maxCols = Math.max( ...rows.map( ( r ) => r.length ) );
+ return rows.map( ( r ) => [ ...r, ...Array( Math.max( 0, maxCols - r.length ) ).fill( '' ) ] );
+ }
+
+ function gridToCsv( rows ) {
+ return rows.map( ( r ) => r.join( ',' ) ).join( '\n' );
+ }
+
+ function cleanGrid( rows ) {
+ if ( ! rows || ! rows.length ) return rows;
+ const isEmpty = ( value ) => value === null || value === undefined || String( value ).trim() === '';
+ let lastColIndex = -1;
+ rows.forEach( ( row ) => {
+ row.forEach( ( value, index ) => {
+ if ( ! isEmpty( value ) ) {
+ lastColIndex = Math.max( lastColIndex, index );
+ }
+ } );
+ } );
+ lastColIndex = Math.max( lastColIndex, 1 );
+ let trimmed = rows.map( ( row ) => row.slice( 0, lastColIndex + 1 ) );
+
+ let lastDataRow = -1;
+ for ( let i = 2; i < trimmed.length; i++ ) {
+ if ( trimmed[ i ].some( ( value ) => ! isEmpty( value ) ) ) {
+ lastDataRow = i;
+ }
+ }
+ const headerRows = trimmed.slice( 0, 2 );
+ if ( lastDataRow >= 2 ) {
+ trimmed = headerRows.concat( trimmed.slice( 2, lastDataRow + 1 ) );
+ } else {
+ trimmed = headerRows.concat( [ Array( lastColIndex + 1 ).fill( '' ) ] );
+ }
+ return trimmed;
+ }
+
+ function buildCsv( series = [], data = [] ) {
+ if ( ! series.length ) return '';
+ const labels = series.map( ( s ) => s.label ).join( ',' );
+ const types = series.map( ( s ) => s.type || 'string' ).join( ',' );
+ const rows = ( data || [] ).map( ( row ) => row.join( ',' ) );
+ return [ labels, types, ...rows ].join( '\n' );
+ }
+
+ function switchSource( id ) {
+ if ( id === 'manual' && source === 'sheet' ) {
+ const cleaned = cleanGrid( sheetRows );
+ skipUploadRef.current = true;
+ setSheetRows( cleaned );
+ setCsvText( gridToCsv( cleaned ) );
+ }
+ setSource( id );
+ setError( null );
+ if ( onClearWarning ) onClearWarning();
+ }
+
+ // ── Auto-load effects ─────────────────────────────────────────────────────
+
+ // Populate manual textarea from initialCsvText (edit mode), without triggering upload.
+ useEffect( () => {
+ if ( ! initialCsvText ) return;
+ skipUploadRef.current = true;
+ lastCsvRef.current = initialCsvText.trim();
+ setCsvText( initialCsvText );
+ }, [ initialCsvText ] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Manual CSV: debounce 700 ms — uploads whenever the full CSV text changes.
+ useEffect( () => {
+ if ( disabled ) return;
+ if ( ( source !== 'manual' && source !== 'sheet' ) || ! csvText.trim() || ! chartId ) return;
+ if ( skipUploadRef.current ) { skipUploadRef.current = false; return; }
+ const trimmed = csvText.trim();
+ if ( lastCsvRef.current === trimmed ) return;
+ const parsed = parseCsv( trimmed );
+ if ( ! parsed ) {
+ setError( __( 'CSV must include header and types rows.', 'visualizer' ) );
+ return;
+ }
+ const t = setTimeout( async () => {
+ setLoading( true ); setError( null );
+ try {
+ const r = await uploadCsvString( chartId, uploadNonce, trimmed );
+ onDataReady( r.series, r.data );
+ lastCsvRef.current = trimmed;
+ } catch ( e ) { setError( e.message ); }
+ finally { setLoading( false ); }
+ }, 700 );
+ return () => clearTimeout( t );
+ }, [ source, csvText ] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // File and URL import are manual (button-triggered).
+ async function handleImportFile() {
+ if ( disabled || ! file || ! chartId ) return;
+ setLoading( true ); setError( null );
+ try {
+ const r = await uploadFile( chartId, uploadNonce, file );
+ onDataReady( r.series, r.data );
+ const csv = buildCsv( r.series, r.data );
+ if ( csv ) {
+ skipUploadRef.current = true;
+ setCsvText( csv );
+ }
+ setSource( 'manual' );
+ } catch ( e ) { setError( e.message ); }
+ finally { setLoading( false ); }
+ }
+
+ async function handleImportUrl() {
+ if ( disabled || ! fileUrl.trim() || ! chartId ) return;
+ setLoading( true ); setError( null );
+ try {
+ const r = await uploadFileUrl( chartId, uploadNonce, fileUrl.trim() );
+ onDataReady( r.series, r.data );
+ const csv = buildCsv( r.series, r.data );
+ if ( csv ) {
+ skipUploadRef.current = true;
+ setCsvText( csv );
+ }
+ setSource( 'manual' );
+ } catch ( e ) { setError( e.message ); }
+ finally { setLoading( false ); }
+ }
+
+ // Keep spreadsheet grid in sync with CSV when switching sources or external updates.
+ useEffect( () => {
+ if ( source !== 'sheet' ) return;
+ if ( sheetEditRef.current ) { sheetEditRef.current = false; return; }
+ setSheetRows( csvToGrid( csvText ) );
+ }, [ source, csvText ] );
+
+ function updateSheetCell( rowIndex, colIndex, value ) {
+ setSheetRows( ( prev ) => {
+ const next = prev.map( ( r ) => [ ...r ] );
+ if ( ! next[ rowIndex ] ) next[ rowIndex ] = [];
+ next[ rowIndex ][ colIndex ] = value;
+ sheetEditRef.current = true;
+ setCsvText( gridToCsv( next ) );
+ return next;
+ } );
+ }
+
+ function addSheetRow() {
+ setSheetRows( ( prev ) => {
+ const cols = prev[ 0 ] ? prev[ 0 ].length : 2;
+ const next = [ ...prev, Array( cols ).fill( '' ) ];
+ sheetEditRef.current = true;
+ setCsvText( gridToCsv( next ) );
+ return next;
+ } );
+ }
+
+ function addSheetCol() {
+ setSheetRows( ( prev ) => {
+ const next = prev.map( ( r ) => [ ...r, '' ] );
+ sheetEditRef.current = true;
+ setCsvText( gridToCsv( next ) );
+ return next;
+ } );
+ }
+
+ return (
+
+
+ { /* ── Source tabs ───────────────────────────────────────────── */ }
+
+ { SOURCES.map( ( s ) => {
+ const selected = source === s.id;
+ return (
+ switchSource( s.id ) }
+ transition="all 0.15s"
+ >
+
+
+
+
+ { s.label }
+
+
+ );
+ } ) }
+
+
+ { ! isPro && source !== 'manual' && (
+
+
+ { __( 'Unlock advanced data sources', 'visualizer' ) }
+
+
+ { __( 'Spreadsheet, File, and URL imports are available in Pro.', 'visualizer' ) }
+
+
+ { __( '• Spreadsheet editor', 'visualizer' ) }
+ { __( '• CSV & XLSX upload', 'visualizer' ) }
+ { __( '• Remote URL import', 'visualizer' ) }
+
+
+
+ { __( 'Upgrade to Pro', 'visualizer' ) }
+
+
+ { __( 'Cancel anytime', 'visualizer' ) }
+
+
+
+ ) }
+
+ { /* ── 1. Manual ───────────────────────────────────────────────── */ }
+ { source === 'manual' && (
+
+
+
+ { __( 'CSV data', 'visualizer' ) }
+
+
+ { __( 'Row 1: labels · Row 2: types', 'visualizer' ) }
+ setCsvText( SAMPLE_CSV ) }
+ disabled={ disabled }
+ opacity={ disabled ? 0.6 : 1 }
+ style={ { cursor: disabled ? 'not-allowed' : 'pointer' } }
+ >
+ { __( 'Load sample', 'visualizer' ) }
+
+
+
+ setCsvText( e.target.value ) }
+ disabled={ disabled }
+ sx={ { '&::placeholder': { color: C.gray3 } } }
+ />
+
+ ) }
+
+ { /* ── 1b. Spreadsheet ─────────────────────────────────────────── */ }
+ { source === 'sheet' && isPro && (
+
+
+
+ { __( 'Spreadsheet', 'visualizer' ) }
+
+
+ { __( 'Row 1: labels · Row 2: types', 'visualizer' ) }
+ setCsvText( SAMPLE_CSV ) }
+ disabled={ disabled }
+ opacity={ disabled ? 0.6 : 1 }
+ style={ { cursor: disabled ? 'not-allowed' : 'pointer' } }
+ >
+ { __( 'Load sample', 'visualizer' ) }
+
+
+
+
+ { sheetRows.length ? (
+ {
+ if ( ! changes || source === 'loadData' ) return;
+ changes.forEach( ( [ row, col, , value ] ) => {
+ updateSheetCell( row, col, value ?? '' );
+ } );
+ } }
+ />
+ ) : (
+
+ { __( 'Loading spreadsheet…', 'visualizer' ) }
+
+ ) }
+
+
+
+ { __( 'Add row', 'visualizer' ) }
+
+
+ { __( 'Add column', 'visualizer' ) }
+
+
+
+ ) }
+
+ { /* ── 2. File ─────────────────────────────────────────────────── */ }
+ { source === 'csv_file' && isPro && (
+ <>
+
+
+ { file ? file.name : __( 'Choose a file (.csv or .xlsx)', 'visualizer' ) }
+
+ fileInputRef.current?.click() }
+ disabled={ disabled }
+ opacity={ disabled ? 0.6 : 1 }
+ >
+ { __( 'Browse', 'visualizer' ) }
+
+
+
+
+ { __( 'Import file', 'visualizer' ) }
+
+
+ setFile( e.target.files[ 0 ] || null ) }
+ />
+ { __( 'Row 1 = column names · Row 2 = types (string / number / date / boolean)', 'visualizer' ) }
+ >
+ ) }
+
+ { /* ── 3. URL ──────────────────────────────────────────────────── */ }
+ { source === 'url' && isPro && (
+
+
+ { __( 'Remote file URL', 'visualizer' ) }
+ setFileUrl( e.target.value ) }
+ disabled={ disabled }
+ />
+ { __( 'Supports .csv and .xlsx · Google Spreadsheet share URLs accepted', 'visualizer' ) }
+
+
+
+ { __( 'Import URL', 'visualizer' ) }
+
+
+
+ ) }
+
+ { /* ── Loading / error feedback ────────────────────────────────── */ }
+ { loading && (
+
+
+ { __( 'Loading data…', 'visualizer' ) }
+
+ ) }
+ { error && (
+
+ { error }
+
+ ) }
+ { dataLoaded && ! loading && ! error && (
+
+
+ { __( 'Data loaded', 'visualizer' ) }
+
+ ) }
+ { dataWarning && (
+
+ { dataWarning }
+
+ ) }
+
+
+ );
+}
diff --git a/classes/Visualizer/ChartBuilder/src/AIBuilder/api.js b/classes/Visualizer/ChartBuilder/src/AIBuilder/api.js
new file mode 100644
index 000000000..8d8bcbf6d
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/AIBuilder/api.js
@@ -0,0 +1,158 @@
+/**
+ * AJAX helpers for the AI Chart Builder.
+ * All calls go to admin-ajax.php using the vizAIBuilder global localized by PHP.
+ */
+
+const { ajaxUrl, nonce } = window.vizAIBuilder || {};
+
+async function post( action, body, options = {} ) {
+ const { nonceOverride = null, omitEmpty = false } = options;
+ const form = new FormData();
+ form.append( 'action', action );
+ form.append( 'nonce', nonceOverride || nonce );
+ for ( const [ key, val ] of Object.entries( body ) ) {
+ if ( omitEmpty && ( val === null || val === undefined || val === '' ) ) {
+ continue;
+ }
+ form.append( key, val );
+ }
+ const res = await fetch( ajaxUrl, { method: 'POST', body: form } );
+ let json;
+ try {
+ json = await res.json();
+ } catch {
+ throw new Error( 'Server returned an unexpected response.' );
+ }
+ if ( ! json.success ) {
+ throw new Error( json.data?.message || 'Request failed.' );
+ }
+ return json.data;
+}
+
+/** Create an auto-draft chart post. Returns { chart_id, upload_nonce }. */
+export async function createChart() {
+ return post( 'visualizer-ai-create', {} );
+}
+
+/** Get the upload nonce for an existing chart (edit mode). Returns { upload_nonce }. */
+export async function getChartNonce( chartId ) {
+ return post( 'visualizer-ai-chart-nonce', { chart_id: chartId } );
+}
+
+/** Fetch chart data for edit mode. Returns { title, series, data, code }. */
+export async function fetchChart( chartId ) {
+ return post( 'visualizer-ai-fetch', { chart_id: chartId } );
+}
+
+/** Publish chart with D3 code. Returns { id, shortcode }. */
+export async function saveChart( chartId, title, code ) {
+ return post( 'visualizer-ai-save', {
+ chart_id: chartId,
+ title,
+ code,
+ } );
+}
+
+// ── Upload helpers ─────────────────────────────────────────────────────────────
+
+/** Upload pasted CSV string. Returns { series, data }. */
+export async function uploadCsvString( chartId, uploadNonce, csvData ) {
+ return post( 'visualizer-ai-upload', {
+ chart_id: chartId,
+ source_type: 'csv_string',
+ csv_data: csvData,
+ }, { nonceOverride: uploadNonce, omitEmpty: true } );
+}
+
+/** Upload a CSV or XLSX file. Returns { series, data }. */
+export async function uploadFile( chartId, uploadNonce, file ) {
+ const ext = file.name.split( '.' ).pop().toLowerCase();
+ return post( 'visualizer-ai-upload', {
+ chart_id: chartId,
+ source_type: ext === 'xlsx' ? 'xlsx_file' : 'csv_file',
+ data_file: file,
+ }, { nonceOverride: uploadNonce, omitEmpty: true } );
+}
+
+/** Upload a remote CSV/XLSX URL. Returns { series, data }. */
+export async function uploadFileUrl( chartId, uploadNonce, url, schedule = '' ) {
+ return post( 'visualizer-ai-upload', {
+ chart_id: chartId,
+ source_type: 'file_url',
+ file_url: url,
+ schedule: schedule,
+ }, { nonceOverride: uploadNonce, omitEmpty: true } );
+}
+
+/** Upload a JSON URL source. Returns { series, data }. */
+export async function uploadJsonUrl( chartId, uploadNonce, params ) {
+ const {
+ url, root = '', paging = '', method = 'GET',
+ auth = '', username = '', password = '', headers = '',
+ schedule = '',
+ } = params;
+ return post( 'visualizer-ai-upload', {
+ chart_id: chartId,
+ source_type: 'json_url',
+ json_url: url,
+ json_root: root,
+ json_paging: paging,
+ json_method: method,
+ json_auth: auth,
+ json_username: username,
+ json_password: password,
+ json_headers: headers,
+ json_schedule: schedule,
+ }, { nonceOverride: uploadNonce, omitEmpty: true } );
+}
+
+// ── AI generation helpers ──────────────────────────────────────────────────────
+
+/**
+ * Start async chart generation.
+ * Pass existingCode (string) when refining so the agent has full context of the current chart.
+ * Returns { workflow_id }.
+ */
+export async function generateChart( chartId, prompt, series, data, existingCode = null, refImageBase64 = null, refImageMime = null ) {
+ const body = {
+ chart_id: chartId,
+ prompt,
+ series: JSON.stringify( series ),
+ data: JSON.stringify( data ),
+ };
+ if ( existingCode ) {
+ body.existing_code = existingCode;
+ }
+ if ( refImageBase64 ) {
+ body.ref_image = refImageBase64;
+ body.ref_image_mime = refImageMime || 'image/jpeg';
+ }
+ return post( 'visualizer-ai-generate', body );
+}
+
+/**
+ * Poll the status of an async generation job.
+ * Returns { status, output: { spec } | null }.
+ */
+export async function pollStatus( workflowId ) {
+ return post( 'visualizer-ai-status', { workflow_id: workflowId } );
+}
+
+/** Upload a database query source. Returns { series, data }. */
+export async function uploadDbQuery( chartId, uploadNonce, query, dbParams = {} ) {
+ const {
+ host = '', port = 3306, name = '',
+ username = '', password = '', type = 'mysql',
+ } = dbParams;
+ return post( 'visualizer-ai-upload', {
+ chart_id: chartId,
+ source_type: 'db_query',
+ db_query: query,
+ db_host: host,
+ db_port: port,
+ db_name: name,
+ db_username: username,
+ db_password: password,
+ db_type: type,
+ }, { nonceOverride: uploadNonce, omitEmpty: true } );
+}
diff --git a/classes/Visualizer/ChartBuilder/src/AIBuilder/index.js b/classes/Visualizer/ChartBuilder/src/AIBuilder/index.js
new file mode 100644
index 000000000..dcbbd0cb3
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/AIBuilder/index.js
@@ -0,0 +1,979 @@
+/**
+ * AI Builder — two-column layout matching layout.html design
+ *
+ * Left : Step 1 (DataSource) + Step 2 (Describe)
+ * Right : Live preview sidebar (560px)
+ * Footer: Cancel + Generate / Publish actions
+ */
+import { useState, useEffect, useRef } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
+import * as d3 from 'd3';
+import * as topojson from 'topojson-client';
+import CodeMirror from '@uiw/react-codemirror';
+import { javascript } from '@codemirror/lang-javascript';
+import { createChart, getChartNonce, fetchChart, saveChart, generateChart, pollStatus, uploadCsvString } from './api';
+import DataSource from './DataSource';
+
+/** Convert Visualizer series + data arrays to plain objects for D3. */
+function toD3Values( series, data ) {
+ return data.map( ( row ) => {
+ const obj = {};
+ series.forEach( ( col, i ) => { obj[ col.label ] = row[ i ]; } );
+ return obj;
+ } );
+}
+
+/** Execute D3 code in a container element. Returns an error message if it fails. */
+function renderD3( code, container, series, data ) {
+ if ( ! code || ! container ) return;
+ const values = toD3Values( series, data );
+ container.innerHTML = '';
+ try {
+ // eslint-disable-next-line no-new-func
+ new Function( 'd3', 'topojson', 'container', 'data', code )( d3, topojson, container, values );
+ return null;
+ } catch ( err ) {
+ const msg = `Chart error: ${ err.message }`;
+ container.innerHTML = `
${ msg }
`;
+ return msg;
+ }
+}
+
+// ── Palette (matches ChooserModal / WP-admin style) ───────────────────────────
+const C = {
+ bg: '#f6f7f7',
+ sidebar: '#f0f0f1',
+ border: '#dcdcde',
+ dark: '#1d2327',
+ gray1: '#646970',
+ gray2: '#8c8f94',
+ gray3: '#c3c4c7',
+ dim: '#f0f0f1',
+ teal: '#39c3d2',
+};
+
+const PROMPT_PRESETS = [
+ __( 'Bar chart comparing totals by category, sorted descending', 'visualizer' ),
+ __( 'Line chart showing trend over time with markers', 'visualizer' ),
+ __( 'Stacked area chart to show part-to-whole over time', 'visualizer' ),
+ __( 'Scatter plot showing correlation between two numeric fields', 'visualizer' ),
+ __( 'Pie chart showing share by category with labels', 'visualizer' ),
+];
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/** Reconstruct a CSV string from Visualizer series/data arrays for display. */
+function buildCsvFromData( series, data ) {
+ if ( ! Array.isArray( series ) || ! series.length ) return '';
+ const labels = series.map( ( s ) => s.label ).join( ',' );
+ const types = series.map( ( s ) => s.type || 'string' ).join( ',' );
+ const rows = ( data || [] ).map( ( row ) => row.join( ',' ) );
+ return [ labels, types, ...rows ].join( '\n' );
+}
+
+// ── Step badge ────────────────────────────────────────────────────────────────
+function StepBadge( { num, active } ) {
+ return (
+
+
+ { num }
+
+
+ );
+}
+
+// ── Pill button (footer / misc) ───────────────────────────────────────────────
+function PillBtn( { children, onClick, disabled, bg = C.dark, color = 'white', border, borderColor, cursor } ) {
+ return (
+
+ { children }
+
+ );
+}
+
+export default function AIBuilder( { onClose, initialChartId = null } ) {
+ const [ chartId, setChartId ] = useState( null );
+ const [ uploadNonce, setUploadNonce ] = useState( null );
+ const [ initError, setInitError ] = useState( null );
+
+ const [ series, setSeries ] = useState( [] );
+ const [ data, setData ] = useState( [] );
+ const [ previewSeries, setPreviewSeries ] = useState( [] );
+ const [ previewData, setPreviewData ] = useState( [] );
+ const [ dataLoaded, setDataLoaded ] = useState( false );
+ const [ initialCsvText, setInitialCsvText ] = useState( '' );
+
+ const [ prompt, setPrompt ] = useState( '' );
+ const [ refImage, setRefImage ] = useState( null );
+
+ const [ code, setCode ] = useState( null );
+ const [ generating, setGenerating ] = useState( false );
+ const [ genError, setGenError ] = useState( null );
+ const [ renderError, setRenderError ] = useState( null );
+ const [ editorError, setEditorError ] = useState( null );
+ const [ dataIncompatible, setDataIncompatible ] = useState( null );
+ const [ fixing, setFixing ] = useState( false );
+ const [ editOpen, setEditOpen ] = useState( false );
+ const [ draftCode, setDraftCode ] = useState( '' );
+
+ const [ title, setTitle ] = useState( '' );
+ const [ saving, setSaving ] = useState( false );
+ const [ saveError, setSaveError ] = useState( null );
+ const [ shortcode, setShortcode ] = useState( null );
+ const [ copied, setCopied ] = useState( false );
+
+ const previewRef = useRef( null );
+ const fullscreenRef = useRef( null );
+ const fullscreenOverlayRef = useRef( null );
+ const refImageInputRef = useRef( null );
+ const [ fullScreen, setFullScreen ] = useState( false );
+ const lastSeriesKeyRef = useRef( null );
+ const fixAttemptedRef = useRef( false );
+ const codeOriginRef = useRef( 'agent' );
+ const isLocked = generating || fixing;
+ const canGenerate = !! prompt?.trim() && ! generating && ! fixing;
+
+ // Focus fullscreen overlay and enable Esc close.
+ useEffect( () => {
+ if ( ! fullScreen ) return;
+ fullscreenOverlayRef.current?.focus();
+ const onKey = ( e ) => {
+ if ( e.key === 'Escape' ) setFullScreen( false );
+ };
+ window.addEventListener( 'keydown', onKey );
+ return () => window.removeEventListener( 'keydown', onKey );
+ }, [ fullScreen ] );
+
+ // Close editor on Escape.
+ useEffect( () => {
+ if ( ! editOpen ) return;
+ const onKey = ( e ) => {
+ if ( e.key === 'Escape' ) closeEditor();
+ };
+ window.addEventListener( 'keydown', onKey );
+ return () => window.removeEventListener( 'keydown', onKey );
+ }, [ editOpen ] );
+
+ // Prevent background page scroll while editor is open.
+ useEffect( () => {
+ if ( ! editOpen ) return;
+ const { body } = document;
+ const prevOverflow = body.style.overflow;
+ body.style.overflow = 'hidden';
+ return () => { body.style.overflow = prevOverflow; };
+ }, [ editOpen ] );
+
+ useEffect( () => {
+ if ( initialChartId ) {
+ // Edit mode: reuse the existing chart and fetch its upload nonce.
+ setChartId( parseInt( initialChartId, 10 ) );
+ Promise.all( [
+ getChartNonce( initialChartId ),
+ fetchChart( initialChartId ),
+ ] )
+ .then( ( [ nonceRes, chartRes ] ) => {
+ setUploadNonce( nonceRes.upload_nonce );
+
+ const applyExisting = ( existing ) => {
+ if ( existing.title ) setTitle( existing.title );
+ if ( existing.series ) setSeries( existing.series );
+ if ( existing.data ) { setData( existing.data ); setDataLoaded( true ); }
+ if ( existing.series ) setPreviewSeries( existing.series );
+ if ( existing.data ) setPreviewData( existing.data );
+ if ( existing.series && existing.data ) {
+ const csv = buildCsvFromData( existing.series, existing.data );
+ if ( csv ) setInitialCsvText( csv );
+ }
+ if ( existing.code ) {
+ setCode( existing.code );
+ }
+ };
+
+ // Prefer localized chart if present; fall back to fetch response.
+ const chartKey = 'visualizer-' + initialChartId;
+ const existing = window.visualizer?.charts?.[ chartKey ];
+ if ( existing ) {
+ applyExisting( existing );
+ } else if ( chartRes ) {
+ applyExisting( chartRes );
+ }
+ } )
+ .catch( ( e ) => setInitError( e.message ) );
+ } else {
+ createChart()
+ .then( ( res ) => { setChartId( res.chart_id ); setUploadNonce( res.upload_nonce ); } )
+ .catch( ( e ) => setInitError( e.message ) );
+ }
+ }, [ initialChartId ] );
+
+ // Re-render preview whenever code or source data changes.
+ useEffect( () => {
+ const activeCode = editOpen ? draftCode : code;
+ if ( ! activeCode || ! previewRef.current ) return;
+ if ( ! Array.isArray( previewSeries ) || ! Array.isArray( previewData ) || previewSeries.length === 0 ) {
+ previewRef.current.innerHTML = '';
+ return;
+ }
+ const err = renderD3( activeCode, previewRef.current, previewSeries, previewData );
+ if ( editOpen ) {
+ setEditorError( err || null );
+ return;
+ }
+ if ( err ) {
+ setRenderError( err );
+ if ( codeOriginRef.current === 'agent' && ! fixing && ! fixAttemptedRef.current ) {
+ fixAttemptedRef.current = true;
+ autoFix( err );
+ }
+ } else {
+ setRenderError( null );
+ }
+ }, [ code, draftCode, editOpen, previewSeries, previewData ] );
+
+ // Fullscreen render
+ useEffect( () => {
+ if ( ! fullScreen || ! code || ! fullscreenRef.current ) return;
+ if ( ! Array.isArray( series ) || ! Array.isArray( data ) || series.length === 0 ) {
+ fullscreenRef.current.innerHTML = '';
+ return;
+ }
+
+ renderD3( code, fullscreenRef.current, series, data );
+
+ const onResize = () => renderD3( code, fullscreenRef.current, series, data );
+ window.addEventListener( 'resize', onResize );
+
+ return () => {
+ window.removeEventListener( 'resize', onResize );
+ if ( fullscreenRef.current ) fullscreenRef.current.innerHTML = '';
+ };
+ }, [ fullScreen, code, series, data ] );
+
+ function handleDataReady( newSeries, newData ) {
+ const nextKey = JSON.stringify( newSeries || [] );
+ const seriesChanged = lastSeriesKeyRef.current && lastSeriesKeyRef.current !== nextKey;
+ setSeries( newSeries );
+ setData( newData );
+ setDataLoaded( true );
+
+ if ( seriesChanged ) {
+ setDataIncompatible( __( 'The column headers changed. Please regenerate to update the chart.', 'visualizer' ) );
+ setRenderError( null );
+ } else if ( code ) {
+ const testContainer = document.createElement( 'div' );
+ const err = renderD3( code, testContainer, newSeries, newData );
+ if ( err ) {
+ setDataIncompatible( __( 'These data changes do not match the current chart. Please regenerate to update the preview.', 'visualizer' ) );
+ setRenderError( null );
+ } else {
+ setPreviewSeries( newSeries );
+ setPreviewData( newData );
+ setDataIncompatible( null );
+ }
+ } else {
+ setPreviewSeries( newSeries );
+ setPreviewData( newData );
+ setDataIncompatible( null );
+ }
+
+ setGenError( null );
+ lastSeriesKeyRef.current = nextKey;
+ }
+ // Track series signature for edit-mode preload.
+ useEffect( () => {
+ if ( series && series.length ) {
+ lastSeriesKeyRef.current = JSON.stringify( series );
+ }
+ }, [ series ] );
+
+ async function handleGenerate() {
+ if ( ! prompt?.trim() ) return;
+ setGenerating( true );
+ setGenError( null );
+ setRenderError( null );
+ setDataIncompatible( null );
+ fixAttemptedRef.current = false;
+ try {
+ // 1. Convert reference image to base64 if provided.
+ let refImageBase64 = null;
+ let refImageMime = null;
+ if ( refImage ) {
+ refImageBase64 = await new Promise( ( resolve, reject ) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve( reader.result.split( ',' )[ 1 ] );
+ reader.onerror = () => reject( new Error( __( 'Failed to read image file.', 'visualizer' ) ) );
+ reader.readAsDataURL( refImage );
+ } );
+ refImageMime = refImage.type || 'image/jpeg';
+ }
+
+ // 2. Kick off the async generation job.
+ // Pass the existing code when refining so the agent knows the current chart state.
+ const { workflow_id: workflowId } = await generateChart( chartId, prompt, series, data, code || null, refImageBase64, refImageMime );
+
+ // 3. Poll until complete or failed (max ~3 min, 2 s interval)
+ const MAX_POLLS = 90;
+ const POLL_MS = 2000;
+ let polls = 0;
+
+ while ( polls < MAX_POLLS ) {
+ await new Promise( ( r ) => setTimeout( r, POLL_MS ) );
+ polls++;
+
+ const result = await pollStatus( workflowId );
+ const status = result.status ?? result.workflowStatus;
+
+ if ( status === 'completed' ) {
+ const newCode = result.output?.code;
+ const dataCsv = result.output?.data_csv || result.output?.csv || null;
+ if ( ! newCode ) throw new Error( __( 'Generation completed but no code was returned.', 'visualizer' ) );
+ setCode( newCode );
+ codeOriginRef.current = 'agent';
+ if ( dataCsv && uploadNonce ) {
+ try {
+ const trimmedCsv = String( dataCsv ).trim();
+ const parsed = await uploadCsvString( chartId, uploadNonce, trimmedCsv );
+ setInitialCsvText( trimmedCsv );
+ handleDataReady( parsed.series, parsed.data );
+ } catch ( e ) {
+ setPreviewSeries( series );
+ setPreviewData( data );
+ setDataIncompatible( null );
+ setGenError( __( 'Chart generated, but the extracted data could not be parsed. Please verify the data or regenerate.', 'visualizer' ) );
+ }
+ } else {
+ setPreviewSeries( series );
+ setPreviewData( data );
+ setDataIncompatible( null );
+ }
+ return;
+ }
+
+ if ( status === 'failed' ) {
+ throw new Error( __( 'Chart generation failed. Please try again.', 'visualizer' ) );
+ }
+ // status === 'queued' | 'running' — keep polling
+ }
+
+ throw new Error( __( 'Generation timed out. Please try again.', 'visualizer' ) );
+ } catch ( e ) {
+ setGenError( e.message );
+ } finally {
+ setGenerating( false );
+ }
+ }
+
+ function buildFixPrompt( errorMessage ) {
+ const base = [
+ 'The D3 code you returned threw a runtime/syntax error when executed.',
+ `Error: "${ errorMessage }"`,
+ 'Please fix the D3 code to resolve the error.',
+ 'Keep the chart intent the same and only return corrected D3 code.',
+ ].join( '\n' );
+ if ( prompt?.trim() ) {
+ return `${ base }\n\nOriginal user request:\n${ prompt.trim() }`;
+ }
+ return base;
+ }
+
+ async function autoFix( errorMessage ) {
+ if ( ! chartId || ! dataLoaded || fixing ) return;
+ setFixing( true );
+ setGenError( null );
+ try {
+ const fixPrompt = buildFixPrompt( errorMessage );
+ const { workflow_id: workflowId } = await generateChart( chartId, fixPrompt, series, data, code || null, null, null );
+ const MAX_POLLS = 60;
+ const POLL_MS = 2000;
+ let polls = 0;
+
+ while ( polls < MAX_POLLS ) {
+ await new Promise( ( r ) => setTimeout( r, POLL_MS ) );
+ polls++;
+
+ const result = await pollStatus( workflowId );
+ const status = result.status ?? result.workflowStatus;
+
+ if ( status === 'completed' ) {
+ const newCode = result.output?.code;
+ if ( ! newCode ) throw new Error( __( 'Fix completed but no code was returned.', 'visualizer' ) );
+ setCode( newCode );
+ codeOriginRef.current = 'agent';
+ setRenderError( null );
+ return;
+ }
+
+ if ( status === 'failed' ) {
+ throw new Error( __( 'Fix attempt failed. Please try again.', 'visualizer' ) );
+ }
+ }
+
+ throw new Error( __( 'Fix attempt timed out. Please try again.', 'visualizer' ) );
+ } catch ( e ) {
+ setGenError( e.message );
+ } finally {
+ setFixing( false );
+ }
+ }
+
+ async function handleFixClick() {
+ if ( ! renderError ) return;
+ await autoFix( renderError );
+ }
+
+ async function handleSave() {
+ if ( ! code ) return;
+ setSaving( true );
+ setSaveError( null );
+ try {
+ const res = await saveChart( chartId, title.trim() || __( 'AI Chart', 'visualizer' ), code );
+ setShortcode( res.shortcode );
+ } catch ( e ) {
+ setSaveError( e.message );
+ } finally {
+ setSaving( false );
+ }
+ }
+
+ function handleCopy() {
+ navigator.clipboard?.writeText( shortcode ).catch( () => {} );
+ setCopied( true );
+ setTimeout( () => setCopied( false ), 2000 );
+ }
+
+ function appendPreset( text ) {
+ const next = prompt ? `${ prompt.trim() }\n${ text }` : text;
+ setPrompt( next );
+ }
+
+ function openEditor() {
+ setDraftCode( code || '' );
+ setEditorError( null );
+ setEditOpen( true );
+ }
+
+ function closeEditor() {
+ setEditOpen( false );
+ setEditorError( null );
+ }
+
+ function saveEditor() {
+ if ( ! draftCode ) return;
+ if ( dataLoaded && Array.isArray( series ) && Array.isArray( data ) && series.length ) {
+ const testContainer = document.createElement( 'div' );
+ const err = renderD3( draftCode, testContainer, series, data );
+ if ( err ) {
+ setEditorError( err );
+ return;
+ }
+ }
+ setCode( draftCode );
+ codeOriginRef.current = 'manual';
+ setPreviewSeries( series );
+ setPreviewData( data );
+ setDataIncompatible( null );
+ setEditorError( null );
+ setRenderError( null );
+ fixAttemptedRef.current = false;
+ setEditOpen( false );
+ }
+
+ // ── Init / error states ───────────────────────────────────────────────────
+
+ if ( initError ) {
+ return (
+
+
+ { initError }
+
+
+ );
+ }
+
+ if ( ! chartId ) {
+ return (
+
+
+ { __( 'Initializing…', 'visualizer' ) }
+
+ );
+ }
+
+ // ── Main layout ───────────────────────────────────────────────────────────
+
+ return (
+ <>
+ { /* ════ Body ═══════════════════════════════════════════════════════ */ }
+
+
+ { /* ── Left panel ─────────────────────────────────────────────── */ }
+
+ { editOpen ? (
+ <>
+
+
+ { __( 'Edit D3 code', 'visualizer' ) }
+
+
+ { __( 'Back', 'visualizer' ) }
+
+
+ e.stopPropagation() }
+ sx={ {
+ '.cm-editor': { height: '100%' },
+ '.cm-scroller': { height: '100%', overflow: 'auto' },
+ } }
+ >
+ setDraftCode( value ) }
+ editable={ ! isLocked }
+ basicSetup={ { lineNumbers: true } }
+ />
+
+ { editorError && (
+
+ { editorError }
+
+ ) }
+
+ { __( 'Need a refresher? Read the D3 documentation to understand selections, scales, and shapes:', 'visualizer' ) }{' '}
+
+ { __( 'd3js.org/getting-started', 'visualizer' ) }
+
+
+
+
+ { __( 'Cancel', 'visualizer' ) }
+
+
+ { __( 'Save', 'visualizer' ) }
+
+
+ >
+ ) : (
+ <>
+ { /* Step 1: Data source */ }
+
+
+
+ { __( 'Data source', 'visualizer' ) }
+
+
+
+ setDataIncompatible( null ) }
+ />
+ { /* Divider */ }
+
+
+ { /* Step 2: Describe */ }
+
+
+
+
+ { __( 'Describe your chart', 'visualizer' ) }
+
+
+
+ { /* Describe textarea box */ }
+
+ setPrompt( e.target.value ) }
+ onKeyDown={ isLocked ? undefined : ( e ) => {
+ if ( e.key === 'Enter' && ! e.shiftKey ) {
+ e.preventDefault();
+ if ( canGenerate ) handleGenerate();
+ }
+ } }
+ disabled={ isLocked }
+ display="block"
+ sx={ { '&::placeholder': { color: C.gray3 } } }
+ />
+
+ { /* Reference image button */ }
+ refImageInputRef.current?.click() }
+ >
+
+ { refImage ? refImage.name : __( 'Reference image', 'visualizer' ) }
+
+
+ { /* Generate / Regenerate button */ }
+ { ! shortcode && (
+
+ { generating ? (
+ <>{ __( 'Generating…', 'visualizer' ) }>
+ ) : fixing ? (
+ <>{ __( 'Fixing…', 'visualizer' ) }>
+ ) : code ? (
+ __( '↺ Regenerate', 'visualizer' )
+ ) : (
+ <>
+
+ { __( 'Generate', 'visualizer' ) }
+ >
+ ) }
+
+ ) }
+
+
+
+ { __( 'Press Enter to generate. Shift + Enter for a new line.', 'visualizer' ) }
+
+
+ setRefImage( e.target.files[ 0 ] || null ) }
+ />
+
+ { genError && (
+
+ { genError }
+
+ ) }
+ { /* Prompt presets */ }
+
+
+ { __( 'Presets', 'visualizer' ) }
+
+
+ { PROMPT_PRESETS.map( ( preset ) => (
+ appendPreset( preset ) }
+ >
+ { preset }
+
+ ) ) }
+
+
+ { renderError && (
+
+ { renderError }
+ { fixAttemptedRef.current && ! fixing && (
+
+
+ { __( 'Fix', 'visualizer' ) }
+
+
+ ) }
+
+ ) }
+
+ >
+ ) }
+
+
+ { /* ── Right: rebuilt preview panel ─────────────────────────────── */ }
+
+
+
+
+ { __( 'Preview', 'visualizer' ) }
+
+
+ { __( 'Live rendering based on your data and prompt', 'visualizer' ) }
+
+
+ { ! editOpen && (
+
+
+ { __( 'Edit code', 'visualizer' ) }
+
+ { code && (
+ setFullScreen( true ) }
+ >
+ { __( 'Full screen', 'visualizer' ) }
+
+ ) }
+
+ ) }
+
+
+
+ svg': { width: '100% !important', height: '100% !important', display: 'block' },
+ } }
+ >
+
+ { ! code && (
+
+
+
+ { __( 'No chart yet', 'visualizer' ) }
+
+
+ { __( 'Load data and describe your chart to get started', 'visualizer' ) }
+
+
+
+ ) }
+
+
+ { shortcode && (
+
+
+ { __( 'Shortcode', 'visualizer' ) }
+
+
+
+ { shortcode }
+
+
+ { copied ? __( 'Copied!', 'visualizer' ) : __( 'Copy', 'visualizer' ) }
+
+
+
+ ) }
+
+
+
+ { /* ════ Footer ══════════════════════════════════════════════════════ */ }
+
+ { saveError && (
+ { saveError }
+ ) }
+
+
+ { __( 'Cancel', 'visualizer' ) }
+
+
+ { code && ! shortcode && ! editOpen && (
+ <>
+
+ setTitle( e.target.value ) }
+ disabled={ isLocked }
+ px="10px" py="7px" borderRadius="8px"
+ border={ `1.5px solid ${ C.border }` }
+ fontSize="12px" color={ C.dark }
+ bg="white" outline="none"
+ sx={ { '&::placeholder': { color: C.gray3 }, '&:focus': { borderColor: C.dark } } }
+ />
+
+
+ { saving ? __( 'Publishing…', 'visualizer' ) : __( 'Publish chart', 'visualizer' ) }
+
+ >
+ ) }
+
+ { shortcode && (
+ { onClose(); window.location.reload(); } } disabled={ isLocked } bg={ C.dark }>
+ { __( 'Done', 'visualizer' ) }
+
+ ) }
+
+
+ { fullScreen && (
+ { if ( e.target === e.currentTarget ) setFullScreen( false ); } }
+ tabIndex={ -1 }
+ ref={ fullscreenOverlayRef }
+ >
+
+
+
{ __( 'Full screen preview', 'visualizer' ) }
+
+
+
+
+
+ ) }
+
+ { /* Editor now lives in the left panel, no modal */ }
+ >
+ );
+}
diff --git a/classes/Visualizer/ChartBuilder/src/ChooserModal.js b/classes/Visualizer/ChartBuilder/src/ChooserModal.js
new file mode 100644
index 000000000..2eeb7d5aa
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/ChooserModal.js
@@ -0,0 +1,110 @@
+/**
+ * ChooserModal
+ *
+ * Presents users with two options when creating a new chart:
+ * 1. Classic Builder — opens the existing iframe-based wizard
+ * 2. AI Chart Builder — opens the new D3-powered React wizard
+ */
+import { useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+function ChooserModal( { isOpen, onClassic, onAIBuilder, onClose } ) {
+ const [ rememberChoice, setRememberChoice ] = useState( false );
+
+ useEffect( () => {
+ if ( isOpen ) {
+ setRememberChoice( false );
+ }
+ }, [ isOpen ] );
+
+ if ( ! isOpen ) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ { __( 'Create a New Chart', 'visualizer' ) }
+
+
+
+ { __( 'Choose how you want to build your chart.', 'visualizer' ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { __( 'We will open your selected builder by default next time.', 'visualizer' ) }
+
+
+
+
+
+
+
+ );
+}
+
+export default ChooserModal;
diff --git a/classes/Visualizer/ChartBuilder/src/index.js b/classes/Visualizer/ChartBuilder/src/index.js
new file mode 100644
index 000000000..11a94a579
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/index.js
@@ -0,0 +1,128 @@
+/**
+ * Chart Builder — Entry point
+ *
+ * Mounts the ChooserModal + AI Builder wizard into #viz-chart-builder-root
+ * and registers window.vizOpenChartChooser for library.js to call.
+ */
+import { render, createElement, useState, useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { ChakraProvider, defaultSystem } from '@chakra-ui/react';
+import ChooserModal from './ChooserModal';
+import AIBuilder from './AIBuilder/index';
+import './style.scss';
+
+const CHOOSER_STORAGE_KEY = 'viz_chart_builder_default';
+
+function getStoredBuilderChoice() {
+ try {
+ return window.localStorage.getItem( CHOOSER_STORAGE_KEY );
+ } catch ( err ) {
+ return null;
+ }
+}
+
+function setStoredBuilderChoice( choice ) {
+ try {
+ window.localStorage.setItem( CHOOSER_STORAGE_KEY, choice );
+ } catch ( err ) {
+ // ignore storage failures
+ }
+}
+
+function ChartBuilderApp() {
+ // mode: 'hidden' | 'chooser' | 'ai-builder'
+ const [ mode, setMode ] = useState( 'hidden' );
+ const [ classicCallback, setClassicCallback ] = useState( null );
+ const [ editChartId, setEditChartId ] = useState( null );
+
+ useEffect( () => {
+ window.vizOpenChartChooser = ( cb ) => {
+ setEditChartId( null );
+ setClassicCallback( () => cb );
+ const storedChoice = getStoredBuilderChoice();
+ if ( storedChoice === 'classic' ) {
+ cb();
+ return;
+ }
+ if ( storedChoice === 'ai' ) {
+ setMode( 'ai-builder' );
+ return;
+ }
+ setMode( 'chooser' );
+ };
+ window.vizOpenAIBuilderEdit = ( chartId ) => {
+ setEditChartId( String( chartId ) );
+ setMode( 'ai-builder' );
+ };
+ window.vizOpenAIBuilderNew = () => {
+ setEditChartId( null );
+ setMode( 'ai-builder' );
+ };
+ return () => {
+ delete window.vizOpenChartChooser;
+ delete window.vizOpenAIBuilderEdit;
+ delete window.vizOpenAIBuilderNew;
+ };
+ }, [] );
+
+ function handleClassic( rememberChoice = false ) {
+ if ( rememberChoice ) {
+ setStoredBuilderChoice( 'classic' );
+ }
+ setMode( 'hidden' );
+ if ( typeof classicCallback === 'function' ) classicCallback();
+ }
+ function handleAIBuilder( rememberChoice = false ) {
+ if ( rememberChoice ) {
+ setStoredBuilderChoice( 'ai' );
+ }
+ setMode( 'ai-builder' );
+ }
+ function handleClose() { setMode( 'hidden' ); setEditChartId( null ); }
+
+ return (
+
+
+
+ { mode === 'ai-builder' && (
+
+
+
+ { /* ── Header ── */ }
+
+
+
+
+
+
+ { __( 'Visualizer', 'visualizer' ) }
+
+
+
+
+
+
+
+
+ ) }
+
+ );
+}
+
+const mountPoint = document.getElementById( 'viz-chart-builder-root' );
+if ( mountPoint ) {
+ render( createElement( ChartBuilderApp ), mountPoint );
+}
diff --git a/classes/Visualizer/ChartBuilder/src/style.scss b/classes/Visualizer/ChartBuilder/src/style.scss
new file mode 100644
index 000000000..ba968810e
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/src/style.scss
@@ -0,0 +1,357 @@
+// ─── Vega tooltip z-index fix (tooltip div is appended to body) ───────────────
+#vg-tooltip-element {
+ z-index: 100001 !important;
+}
+
+// ─── Shared overlay / modal base ─────────────────────────────────────────────
+
+%overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ z-index: 100000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+%modal {
+ position: relative;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
+}
+
+// ─── Chooser modal ────────────────────────────────────────────────────────────
+
+.viz-chooser-overlay {
+ @extend %overlay;
+}
+
+.viz-chooser-modal {
+ @extend %modal;
+ padding: 40px 36px 32px;
+ width: 580px;
+ max-width: calc(100vw - 32px);
+ text-align: center;
+}
+
+.viz-chooser-close {
+ position: absolute;
+ top: 14px;
+ right: 16px;
+ background: none;
+ border: none;
+ font-size: 18px;
+ cursor: pointer;
+ color: #666;
+ padding: 4px 8px;
+ line-height: 1;
+ &:hover { color: #000; }
+}
+
+.viz-chooser-title {
+ margin: 0 0 8px;
+ font-size: 20px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.viz-chooser-subtitle {
+ margin: 0 0 28px;
+ color: #646970;
+ font-size: 14px;
+}
+
+.viz-chooser-options {
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+ margin-bottom: 24px;
+}
+
+.viz-chooser-option {
+ position: relative;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 24px 16px 20px;
+ border: 2px solid #dcdcde;
+ border-radius: 8px;
+ background: #f6f7f7;
+ cursor: pointer;
+ transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
+ text-align: center;
+
+ &:hover, &:focus-visible {
+ border-color: #2271b1;
+ background: #f0f6fc;
+ box-shadow: 0 0 0 3px rgba(34, 113, 177, 0.15);
+ outline: none;
+ }
+
+ &--ai {
+ border-color: rgba(57, 195, 210, 0.45);
+ background: #eef9fb;
+ &:hover, &:focus-visible {
+ border-color: #39c3d2;
+ background: #e6f7fa;
+ box-shadow: 0 0 0 3px rgba(57, 195, 210, 0.18);
+ }
+ }
+}
+
+.viz-chooser-option__badge {
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ background: #39c3d2;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 2px 8px;
+ border-radius: 12px;
+}
+
+.viz-chooser-option__icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ svg { width: 24px; height: 24px; }
+
+ &--classic { background: #e0f0ff; color: #2271b1; }
+ &--ai { background: #dff6f9; color: #0b6b75; }
+}
+
+.viz-chooser-option__title {
+ font-size: 15px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.viz-chooser-option__desc {
+ font-size: 12px;
+ color: #646970;
+ line-height: 1.5;
+}
+
+.viz-chooser-remember {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 14px;
+}
+
+.viz-chooser-remember__label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ color: #1d2327;
+ cursor: pointer;
+}
+
+.viz-chooser-remember__label input {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+}
+
+.viz-chooser-remember__help {
+ margin: 0;
+ font-size: 12px;
+ color: #646970;
+}
+
+.viz-chooser-cancel {
+ background: none;
+ border: none;
+ color: #646970;
+ font-size: 13px;
+ cursor: pointer;
+ padding: 4px 8px;
+ text-decoration: underline;
+ &:hover { color: #1d2327; }
+}
+
+// ─── AI Builder overlay / modal ───────────────────────────────────────────────
+
+.viz-ai-builder-overlay {
+ @extend %overlay;
+}
+
+.viz-ai-builder-modal {
+ @extend %modal;
+ width: calc(100vw - 48px);
+ max-width: 1400px;
+ height: calc(100vh - 48px);
+ overflow: hidden;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.viz-ai-builder-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px 14px 24px;
+ border-bottom: 1px solid #dcdcde;
+ background: #fff;
+ flex-shrink: 0;
+ z-index: 1;
+}
+
+.viz-ai-builder-header__logo {
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ background: #39c3d2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.viz-ai-builder-close {
+ background: none;
+ border: none;
+ padding: 6px;
+ border-radius: 6px;
+ cursor: pointer;
+ color: #8c8f94;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s, color 0.15s;
+ &:hover { background: #f0f0f1; color: #1d2327; }
+}
+
+// ─── Fullscreen preview ─────────────────────────────────────────────────────
+
+.viz-ai-fullscreen-overlay {
+ @extend %overlay;
+ z-index: 100002;
+}
+
+.viz-ai-fullscreen-modal {
+ @extend %modal;
+ width: calc(100vw - 64px);
+ height: calc(100vh - 64px);
+ max-width: 1600px;
+ max-height: 1000px;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.viz-ai-fullscreen-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ border-bottom: 1px solid #e2e3e6;
+ background: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+.viz-ai-fullscreen-body {
+ width: 100%;
+ flex: 1;
+ min-height: 0;
+ > svg {
+ width: 100% !important;
+ height: 100% !important;
+ display: block;
+ }
+}
+
+.viz-ai-fullscreen-close {
+ background: #fff;
+ border: 1px solid #dcdcde;
+ border-radius: 6px;
+ padding: 6px 10px;
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: #1d2327;
+ &:hover { filter: brightness(0.98); }
+}
+
+// Ensure Handsontable context menu appears above modal layers.
+.viz-ai-hot {
+ .htContextMenu,
+ .htContextMenu table {
+ z-index: 100010 !important;
+ }
+}
+
+// Handsontable context menu is attached to body; keep it above overlays.
+.htContextMenu,
+.htContextMenu table {
+ z-index: 100010 !important;
+}
+
+.viz-ai-builder-header__badge {
+ background: linear-gradient(135deg, #6c5ecf, #a855f7);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ padding: 3px 9px;
+ border-radius: 20px;
+}
+
+.viz-ai-builder-header__title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: #1d2327;
+}
+
+// ─── Spinner (used in legacy classes, keep for compat) ────────────────────────
+
+.viz-spinner {
+ display: inline-block;
+ width: 18px;
+ height: 18px;
+ border: 2px solid #dcdcde;
+ border-top-color: #6c5ecf;
+ border-radius: 50%;
+ animation: viz-spin 0.7s linear infinite;
+ flex-shrink: 0;
+
+ &--sm {
+ width: 13px;
+ height: 13px;
+ }
+}
+
+@keyframes viz-spin {
+ to { transform: rotate(360deg); }
+}
+
+// ─── Shortcode code (used in legacy saved state) ──────────────────────────────
+
+.viz-shortcode-code {
+ background: #f6f7f7;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+ padding: 6px 12px;
+ font-size: 13px;
+ font-family: monospace;
+}
diff --git a/classes/Visualizer/ChartBuilder/webpack.config.js b/classes/Visualizer/ChartBuilder/webpack.config.js
new file mode 100644
index 000000000..5ce6f7cac
--- /dev/null
+++ b/classes/Visualizer/ChartBuilder/webpack.config.js
@@ -0,0 +1,9 @@
+const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
+
+module.exports = {
+ ...defaultConfig,
+ externals: {
+ ...defaultConfig.externals,
+ handsontable: 'Handsontable',
+ },
+};
diff --git a/classes/Visualizer/D3Renderer/src/index.js b/classes/Visualizer/D3Renderer/src/index.js
new file mode 100644
index 000000000..a49acbcf3
--- /dev/null
+++ b/classes/Visualizer/D3Renderer/src/index.js
@@ -0,0 +1,155 @@
+/**
+ * D3.js frontend renderer for Visualizer.
+ *
+ * Listens to the `visualizer:render:chart:start` event fired by render-facade.js.
+ * For charts with library === 'd3', retrieves the stored D3 code, converts the
+ * series/data arrays to plain objects, and executes the code via new Function.
+ */
+import * as d3 from 'd3';
+import * as topojson from 'topojson-client';
+
+/** Convert Visualizer series + data arrays to plain objects for D3. */
+function toD3Values( series, data ) {
+ if ( ! Array.isArray( series ) || ! Array.isArray( data ) ) return [];
+ return data.map( ( row ) => {
+ const obj = {};
+ series.forEach( ( col, i ) => {
+ obj[ col.label ] = row[ i ];
+ } );
+ return obj;
+ } );
+}
+
+/**
+ * Render a single D3 chart into its container element.
+ *
+ * @param {string} id - DOM element ID of the container
+ * @param {object} chart - chart entry from visualizer.charts
+ */
+function renderD3Chart( id, chart ) {
+ const container = document.getElementById( id );
+ if ( ! container ) return;
+
+ const code = typeof chart.code === 'string' ? chart.code : null;
+
+ if ( ! code ) {
+ container.innerHTML = 'No chart code found.
';
+ return;
+ }
+
+ const values = toD3Values( chart.series, chart.data );
+
+ function doRender() {
+ try {
+ // eslint-disable-next-line no-new-func
+ new Function( 'd3', 'topojson', 'container', 'data', code )( d3, topojson, container, values );
+ } catch ( err ) {
+ container.innerHTML = 'Chart render error: ' + err.message + '
';
+ }
+ }
+
+ // Double requestAnimationFrame — ensures browser has completed layout before measuring.
+ requestAnimationFrame( () => requestAnimationFrame( doRender ) );
+}
+
+function ensurePngName( name ) {
+ if ( ! name ) return 'chart.png';
+ return name.toLowerCase().endsWith( '.png' ) ? name : `${ name }.png`;
+}
+
+function downloadDataUrl( dataUrl, name ) {
+ const link = document.createElement( 'a' );
+ link.href = dataUrl;
+ link.download = ensurePngName( name );
+ document.body.appendChild( link );
+ link.click();
+ link.remove();
+}
+
+function svgToPng( svg, callback ) {
+ const rect = svg.getBoundingClientRect();
+ const width = parseFloat( svg.getAttribute( 'width' ) ) || rect.width || 800;
+ const height = parseFloat( svg.getAttribute( 'height' ) ) || rect.height || 600;
+ const clone = svg.cloneNode( true );
+ clone.setAttribute( 'width', width );
+ clone.setAttribute( 'height', height );
+ const serializer = new XMLSerializer();
+ const svgText = serializer.serializeToString( clone );
+ const svgDataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( svgText );
+
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement( 'canvas' );
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext( '2d' );
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect( 0, 0, width, height );
+ ctx.drawImage( img, 0, 0 );
+ callback( canvas.toDataURL( 'image/png' ) );
+ };
+ img.onerror = () => callback( null );
+ img.src = svgDataUrl;
+}
+
+function handleImageAction( id, name, action ) {
+ const container = document.getElementById( id );
+ if ( ! container ) return;
+
+ const canvas = container.querySelector( 'canvas' );
+ if ( canvas && typeof canvas.toDataURL === 'function' ) {
+ const img = canvas.toDataURL( 'image/png' );
+ if ( action === 'print' ) {
+ const win = window.open();
+ win.document.write( "
" );
+ win.document.close();
+ win.onload = function () { win.print(); setTimeout( win.close, 500 ); };
+ } else {
+ downloadDataUrl( img, name );
+ }
+ return;
+ }
+
+ const svg = container.querySelector( 'svg' );
+ if ( ! svg ) return;
+
+ svgToPng( svg, ( img ) => {
+ if ( ! img ) return;
+ if ( action === 'print' ) {
+ const win = window.open();
+ win.document.write( "
" );
+ win.document.close();
+ win.onload = function () { win.print(); setTimeout( win.close, 500 ); };
+ } else {
+ downloadDataUrl( img, name );
+ }
+ } );
+}
+
+( function ( $ ) {
+ $( 'body' ).on( 'visualizer:render:chart:start', function ( e, viz ) {
+ if ( ! viz.charts ) return;
+
+ // Frontend mode: a specific chart ID is provided.
+ if ( viz.id ) {
+ const chart = viz.charts[ viz.id ];
+ if ( chart && chart.library === 'd3' ) {
+ renderD3Chart( viz.id, chart );
+ }
+ return;
+ }
+
+ // Admin / batch mode: no specific ID — render all d3 charts.
+ Object.keys( viz.charts ).forEach( ( id ) => {
+ const chart = viz.charts[ id ];
+ if ( chart && chart.library === 'd3' ) {
+ renderD3Chart( id, chart );
+ }
+ } );
+ } );
+
+ $( 'body' ).on( 'visualizer:action:specificchart', function ( event, v ) {
+ if ( v.action !== 'image' && v.action !== 'print' ) return;
+ handleImageAction( v.id, v?.dataObj?.name, v.action );
+ } );
+} )( jQuery );
diff --git a/classes/Visualizer/Elementor/Widget.php b/classes/Visualizer/Elementor/Widget.php
new file mode 100644
index 000000000..99b13b218
--- /dev/null
+++ b/classes/Visualizer/Elementor/Widget.php
@@ -0,0 +1,353 @@
+ |
+// +----------------------------------------------------------------------+
+/**
+ * Elementor widget for displaying Visualizer charts.
+ *
+ * @category Visualizer
+ * @package Elementor
+ *
+ * @since 3.11.16
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Visualizer Elementor Widget
+ */
+class Visualizer_Elementor_Widget extends \Elementor\Widget_Base {
+
+ /**
+ * Get widget name.
+ *
+ * @return string Widget name.
+ */
+ public function get_name() {
+ return 'visualizer-chart';
+ }
+
+ /**
+ * Get widget title.
+ *
+ * @return string Widget title.
+ */
+ public function get_title() {
+ return esc_html__( 'Visualizer Chart', 'visualizer' );
+ }
+
+ /**
+ * Get widget icon.
+ *
+ * @return string Widget icon CSS class.
+ */
+ public function get_icon() {
+ return 'visualizer-elementor-icon';
+ }
+
+ /**
+ * Get widget categories.
+ *
+ * @return array Widget categories.
+ */
+ public function get_categories() {
+ return array( 'general' );
+ }
+
+ /**
+ * Get widget keywords.
+ *
+ * @return array Widget keywords.
+ */
+ public function get_keywords() {
+ return array( 'visualizer', 'chart', 'graph', 'table', 'data' );
+ }
+
+ /**
+ * Build the select options from all published Visualizer charts.
+ *
+ * @return array Associative array of chart ID => label.
+ */
+ private function get_chart_options() {
+ static $options_cache = null;
+ if ( null !== $options_cache ) {
+ return $options_cache;
+ }
+
+ $options = array(
+ '' => esc_html__( '— Select a chart —', 'visualizer' ),
+ );
+
+ $charts = get_posts(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish',
+ 'orderby' => 'title',
+ 'order' => 'ASC',
+ 'no_found_rows' => true,
+ )
+ );
+
+ foreach ( $charts as $chart ) {
+ $settings = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SETTINGS );
+ $title = '#' . $chart->ID;
+ if ( ! empty( $settings[0]['title'] ) ) {
+ $title = $settings[0]['title'];
+ }
+ // ChartJS stores title as an array.
+ if ( is_array( $title ) && isset( $title['text'] ) ) {
+ $title = $title['text'];
+ }
+ if ( ! empty( $settings[0]['backend-title'] ) ) {
+ $title = $settings[0]['backend-title'];
+ }
+ if ( empty( $title ) ) {
+ $title = '#' . $chart->ID;
+ }
+ $options[ $chart->ID ] = $title;
+ }
+
+ $options_cache = $options;
+ return $options_cache;
+ }
+
+ /**
+ * Register widget controls.
+ *
+ * @return void
+ */
+ protected function register_controls() {
+ $this->start_controls_section(
+ 'section_chart',
+ array(
+ 'label' => esc_html__( 'Chart', 'visualizer' ),
+ 'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
+ )
+ );
+
+ $admin_url = admin_url( 'admin.php?page=' . Visualizer_Plugin::NAME );
+ $chart_options = $this->get_chart_options();
+ $has_charts = count( $chart_options ) > 1; // More than just the placeholder option.
+
+ if ( $has_charts ) {
+ $this->add_control(
+ 'chart_id',
+ array(
+ 'label' => esc_html__( 'Select Chart', 'visualizer' ),
+ 'type' => \Elementor\Controls_Manager::SELECT,
+ 'options' => $chart_options,
+ 'default' => '',
+ )
+ );
+
+ $this->add_control(
+ 'chart_notice',
+ array(
+ 'type' => \Elementor\Controls_Manager::RAW_HTML,
+ 'raw' => sprintf(
+ /* translators: 1: opening anchor tag, 2: closing anchor tag */
+ esc_html__( 'You can create and manage your charts from the %1$sVisualizer dashboard%2$s.', 'visualizer' ),
+ '',
+ ''
+ ),
+ 'content_classes' => 'elementor-panel-alert elementor-panel-alert-info',
+ )
+ );
+ } else {
+ $this->add_control(
+ 'no_charts_notice',
+ array(
+ 'type' => \Elementor\Controls_Manager::RAW_HTML,
+ 'raw' => sprintf(
+ /* translators: 1: opening anchor tag, 2: closing anchor tag */
+ esc_html__( 'No charts found. %1$sCreate a chart%2$s in the Visualizer dashboard first.', 'visualizer' ),
+ '',
+ ''
+ ),
+ 'content_classes' => 'elementor-panel-alert elementor-panel-alert-warning',
+ )
+ );
+ }
+
+ $this->end_controls_section();
+ }
+
+ /**
+ * Render the widget output on the frontend.
+ *
+ * @return void
+ */
+ protected function render() {
+ $settings = $this->get_settings_for_display();
+ $chart_id = ! empty( $settings['chart_id'] ) ? absint( $settings['chart_id'] ) : 0;
+
+ if ( ! $chart_id ) {
+ if ( \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
+ echo '' . esc_html__( 'Please select a chart from the widget settings.', 'visualizer' ) . '
';
+ }
+ return;
+ }
+
+ // Detect Elementor edit / preview context early — needed before do_shortcode().
+ $is_editor = \Elementor\Plugin::$instance->editor->is_edit_mode() ||
+ \Elementor\Plugin::$instance->preview->is_preview_mode();
+
+ // In the editor, force lazy-loading off so the chart renders immediately in the
+ // preview iframe without requiring a user-interaction event (scroll, hover, etc.).
+ // Also suppress action buttons (edit, export, etc.) — they are meaningless inside
+ // the Elementor preview and the edit link does nothing there.
+ if ( $is_editor ) {
+ add_filter( 'visualizer_lazy_load_chart', '__return_false' );
+ add_filter( 'visualizer_pro_add_actions', '__return_empty_array' );
+ }
+
+ // Ensure visualizer-customization is registered before the shortcode enqueues
+ // visualizer-render-{library} which depends on it. wp_enqueue_scripts never fires
+ // in admin or AJAX contexts (Elementor editor / AJAX re-render), so we trigger the
+ // action manually. It is a no-op when already registered.
+ do_action( 'visualizer_enqueue_scripts' );
+
+ // Capture the shortcode output so we can parse the generated element ID.
+ $html = do_shortcode( '[visualizer id="' . $chart_id . '"]' );
+
+ if ( $is_editor ) {
+ remove_filter( 'visualizer_lazy_load_chart', '__return_false' );
+ remove_filter( 'visualizer_pro_add_actions', '__return_empty_array' );
+
+ // The shortcode enqueues visualizer-render-{library} (render-facade.js).
+ // Dequeue it so Elementor's AJAX response doesn't inject it into the preview
+ // iframe. The preview page already loads render-google.js / render-chartjs.js
+ // via elementor/preview/enqueue_scripts; injecting render-facade.js would add
+ // a second visualizer:render:chart:start trigger causing duplicate renders.
+ foreach ( wp_scripts()->queue as $handle ) {
+ if ( 0 === strpos( $handle, 'visualizer-render-' )
+ && 'visualizer-render-google-lib' !== $handle
+ && 'visualizer-render-chartjs-lib' !== $handle
+ && 'visualizer-render-datatables-lib' !== $handle ) {
+ wp_dequeue_script( $handle );
+ }
+ }
+ }
+
+ echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+
+ if ( ! $is_editor ) {
+ return;
+ }
+
+ // Extract the element ID generated by the shortcode (visualizer-{id}-{rand}).
+ if ( ! preg_match( '/\bid="(visualizer-' . $chart_id . '-\d+)"/', $html, $matches ) ) {
+ return;
+ }
+ $element_id = $matches[1];
+
+ $chart = get_post( $chart_id );
+ if ( ! $chart || Visualizer_Plugin::CPT_VISUALIZER !== $chart->post_type ) {
+ return;
+ }
+
+ $type = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, true );
+ $series = get_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, true );
+ $chart_settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true );
+ $chart_data = Visualizer_Module::get_chart_data( $chart, $type );
+
+ if ( empty( $chart_settings['height'] ) ) {
+ $chart_settings['height'] = '400';
+ }
+
+ // Read library from meta and normalise to the lowercase slugs that
+ // render-google.js / render-chartjs.js / render-datatables.js and
+ // elementor-widget-preview.js expect.
+ $library = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true );
+ $library_map = array(
+ 'GoogleCharts' => 'google',
+ 'ChartJS' => 'chartjs',
+ 'DataTable' => 'datatables',
+ );
+ if ( isset( $library_map[ $library ] ) ) {
+ $library = $library_map[ $library ];
+ } elseif ( ! $library ) {
+ $library = 'google';
+ }
+
+ $series = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SERIES, $series, $chart_id, $type );
+ $chart_settings = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $chart_settings, $chart_id, $type );
+ $chart_settings = $this->apply_custom_css_class_names( $chart_settings, $chart_id );
+
+ $chart_entry = array(
+ 'type' => $type,
+ 'series' => $series,
+ 'settings' => $chart_settings,
+ 'data' => $chart_data,
+ 'library' => $library,
+ );
+
+ // Elementor injects widget HTML via innerHTML, so ',
+ esc_attr( $element_id ),
+ wp_json_encode( $chart_entry ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ );
+ }
+
+ /**
+ * Ensure custom CSS class mappings are present in settings for preview rendering.
+ *
+ * @param array $settings Chart settings.
+ * @param int $chart_id Chart ID.
+ * @return array
+ */
+ private function apply_custom_css_class_names( $settings, $chart_id ) {
+ if ( empty( $settings['customcss'] ) || ! is_array( $settings['customcss'] ) ) {
+ return $settings;
+ }
+
+ $classes = array();
+ $id = 'visualizer-' . $chart_id;
+
+ foreach ( $settings['customcss'] as $name => $element ) {
+ if ( empty( $name ) || ! is_array( $element ) ) {
+ continue;
+ }
+ $has_properties = false;
+ foreach ( $element as $property => $value ) {
+ if ( '' !== $property && '' !== $value && null !== $value ) {
+ $has_properties = true;
+ break;
+ }
+ }
+ if ( ! $has_properties ) {
+ continue;
+ }
+ $classes[ $name ] = $id . $name;
+ }
+
+ if ( ! empty( $classes ) ) {
+ $settings['cssClassNames'] = $classes;
+ }
+
+ return $settings;
+ }
+}
diff --git a/classes/Visualizer/Gutenberg/.eslintrc b/classes/Visualizer/Gutenberg/.eslintrc
deleted file mode 100644
index c578589bd..000000000
--- a/classes/Visualizer/Gutenberg/.eslintrc
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "env": {
- "browser": true,
- "es6": true
- },
- "extends": "wordpress",
- "parserOptions": {
- "ecmaFeatures": {
- "jsx": true
- },
- "ecmaVersion": 2018,
- "sourceType": "module"
- },
- "plugins": [
- "react"
- ],
- "rules": {
- "indent": [
- "off",
- "tab"
- ],
- "linebreak-style": [
- "off",
- "unix"
- ],
- "quotes": [
- "error",
- "single"
- ],
- "semi": [
- "error",
- "always"
- ],
- "no-cond-assign": "off"
- }
-}
\ No newline at end of file
diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php
index 045819b54..57db80dde 100644
--- a/classes/Visualizer/Gutenberg/Block.php
+++ b/classes/Visualizer/Gutenberg/Block.php
@@ -64,20 +64,26 @@ private function __construct() {
}
/**
- * Enqueue front end and editor JavaScript and CSS
+ * Enqueue Gutenberg block assets.
*/
public function enqueue_gutenberg_scripts() {
- global $wp_version, $pagenow;
-
- $blockPath = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/block.js';
- $handsontableJS = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/handsontable.js';
- $stylePath = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/block.css';
- $handsontableCSS = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/handsontable.css';
+ global $pagenow;
+
+ $blockPath = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/index.js';
+ $stylePath = VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/style-index.css';
+ $asset_path = VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/index.asset.php';
+ if ( file_exists( $asset_path ) ) {
+ // @phpstan-ignore-next-line
+ $asset = require $asset_path;
+ } else {
+ $asset = array(
+ 'dependencies' => array(),
+ 'version' => $this->version,
+ );
+ }
if ( VISUALIZER_TEST_JS_CUSTOMIZATION ) {
- $version = filemtime( VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/block.js' );
- } else {
- $version = $this->version;
+ $asset['version'] = filemtime( VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/index.js' );
}
if ( ! wp_script_is( 'visualizer-datatables', 'registered' ) ) {
@@ -89,59 +95,53 @@ public function enqueue_gutenberg_scripts() {
}
// Enqueue the bundled block JS file
- wp_enqueue_script( 'handsontable', $handsontableJS );
- wp_enqueue_script( 'visualizer-gutenberg-block', $blockPath, array( 'wp-api', 'handsontable', 'visualizer-datatables', 'moment', 'lodash' ), $version, true );
-
- $type = 'community';
-
- if ( Visualizer_Module::is_pro() ) {
- $type = 'pro';
- if ( apply_filters( 'visualizer_is_business', false ) ) {
- $type = 'business';
- }
+ $script_deps = array(
+ 'wp-api',
+ 'wp-blocks',
+ 'wp-block-editor',
+ 'wp-components',
+ 'wp-editor',
+ 'wp-element',
+ 'wp-i18n',
+ 'lodash',
+ 'moment',
+ 'react',
+ 'visualizer-datatables',
+ );
+ if ( isset( $asset['dependencies'] ) && is_array( $asset['dependencies'] ) ) {
+ $script_deps = array_merge( $script_deps, $asset['dependencies'] );
}
-
- $table_col_mapping = Visualizer_Source_Query_Params::get_all_db_tables_column_mapping( null, false );
+ $script_deps = array_values( array_unique( $script_deps ) );
+ wp_enqueue_script( 'visualizer-gutenberg-block', $blockPath, $script_deps, $asset['version'], true );
$translation_array = array(
- 'isPro' => $type,
- 'proTeaser' => tsdk_utmify( Visualizer_Plugin::PRO_TEASER_URL, 'blockupsell'),
- 'absurl' => VISUALIZER_ABSURL,
- 'charts' => Visualizer_Module_Admin::_getChartTypesLocalized(),
'adminPage' => menu_page_url( 'visualizer', false ),
'createChart' => add_query_arg( array( 'action' => 'visualizer-create-chart', 'library' => 'yes', 'type' => '', 'chart-library' => '', 'tab' => 'visualizer' ), admin_url( 'admin-ajax.php' ) ),
- 'sqlTable' => $table_col_mapping,
'chartsPerPage' => defined( 'TI_E2E_TESTING' ) ? 20 : 6,
- 'proFeaturesLocked' => Visualizer_Module_Admin::proFeaturesLocked(),
'isFullSiteEditor' => 'site-editor.php' === $pagenow,
- 'legacyBlockEdit' => apply_filters( 'visualizer_legacy_block_edit', false ),
/* translators: %1$s: opening tag, %2$s: closing tag */
- 'blockEditDoc' => sprintf( __( 'The editor for managing chart settings has been removed from the block editor. You can find more information in this %1$sdocumentation%2$s', 'visualizer' ), '', '' ),
'chartEditUrl' => admin_url( 'admin-ajax.php' ),
);
wp_localize_script( 'visualizer-gutenberg-block', 'visualizerLocalize', $translation_array );
- // Enqueue frontend and editor block styles
- wp_enqueue_style( 'handsontable', $handsontableCSS );
- wp_enqueue_style( 'visualizer-gutenberg-block', $stylePath, array( 'visualizer-datatables' ), $version );
-
- if ( version_compare( $wp_version, '4.9.0', '>' ) ) {
-
- wp_enqueue_code_editor(
- array(
- 'type' => 'sql',
- 'codemirror' => array(
- 'autofocus' => true,
- 'lineWrapping' => true,
- 'dragDrop' => false,
- 'matchBrackets' => true,
- 'autoCloseBrackets' => true,
- 'extraKeys' => array( 'Shift-Space' => 'autocomplete' ),
- 'hintOptions' => array( 'tables' => $table_col_mapping ),
- ),
- )
+ $d3_renderer_asset = VISUALIZER_ABSPATH . '/classes/Visualizer/D3Renderer/build/index.asset.php';
+ if ( file_exists( $d3_renderer_asset ) && ! wp_script_is( 'visualizer-d3-renderer', 'registered' ) ) {
+ // @phpstan-ignore-next-line
+ $d3_asset = include $d3_renderer_asset;
+ wp_register_script(
+ 'visualizer-d3-renderer',
+ VISUALIZER_ABSURL . 'classes/Visualizer/D3Renderer/build/index.js',
+ array_merge( $d3_asset['dependencies'], array( 'jquery' ) ),
+ $d3_asset['version'],
+ true
);
}
+ if ( wp_script_is( 'visualizer-d3-renderer', 'registered' ) ) {
+ wp_enqueue_script( 'visualizer-d3-renderer' );
+ }
+
+ // Enqueue frontend and editor block styles
+ wp_enqueue_style( 'visualizer-gutenberg-block', $stylePath, array( 'visualizer-datatables' ), $asset['version'] );
}
/**
* Hook server side rendering into render callback
@@ -186,8 +186,7 @@ public function gutenberg_block_callback( $atts ) {
return '';
}
- // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
- if ( $atts['lazy'] == -1 || $atts['lazy'] == false ) {
+ if ( $atts['lazy'] === '-1' || $atts['lazy'] === false ) {
$atts['lazy'] = 'no';
}
@@ -216,123 +215,6 @@ public function register_rest_endpoints() {
'get_callback' => array( $this, 'get_visualizer_data' ),
)
);
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/get-query-data',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'get_query_data' ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/get-json-root',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'get_json_root_data' ),
- 'args' => array(
- 'url' => array(
- 'sanitize_callback' => 'esc_url_raw',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/get-json-data',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'get_json_data' ),
- 'args' => array(
- 'url' => array(
- 'sanitize_callback' => 'esc_url_raw',
- ),
- 'chart' => array(
- 'sanitize_callback' => 'absint',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/set-json-data',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'set_json_data' ),
- 'args' => array(
- 'url' => array(
- 'sanitize_callback' => 'esc_url_raw',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/update-chart',
- array(
- 'methods' => 'POST',
- 'callback' => array( $this, 'update_chart_data' ),
- 'args' => array(
- 'id' => array(
- 'sanitize_callback' => 'absint',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/upload-data',
- array(
- 'methods' => 'POST',
- 'callback' => array( $this, 'upload_csv_data' ),
- 'args' => array(
- 'url' => array(
- 'sanitize_callback' => 'esc_url_raw',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
-
- register_rest_route(
- 'visualizer/v' . VISUALIZER_REST_VERSION,
- '/get-permission-data',
- array(
- 'methods' => 'GET',
- 'callback' => array( $this, 'get_permission_data' ),
- 'args' => array(
- 'type' => array(
- 'sanitize_callback' => 'sanitize_text_field',
- ),
- ),
- 'permission_callback' => function () {
- return current_user_can( 'edit_posts' );
- },
- )
- );
}
/**
@@ -350,6 +232,9 @@ public function get_visualizer_data( $post ) {
$library = get_post_meta( $post_id, Visualizer_Plugin::CF_CHART_LIBRARY, true );
$data['visualizer-chart-library'] = $library;
+ if ( 'd3' === $library ) {
+ $data['visualizer-d3-code'] = get_post_meta( $post_id, Visualizer_Module_AIBuilder::CF_D3_CODE, true );
+ }
$data['visualizer-source'] = get_post_meta( $post_id, Visualizer_Plugin::CF_SOURCE, true );
@@ -472,11 +357,12 @@ public function get_visualizer_data( $post ) {
$permissions = get_post_meta( $post_id, Visualizer_Pro::CF_PERMISSIONS, true );
if ( empty( $permissions ) ) {
- $permissions = array( 'permissions' => array(
+ $permissions = array(
+ 'permissions' => array(
'read' => 'all',
'edit' => 'roles',
'edit-specific' => array( 'administrator' ),
- ),
+ ),
);
}
@@ -486,227 +372,6 @@ public function get_visualizer_data( $post ) {
return $data;
}
- /**
- * Returns the data for the query.
- *
- * @access public
- */
- public function get_query_data( $data ) {
- if ( ! current_user_can( 'administrator' ) || ( is_multisite() && ! is_super_admin() ) ) {
- return false;
- }
-
- $source = new Visualizer_Source_Query( stripslashes( $data['query'] ) );
- $html = $source->fetch( true );
- $source->fetch( false );
- $name = $source->getSourceName();
- $series = $source->getSeries();
- $data = $source->getRawData();
- $error = '';
- if ( empty( $html ) ) {
- $error = $source->get_error();
- wp_send_json_error( array( 'msg' => $error ) );
- }
- wp_send_json_success( array( 'table' => $html, 'name' => $name, 'series' => $series, 'data' => $data ) );
- }
-
- /**
- * Returns the JSON root.
- *
- * @access public
- */
- public function get_json_root_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- $source = new Visualizer_Source_Json( $data );
-
- $roots = $source->fetchRoots();
- if ( empty( $roots ) ) {
- wp_send_json_error( array( 'msg' => $source->get_error() ) );
- }
-
- wp_send_json_success( array( 'url' => $data['url'], 'roots' => $roots ) );
- }
-
- /**
- * Returns the JSON data.
- *
- * @access public
- */
- public function get_json_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- $chart_id = $data['chart'];
-
- if ( empty( $chart_id ) ) {
- wp_die();
- }
-
- $source = new Visualizer_Source_Json( $data );
- $source->fetch();
- $table = $source->getRawData();
-
- if ( empty( $table ) ) {
- wp_send_json_error( array( 'msg' => esc_html__( 'Unable to fetch data from the endpoint. Please try again.', 'visualizer' ) ) );
- }
-
- $table = Visualizer_Render_Layout::show( 'editor-table', $table, $chart_id, 'viz-json-table', false, false );
- wp_send_json_success( array( 'table' => $table, 'root' => $data['root'], 'url' => $data['url'], 'paging' => $source->getPaginationElements() ) );
- }
-
- /**
- * Set the JSON data.
- *
- * @access public
- */
- public function set_json_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- $source = new Visualizer_Source_Json( $data );
-
- $table = $source->fetch();
- if ( empty( $table ) ) {
- wp_send_json_error( array( 'msg' => esc_html__( 'Unable to fetch data from the endpoint. Please try again.', 'visualizer' ) ) );
- }
-
- $source->fetchFromEditableTable();
- $name = $source->getSourceName();
- $series = json_encode( $source->getSeries() );
- $data = json_encode( $source->getRawData() );
- wp_send_json_success( array( 'name' => $name, 'series' => $series, 'data' => $data ) );
- }
-
- /**
- * Rest Callback Method
- */
- public function update_chart_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- if ( $data['id'] && ! is_wp_error( $data['id'] ) ) {
- if ( get_post_type( $data['id'] ) !== Visualizer_Plugin::CPT_VISUALIZER ) {
- return new WP_Error( 'invalid_post_type', 'Invalid post type.' );
- }
- $chart_type = sanitize_text_field( $data['visualizer-chart-type'] );
- $source_type = sanitize_text_field( $data['visualizer-source'] );
- $default_data = (int) $data['visualizer-default-data'];
- $series_data = map_deep( $data['visualizer-series'], array( $this, 'sanitize_value' ) );
- $settings_data = map_deep( $data['visualizer-settings'], array( $this, 'sanitize_value' ) );
-
- update_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_TYPE, $chart_type );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_SOURCE, $source_type );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_DEFAULT_DATA, $default_data );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_SERIES, $series_data );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_SETTINGS, $settings_data );
-
- if ( $data['visualizer-chart-url'] && $data['visualizer-chart-schedule'] >= 0 ) {
- $chart_url = esc_url_raw( $data['visualizer-chart-url'] );
- $chart_schedule = intval( $data['visualizer-chart-schedule'] );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_URL, $chart_url );
- apply_filters( 'visualizer_pro_chart_schedule', $data['id'], $chart_url, $chart_schedule );
- } else {
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_URL );
- apply_filters( 'visualizer_pro_remove_schedule', $data['id'] );
- }
-
- // let's check if this is not an external db chart
- // as there is no support for that in the block editor interface
- $external_params = get_post_meta( $data['id'], Visualizer_Plugin::CF_REMOTE_DB_PARAMS, true );
- if ( empty( $external_params ) ) {
- if ( $source_type === 'Visualizer_Source_Query' ) {
- $db_schedule = intval( $data['visualizer-db-schedule'] );
- $db_query = $data['visualizer-db-query'];
- update_post_meta( $data['id'], Visualizer_Plugin::CF_DB_SCHEDULE, $db_schedule );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_DB_QUERY, stripslashes( $db_query ) );
- } else {
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_DB_SCHEDULE );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_DB_QUERY );
- }
-
- if ( 'Visualizer_Source_Csv_Remote' === $source_type ) {
- $schedule_url = esc_url_raw( $data['visualizer-chart-url'] );
- $schedule_id = intval( $data['visualizer-chart-schedule'] );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_URL, $schedule_url );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_SCHEDULE, $schedule_id );
- } else {
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_URL );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_SCHEDULE );
- }
- }
-
- if ( $source_type === 'Visualizer_Source_Json' ) {
- $json_schedule = intval( $data['visualizer-json-schedule'] );
- $json_url = esc_url_raw( $data['visualizer-json-url'] );
- $json_headers = esc_url_raw( $data['visualizer-json-headers'] );
- $json_root = sanitize_text_field( $data['visualizer-json-root'] );
- $json_paging = sanitize_text_field( $data['visualizer-json-paging'] );
-
- update_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_SCHEDULE, $json_schedule );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_URL, $json_url );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_HEADERS, $json_headers );
- update_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_ROOT, $json_root );
-
- if ( ! empty( $json_paging ) ) {
- update_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_PAGING, $json_paging );
- } else {
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_PAGING );
- }
- } else {
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_SCHEDULE );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_URL );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_HEADERS );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_ROOT );
- delete_post_meta( $data['id'], Visualizer_Plugin::CF_JSON_PAGING );
- }
-
- if ( Visualizer_Module::is_pro() ) {
- $permissions_data = map_deep( $data['visualizer-permissions'], array( $this, 'sanitize_value' ) );
- update_post_meta( $data['id'], Visualizer_Pro::CF_PERMISSIONS, $permissions_data );
- }
-
- if ( $data['visualizer-chart-url'] ) {
- $chart_url = esc_url_raw( $data['visualizer-chart-url'] );
- $content['source'] = $chart_url;
- $content['data'] = $this->format_chart_data( $data['visualizer-data'], $data['visualizer-series'] );
- } else {
- $content = $this->format_chart_data( $data['visualizer-data'], $data['visualizer-series'] );
- }
-
- $chart = array(
- 'ID' => $data['id'],
- 'post_content' => serialize( $content ),
- );
-
- wp_update_post( $chart );
-
- // Clear existing chart cache.
- $cache_key = Visualizer_Plugin::CF_CHART_CACHE . '_' . $data['id'];
- if ( get_transient( $cache_key ) ) {
- delete_transient( $cache_key );
- }
-
- $revisions = wp_get_post_revisions( $data['id'], array( 'order' => 'ASC' ) );
-
- if ( count( $revisions ) > 1 ) {
- $revision_ids = array_keys( $revisions );
-
- // delete all revisions.
- foreach ( $revision_ids as $id ) {
- wp_delete_post_revision( $id );
- }
- }
-
- return new \WP_REST_Response( array( 'success' => sprintf( 'Chart updated' ) ) );
- }
- }
-
/**
* Format chart data.
*
@@ -714,7 +379,7 @@ public function update_chart_data( $data ) {
*/
public function format_chart_data( $data, $series ) {
foreach ( $series as $i => $row ) {
- // if no value exists for the seires, then add null
+ // if no value exists for the series, then add null
if ( ! isset( $series[ $i ] ) ) {
$series[ $i ] = null;
}
@@ -768,84 +433,6 @@ public function toUTF8( $datum ) {
return $datum;
}
- /**
- * Handle remote CSV data
- */
- public function upload_csv_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- $remote_data = false;
- if ( isset( $data['url'] ) && function_exists( 'wp_http_validate_url' ) ) {
- $remote_data = wp_http_validate_url( $data['url'] );
- }
- if ( false !== $remote_data && ! is_wp_error( $remote_data ) ) {
- $source = new Visualizer_Source_Csv_Remote( $remote_data );
- if ( $source->fetch() ) {
- $temp = $source->getData();
- if ( is_string( $temp ) && is_array( unserialize( $temp ) ) ) {
- $content['series'] = $source->getSeries();
- $content['data'] = $source->getRawData();
- return $content;
- } else {
- return new \WP_REST_Response( array( 'failed' => sprintf( 'Invalid CSV URL' ) ) );
- }
- } else {
- return new \WP_REST_Response( array( 'failed' => sprintf( 'Invalid CSV URL' ) ) );
- }
- } else {
- return new \WP_REST_Response( array( 'failed' => sprintf( 'Invalid CSV URL' ) ) );
- }
- }
-
- /**
- * Get permission data
- */
- public function get_permission_data( $data ) {
- if ( ! current_user_can( 'edit_posts' ) ) {
- return false;
- }
-
- $options = array();
- switch ( $data['type'] ) {
- case 'users':
- $query = new WP_User_Query(
- array(
- 'number' => 1000,
- 'orderby' => 'display_name',
- 'fields' => array( 'ID', 'display_name' ),
- 'count_total' => false,
- )
- );
- $users = $query->get_results();
- if ( ! empty( $users ) ) {
- $i = 0;
- foreach ( $users as $user ) {
- $options[ $i ]['value'] = $user->ID;
- $options[ $i ]['label'] = $user->display_name;
- $i++;
- }
- }
- break;
- case 'roles':
- if ( ! function_exists( 'get_editable_roles' ) ) {
- require_once ABSPATH . 'wp-admin/includes/user.php';
- }
- $roles = get_editable_roles();
- if ( ! empty( $roles ) ) {
- $i = 0;
- foreach ( get_editable_roles() as $name => $info ) {
- $options[ $i ]['value'] = $name;
- $options[ $i ]['label'] = $name;
- $i++;
- }
- }
- break;
- }
- return $options;
- }
-
/**
* Filter Rest Query
*/
@@ -867,18 +454,4 @@ public function add_rest_query_vars( $args, \WP_REST_Request $request ) {
}
return $args;
}
-
- /**
- * Sanitize value.
- *
- * @param mixed $value The value to sanitize.
- * @return mixed Sanitized value.
- */
- private function sanitize_value( $value ) {
- if ( is_string( $value ) ) {
- return sanitize_text_field( $value );
- }
-
- return $value;
- }
}
diff --git a/classes/Visualizer/Gutenberg/build/block.css b/classes/Visualizer/Gutenberg/build/block.css
deleted file mode 100644
index a5a300aed..000000000
--- a/classes/Visualizer/Gutenberg/build/block.css
+++ /dev/null
@@ -1 +0,0 @@
-.visualizer-settings{background-color:#f8f9f9;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;font-size:13px;position:relative}.visualizer-settings .visualizer-settings__title{margin:0;padding:1.5rem 0;text-align:center;border-bottom:1px solid #e6eaee}.visualizer-settings .visualizer-settings__title .dashicon{vertical-align:top;margin-right:.25em}.visualizer-settings .visualizer-settings__content{padding:2.5em 0}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-description{margin:0 0 1.5em 0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;font-size:18px;text-align:center}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option{display:flex;align-items:flex-start;flex-wrap:wrap;margin:0 auto;padding:1.25em 1.5em;max-width:80%;background:#fff;border-width:1px 1px 0;border-style:solid;border-color:#e6eaee;cursor:pointer}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option.locked{cursor:default}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option.locked:hover{background:#fff}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option:hover{background:#f5f5f5}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option:last-of-type{border-bottom-width:1px}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option .visualizer-settings__content-option-title{max-width:80%;display:block;font-size:1.25em}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option .visualizer-settings__content-option-icon{align-self:center;margin-left:auto;color:#b9bcc2}.visualizer-settings .visualizer-settings__content .visualizer-settings__content-option .visualizer-settings__content-option-icon .dashicon{height:25px;width:25px}.visualizer-settings .visualizer-settings__charts{text-align:center;padding-bottom:25px}.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid{display:grid;grid-template-columns:50% 50%}.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid .visualizer-settings__charts-single{margin:25px;padding-bottom:50px;background-color:#efefef;position:relative}.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid .visualizer-settings__charts-single .visualizer-settings__charts-title{padding:10px;font-weight:bold;text-align:center}.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid .visualizer-settings__charts-single .visualizer-settings__charts-footer{font-size:small}.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid .visualizer-settings__charts-single .visualizer-settings__charts-controls{width:100%;position:absolute;bottom:0;padding:10px;font-weight:bold;text-align:center;cursor:pointer}.visualizer-settings .visualizer-settings__charts .dataTables_wrapper{background:#fff;padding:10px}.visualizer-settings .visualizer-settings__charts .visualizer-no-charts{padding-top:25px}.visualizer-settings .visualizer-settings__chart{text-align:center}.visualizer-settings .visualizer-settings__chart .dataTables_wrapper{background:#fff;padding:10px}.visualizer-settings .visualizer-settings__controls{margin:0;padding:1.5rem 0;text-align:center;border-top:1px solid #e6eaee}.visualizer-advanced-panel.components-panel__body.is-opened>.components-panel__body-title{margin-bottom:0}.visualizer-inner-sections{background:#f8f9f9}.visualizer-inner-sections .components-panel__body-toggle:hover{background:#eee}.visualizer-inner-sections ul.visualizer-list{list-style:disc;margin-left:15px}.components-panel__body-button .components-panel__body-toggle.components-button .dashicons-admin-tools{margin:-2px 6px -2px 0}.components-panel__body-button .components-panel__body-toggle.components-button .dashicons-admin-users{margin:-2px 6px -2px 0}.components-panel__body-button .components-panel__body-toggle.components-button .components-panel__arrow{width:48px;height:48px;right:0;border-top:1px solid #ddd;transform:translateY(-50%) rotate(270deg)}.components-panel__body-button.visualizer-panel-back .components-panel__body-title{background:#f3f3f3}.components-panel__body-button.visualizer-panel-back .components-panel__body-title:hover{background:#f3f3f3}.components-panel__body-button.visualizer-panel-back .components-panel__body-title .components-panel__body-toggle{margin:10px 0;background:#fff}.components-panel__body-button.visualizer-panel-back .components-panel__body-title .components-panel__body-toggle.components-button{padding-left:60px}.components-panel__body-button.visualizer-panel-back .components-panel__body-title .components-panel__body-toggle.components-button:hover .components-panel__arrow{background:#f3f3f3;border-width:1px 1px 0 1px;border-color:#ddd;border-style:solid}.components-panel__body-button.visualizer-panel-back .components-panel__body-title .components-panel__body-toggle.components-button .components-panel__arrow{left:0;transform:translateY(-50%) rotate(90deg)}.visualizer-chart-editor{max-width:100%;margin:25px 25px 0}.visualizer-chart-editor .htEditor{margin-bottom:20px}.visualizer-chart-editor .htEditor .htRowHeaders{height:auto !important;width:auto !important}.visualizer-chart-editor .htEditor .ht_master .wtHolder{height:auto !important;width:auto !important}.visualizer-json-query-modal .components-modal__content{padding-left:0;padding-right:0}.visualizer-json-query-modal .components-modal__content .components-modal__header{margin:0}.visualizer-json-query-modal .components-icon-button{margin:10px 0}.visualizer-json-query-modal .visualizer-json-query-modal-headers-panel{padding:0 0 1em 2.2em}.visualizer-json-query-modal .visualizer-json-query-modal-headers-panel .components-base-control{display:inline-block}.visualizer-json-query-modal .visualizer-json-query-modal-headers-panel .visualizer-json-query-modal-field-separator{padding:0 10px}.visualizer-json-query-modal .viz-editor-table tbody tr:first-child{background-color:#ececec !important}.visualizer-json-query-modal .viz-editor-table tr th{background-color:#ccc}.visualizer-json-query-modal .viz-editor-table thead tr th:nth-child(n+1){cursor:move !important}.visualizer-json-query-modal #visualizer-json-query-table{margin-bottom:10px}.visualizer-json-query-modal ul{list-style:disc;margin-left:10px}.visualizer-db-query-modal .CodeMirror-scroll{overflow:hidden !important;height:50%;margin:0;padding:0}.visualizer-db-query-modal .CodeMirror-wrap{height:200px;padding:15px;color:#fff;background:#282923;font-size:15px;margin-bottom:20px}.visualizer-db-query-modal .CodeMirror-wrap .CodeMirror-cursor{border-left:1px solid #fff !important}.visualizer-db-query-modal .CodeMirror-wrap .CodeMirror-placeholder{color:#fff}.visualizer-db-query-modal .CodeMirror-wrap pre{color:#fff !important}.visualizer-db-query-modal .CodeMirror-wrap .cm-keyword{color:#f92472 !important}.visualizer-db-query-modal .CodeMirror-wrap .cm-comment{color:#74705d !important}.visualizer-db-query-modal .CodeMirror-wrap .cm-number{color:#fff !important}.visualizer-db-query-modal .CodeMirror-wrap .cm-string{color:#fff !important}.visualizer-db-query-modal ul{list-style:disc;margin-left:10px}.visualizer-db-query-modal .db-wizard-error{color:red}.visualizer-db-query-modal .visualizer-db-query-actions .components-button:first-child{margin-right:10px}.htContextMenu:not(.htGhostTable){z-index:999999}.htDatepickerHolder,.CodeMirror-hints,.DTCR_clonedTable,.DTCR_pointer{z-index:999999 !important}.vz-permission-tab select.components-select-control__input{overflow:auto !important}.components-panel .components-select-control{height:auto !important}@media(min-width: 768px){.visualizer-json-query-modal{width:668px}.visualizer-db-query-modal .CodeMirror-wrap{min-width:550px}}@media(max-width: 768px){.visualizer-settings .visualizer-settings__charts .visualizer-settings__charts-grid{display:grid;grid-template-columns:100%}}.viz-edit-chart-new{display:flex;flex-direction:column;align-items:center;gap:12px}.viz-edit-chart-new p{padding:12px;padding-left:24px;color:#ff9901}.viz-edit-chart-new p a:hover{pointer:cursor}
diff --git a/classes/Visualizer/Gutenberg/build/block.js b/classes/Visualizer/Gutenberg/build/block.js
deleted file mode 100644
index 5e1cd447c..000000000
--- a/classes/Visualizer/Gutenberg/build/block.js
+++ /dev/null
@@ -1 +0,0 @@
-(()=>{var e,t={5644:function(e,t,n){!function(e,t,n){"use strict";t=t&&t.hasOwnProperty("default")?t.default:t,n=n&&n.hasOwnProperty("default")?n.default:n;var r=function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")},a=function(){function e(e,t){for(var n=0;n-1)return t}return e}}]),e}(),l=function(e){function l(){r(this,l);var e=i(this,(l.__proto__||Object.getPrototypeOf(l)).apply(this,arguments));return e.settingsMapper=new s,e.id=null,e.hotInstance=null,e.hotElementRef=null,e}return o(l,e),a(l,[{key:"setHotElementRef",value:function(e){this.hotElementRef=e}},{key:"componentDidMount",value:function(){var e=this.settingsMapper.getSettings(this.props);this.hotInstance=new t(this.hotElementRef,e)}},{key:"shouldComponentUpdate",value:function(e,t){return this.updateHot(this.settingsMapper.getSettings(e)),!1}},{key:"componentWillUnmount",value:function(){this.hotInstance.destroy()}},{key:"render",value:function(){return this.id=this.props.id||"hot-"+Math.random().toString(36).substring(5),this.className=this.props.className||"",this.style=this.props.style||{},n.createElement("div",{ref:this.setHotElementRef.bind(this),id:this.id,className:this.className,style:this.style})}},{key:"updateHot",value:function(e){this.hotInstance.updateSettings(e,!1)}}]),l}(n.Component);e.HotTable=l,Object.defineProperty(e,"__esModule",{value:!0})}(t,n(3748),n(6540))},2867:(e,t,n)=>{"use strict";var r=n(6540),a=n(9921),o=n.n(a),i=function(e,t){return i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])},i(e,t)};function s(e,t){function n(){this.constructor=e}i(e,t),e.prototype=null===t?Object.create(t):(n.prototype=t.prototype,new n)}var l=function(){return l=Object.assign||function(e){for(var t,n=1,r=arguments.length;n0&&a[a.length-1])||6!==o[0]&&2!==o[0])){i=0;continue}if(3===o[0]&&(!a||o[1]>a[0]&&o[1]0){var i=Array.from({length:o-1}).map((function(e,r){var o=t.getColumnID(a,r+1);return t.state.hiddenColumns.includes(o)?"#CCCCCC":void 0!==n.colors&&null!==n.colors?n.colors[r]:h[r]}));r.setOptions(l({},n,{colors:i})),r.draw()}}},t.onResize=function(){t.props.googleChartWrapper.draw()},t}return s(t,e),t.prototype.componentDidMount=function(){this.draw(this.props),window.addEventListener("resize",this.onResize),(this.props.legend_toggle||this.props.legendToggle)&&this.listenToLegendToggle()},t.prototype.componentWillUnmount=function(){var e=this.props,t=e.google,n=e.googleChartWrapper;window.removeEventListener("resize",this.onResize),t.visualization.events.removeAllListeners(n),"Timeline"===n.getChartType()&&n.getChart()&&n.getChart().clearChart()},t.prototype.componentDidUpdate=function(){this.draw(this.props)},t.prototype.render=function(){return null},t}(r.Component),L=function(e){function t(){return null!==e&&e.apply(this,arguments)||this}return s(t,e),t.prototype.componentDidMount=function(){},t.prototype.componentWillUnmount=function(){},t.prototype.shouldComponentUpdate=function(){return!1},t.prototype.render=function(){var e=this.props,t=e.google,n=e.googleChartWrapper,a=e.googleChartDashboard;return(0,r.createElement)(w,{render:function(e){return(0,r.createElement)(M,l({},e,{google:t,googleChartWrapper:n,googleChartDashboard:a}))}})},t}(r.Component),k=function(e){function t(){return null!==e&&e.apply(this,arguments)||this}return s(t,e),t.prototype.shouldComponentUpdate=function(){return!1},t.prototype.listenToEvents=function(e){var t=this,n=e.chartEvents,r=e.google,a=e.googleChartWrapper;if(null!==n){r.visualization.events.removeAllListeners(a);for(var o=function(e){var n=e.eventName,o=e.callback;r.visualization.events.addListener(a,n,(function(){for(var e=[],n=0;n1&&void 0!==arguments[1]?arguments[1]:0,n=(P[e[t+0]]+P[e[t+1]]+P[e[t+2]]+P[e[t+3]]+"-"+P[e[t+4]]+P[e[t+5]]+"-"+P[e[t+6]]+P[e[t+7]]+"-"+P[e[t+8]]+P[e[t+9]]+"-"+P[e[t+10]]+P[e[t+11]]+P[e[t+12]]+P[e[t+13]]+P[e[t+14]]+P[e[t+15]]).toLowerCase();if(!x(n))throw TypeError("Stringified UUID is invalid");return n};const z=function(e,t,n){var r=(e=e||{}).random||(e.rng||j)();if(r[6]=15&r[6]|64,r[8]=63&r[8]|128,t){n=n||0;for(var a=0;a<16;++a)t[n+a]=r[a];return t}return H(r)};function A(e){return A="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},A(e)}function N(e,t){for(var n=0;n)<[^<]*)*<\/script>/gi,""):r.series[t].format.truthy.replace(/';
-
}
/**
* Renders library content.
@@ -283,37 +299,64 @@ private function _renderLibrary() {
echo '';
echo '
';
if ( ! empty( $this->charts ) ) {
- echo '
';
- $count = 0;
- foreach ( $this->charts as $placeholder_id => $chart ) {
- // show the sidebar after the first 3 charts.
- $count++;
- $enable_controls = false;
- $settings = isset( $chart['settings'] ) ? $chart['settings'] : array();
- if ( ! empty( $settings['controls']['controlType'] ) ) {
- $column_index = $settings['controls']['filterColumnIndex'];
- $column_label = $settings['controls']['filterColumnLabel'];
- if ( 'false' !== $column_index || 'false' !== $column_label ) {
- $enable_controls = true;
+ if ( $this->_isListView() ) {
+ echo '
';
+ $this->_renderSidebar();
+ echo '
';
+ echo '';
+ echo '| ' . esc_html__( 'ID', 'visualizer' ) . ' | ';
+ echo '' . esc_html__( 'Title', 'visualizer' ) . ' | ';
+ echo '' . esc_html__( 'Type', 'visualizer' ) . ' | ';
+ echo '' . esc_html__( 'Shortcode', 'visualizer' ) . ' | ';
+ echo '' . esc_html__( 'Actions', 'visualizer' ) . ' | ';
+ echo '
';
+ foreach ( $this->charts as $placeholder_id => $chart ) {
+ $enable_controls = false;
+ $settings = isset( $chart['settings'] ) ? $chart['settings'] : array();
+ if ( ! empty( $settings['controls']['controlType'] ) ) {
+ $column_index = $settings['controls']['filterColumnIndex'];
+ $column_label = $settings['controls']['filterColumnLabel'];
+ if ( 'false' !== $column_index || 'false' !== $column_label ) {
+ $enable_controls = true;
+ }
}
- }
- if ( 3 === $count ) {
- $this->_renderSidebar();
- $this->_renderChartBox( $placeholder_id, $chart['id'], $enable_controls );
- } else {
$this->_renderChartBox( $placeholder_id, $chart['id'], $enable_controls );
}
- }
- // show the sidebar if there are less than 3 charts.
- if ( $count < 3 ) {
+ echo '
';
+ echo '
';
+ } else {
+ echo '
';
$this->_renderSidebar();
+ foreach ( $this->charts as $placeholder_id => $chart ) {
+ $enable_controls = false;
+ $settings = isset( $chart['settings'] ) ? $chart['settings'] : array();
+ if ( ! empty( $settings['controls']['controlType'] ) ) {
+ $column_index = $settings['controls']['filterColumnIndex'];
+ $column_label = $settings['controls']['filterColumnLabel'];
+ if ( 'false' !== $column_index || 'false' !== $column_label ) {
+ $enable_controls = true;
+ }
+ }
+ $this->_renderChartBox( $placeholder_id, $chart['id'], $enable_controls );
+ }
+ echo '
';
}
- echo '
';
} else {
- echo '
';
+ echo '
';
echo '
';
echo '
';
- echo '
', esc_html__( 'No charts found', 'visualizer' ), '
';
+ echo '
';
+ echo esc_html__( 'No charts found', 'visualizer' );
+ echo '
';
echo '
';
echo '';
echo '
';
@@ -350,8 +393,9 @@ private function _renderLibrary() {
* @param int $chart_id The id of the chart.
*/
private function _renderChartBox( $placeholder_id, $chart_id, $with_filter = false ) {
- $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS );
- $title = '#' . $chart_id;
+ $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS );
+ $chart_library = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true );
+ $title = '#' . $chart_id;
if ( ! empty( $settings[0]['title'] ) ) {
$title = $settings[0]['title'];
}
@@ -399,8 +443,12 @@ private function _renderChartBox( $placeholder_id, $chart_id, $with_filter = fal
)
);
$chart_type = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, true );
+ $chart_type_label = $chart_type;
+ if ( empty( $chart_type_label ) && 'd3' === strtolower( (string) $chart_library ) ) {
+ $chart_type_label = __( 'AI', 'visualizer' );
+ }
- $types = ['area', 'geo', 'column', 'bubble', 'scatter', 'gauge', 'candlestick', 'timeline', 'combo', 'polarArea', 'radar' ];
+ $types = array( 'area', 'geo', 'column', 'bubble', 'scatter', 'gauge', 'candlestick', 'timeline', 'combo', 'polarArea', 'radar' );
$pro_class = '';
@@ -414,7 +462,28 @@ private function _renderChartBox( $placeholder_id, $chart_id, $with_filter = fal
$chart_status['title'] = __( 'Click to view the error', 'visualizer' );
}
$shortcode = sprintf( '[visualizer id="%s" class=""]', $chart_id );
- echo '
', esc_html( $title ), '
';
+
+ if ( $this->_isListView() ) {
+ // ── List view: table row ──
+ echo '
';
+ echo '| #' . esc_html( (string) $chart_id ) . ' | ';
+ echo '' . esc_html( $title ) . ' | ';
+ echo '' . ( ! empty( $chart_type_label ) ? '' . esc_html( $chart_type_label ) . '' : '—' ) . ' | ';
+ echo '' . esc_html( $shortcode ) . ' | ';
+ echo ' | ';
+ echo '
';
+ return;
+ }
+
+ // ── Grid view: card ──
+ $type_badge = ! empty( $chart_type ) ? '
' . esc_html( $chart_type ) . '' : '';
+ echo '
' . esc_html( $title ) . '' . $type_badge . '
';
if ( Visualizer_Module::is_pro() && $with_filter ) {
echo '
';
echo '
';
@@ -427,14 +496,14 @@ private function _renderChartBox( $placeholder_id, $chart_id, $with_filter = fal
}
echo '
';
}
+ /**
+ * Returns true when the library should render in list (no-preview) mode.
+ *
+ * Priority: ?view= URL param (saves to user meta) → saved user meta → grid default.
+ *
+ * No nonce needed: this is a bookmarkable UI preference URL. A nonce would expire
+ * and break saved/shared links for zero real security gain — the value is allowlisted
+ * to 'list'|'grid' before any write happens.
+ */
+ private function _isListView(): bool {
+ if ( null !== $this->_list_view_cached ) {
+ return $this->_list_view_cached;
+ }
+ if ( isset( $_GET['view'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $view = sanitize_text_field( wp_unslash( $_GET['view'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( in_array( $view, array( 'list', 'grid' ), true ) ) {
+ update_user_meta( get_current_user_id(), 'visualizer_library_view', $view );
+ }
+ $this->_list_view_cached = ( 'list' === $view );
+ } else {
+ $saved = get_user_meta( get_current_user_id(), 'visualizer_library_view', true );
+ $this->_list_view_cached = ( 'list' === $saved );
+ }
+ return $this->_list_view_cached;
+ }
+
+ /**
+ * Returns the HTML for the grid/list view toggle links.
+ */
+ private function _getViewToggleHTML(): string {
+ $is_list = $this->_isListView();
+ $grid_url = esc_url( add_query_arg( 'view', 'grid' ) );
+ $list_url = esc_url( add_query_arg( 'view', 'list' ) );
+ return '
'
+ . '
';
+ }
+
/**
* Render 2-col sidebar
*/
private function _renderSidebar() {
if ( ! Visualizer_Module::is_pro() ) {
- echo '
';
- echo '
';
- echo '
@@ -108,9 +108,7 @@ protected function _renderSidebarContent() {
- Hate it? Love it? Rate it!
-
- Visualizer ©
+
chart->ID );
Visualizer_Render_Layout::show( 'json-screen', $this->chart->ID );
}
-
}
diff --git a/classes/Visualizer/Render/Page/Send.php b/classes/Visualizer/Render/Page/Send.php
index 3f21f2c26..b1cb52483 100644
--- a/classes/Visualizer/Render/Page/Send.php
+++ b/classes/Visualizer/Render/Page/Send.php
@@ -53,5 +53,4 @@ protected function _toHTML() {
echo '';
echo '