Skip to content
123 changes: 123 additions & 0 deletions backend/Actions/WpTableBuilder/RecordApiHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

/**
* WP Table Builder Record Api
*/

namespace BitApps\Integrations\Actions\WpTableBuilder;

use BitApps\Integrations\Config;
use BitApps\Integrations\Core\Util\Common;
use BitApps\Integrations\Core\Util\Hooks;
use BitApps\Integrations\Log\LogHandler;

/**
* Provide functionality for WP Table Builder table writes
*/
class RecordApiHelper
{
private $_integrationID;

private $_integrationDetails;

public function __construct($integrationDetails, $integId)
{
$this->_integrationDetails = $integrationDetails;
$this->_integrationID = $integId;
}

/**
* Execute the integration
*
* @param array $fieldValues Field values from trigger
* @param array $fieldMap Field mapping
* @param array $utilities Optional actions
*
* @return array
*/
public function execute($fieldValues, $fieldMap, $utilities)
{
if (!\defined('WPTB_PLUGIN_DIR')) {
return [
'success' => false,
'message' => __('WP Table Builder is not installed or activated', 'bit-integrations')
];
}

$fieldData = static::generateReqDataFromFieldMap($fieldMap, $fieldValues);

// No fallback action: every action writes, and delete_table removes a table, so
// a flow that lost its mainAction should fail through the default branch.
$mainAction = $this->_integrationDetails->mainAction ?? '';

$defaultResponse = [
'success' => false,
// translators: %s: Plugin name
'message' => wp_sprintf(__('%s plugin is not installed or activated', 'bit-integrations'), 'Bit Integrations Pro')
];

switch ($mainAction) {
case 'create_table':
$response = Hooks::apply(Config::withPrefix('wptablebuilder_create_table'), $defaultResponse, $fieldData);
$type = 'table';
$actionType = 'create_table';

break;

case 'update_table':
$response = Hooks::apply(Config::withPrefix('wptablebuilder_update_table'), $defaultResponse, $fieldData);
$type = 'table';
$actionType = 'update_table';

break;

case 'delete_table':
$response = Hooks::apply(Config::withPrefix('wptablebuilder_delete_table'), $defaultResponse, $fieldData, $utilities);
$type = 'table';
$actionType = 'delete_table';

break;

case 'add_row':
$response = Hooks::apply(Config::withPrefix('wptablebuilder_add_row'), $defaultResponse, $fieldData, $this->_integrationDetails);
$type = 'row';
$actionType = 'add_row';

break;

default:
$response = [
'success' => false,
'message' => __('Invalid action', 'bit-integrations')
];
$type = 'WpTableBuilder';
$actionType = 'unknown';

break;
}

$responseType = isset($response['success']) && $response['success'] ? 'success' : 'error';
LogHandler::save($this->_integrationID, ['type' => $type, 'type_name' => $actionType], $responseType, $response);

return $response;
}

private static function generateReqDataFromFieldMap($fieldMap, $fieldValues)
{
$dataFinal = [];
foreach ($fieldMap as $item) {
$triggerValue = $item->formField;
$actionValue = $item->wpTableBuilderField;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Use the null coalescing operator (??) when accessing properties on $item to prevent 'Undefined property' notices if any expected fields are missing from the mapped item.

            $triggerValue = $item->formField ?? '';
            $actionValue = $item->wpTableBuilderField ?? '';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch, applied:

$triggerValue = $item->formField ?? '';
$actionValue = $item->wpTableBuilderField ?? '';

These are direct property reads, which is what makes this one different from the isset()/empty() cases elsewhere in this review — a direct read on a missing property does emit Warning: Undefined property, so the ?? is doing real work here.


if (empty($actionValue)) {
continue;
}

$dataFinal[$actionValue] = $triggerValue === 'custom' && isset($item->customValue)
? Common::replaceFieldWithValue($item->customValue, $fieldValues)
: $fieldValues[$triggerValue] ?? '';
}

return $dataFinal;
}
}
16 changes: 16 additions & 0 deletions backend/Actions/WpTableBuilder/Routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

if (!defined('ABSPATH')) {
exit;
}

use BitApps\Integrations\Actions\WpTableBuilder\WpTableBuilderController;
use BitApps\Integrations\Core\Util\Route;

Route::post('wptablebuilder_authorize', [WpTableBuilderController::class, 'wpTableBuilderAuthorize']);

// Create/Update/Delete take table_id through the field map, so a flow can target a
// different table per run. Add Row is the exception: its column list has to be known
// while the flow is being configured, which only a fixed table can provide.
Route::post('refresh_wptablebuilder_tables', [WpTableBuilderController::class, 'refreshTables']);
Route::post('refresh_wptablebuilder_columns', [WpTableBuilderController::class, 'refreshColumns']);
181 changes: 181 additions & 0 deletions backend/Actions/WpTableBuilder/WpTableBuilderController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

/**
* WP Table Builder Integration
*/

namespace BitApps\Integrations\Actions\WpTableBuilder;

use DOMDocument;
use DOMXPath;
use WP_Error;

/**
* Provide functionality for WP Table Builder integration
*/
class WpTableBuilderController
{
/**
* Tables are a custom post type, and the table body lives in a single post meta.
* Mirrors WP Table Builder's Cpt::POST_TYPE.
*/
public const POST_TYPE = 'wptb-tables';

public const CONTENT_META_KEY = '_wptb_content_';

public static function isExists()
{
if (!\defined('WPTB_PLUGIN_DIR')) {
wp_send_json_error(
__(
'WP Table Builder is not activated or not installed',
'bit-integrations'
),
400
);
}
}

public static function wpTableBuilderAuthorize()
{
self::isExists();
wp_send_json_success(true);
}

/**
* List the tables an Add Row flow can append to.
*
* Trashed tables are excluded — appending to one would silently write into a table
* nobody can see.
*/
public static function refreshTables()
{
self::isExists();

$tables = get_posts(
[
'post_type' => self::POST_TYPE,
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
'numberposts' => -1,
'orderby' => 'title',
'order' => 'ASC',
'suppress_filters' => true,
]
);

$response['tables'] = array_map(
function ($table) {
return [
'value' => (string) $table->ID,
// An untitled table is still selectable, so fall back to the id.
'label' => $table->post_title === ''
// Translators: %d is the table's post ID. WP Table Builder tables are a custom post type, and the title is optional.
? wp_sprintf(__('Table #%d', 'bit-integrations'), $table->ID)
: $table->post_title,
];
},
$tables
);

wp_send_json_success($response);
}

/**
* Read a table's column labels from its header row so the field map can be built
* with real names instead of positional placeholders.
*
* @param mixed $requestParams
*/
public static function refreshColumns($requestParams)
{
self::isExists();

if (empty($requestParams->selectedTable)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent potential PHP warnings or errors (such as attempting to read property on null) when the request body is empty or invalid, add a defensive check to ensure $requestParams is not empty before accessing its properties.

        if (empty($requestParams) || empty($requestParams->selectedTable)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining — empty() already covers this.

empty($requestParams->selectedTable) does not evaluate the property read when $requestParams is null; it returns true silently. Verified on the PHP this runs against:

PHP 8.4.23
empty($null->p)   bool(true)    no warning
isset($null->p)   bool(false)   no warning

Only a direct read warns. So empty($requestParams) || is a second condition that can never change the outcome.

The same review flagged a case where the read is direct — $item->formField in RecordApiHelper — and that one was real and is now fixed.

wp_send_json_error(__('Select a table first', 'bit-integrations'), 400);
}

$table = get_post((int) $requestParams->selectedTable);

if (!$table || $table->post_type !== self::POST_TYPE) {
wp_send_json_error(__('Table not found', 'bit-integrations'), 400);
}

$columns = self::readColumnLabels(get_post_meta($table->ID, self::CONTENT_META_KEY, true));

if (empty($columns)) {
wp_send_json_error(
__('No columns found. Open the table in WP Table Builder and add at least one row.', 'bit-integrations'),
400
);
}

wp_send_json_success(['columns' => $columns]);
}

public function execute($integrationData, $fieldValues)
{
$integrationDetails = $integrationData->flow_details;
$integId = $integrationData->id;
$fieldMap = $integrationDetails->field_map;
$utilities = isset($integrationDetails->utilities) ? $integrationDetails->utilities : [];

if (empty($fieldMap)) {
return new WP_Error('field_map_empty', __('Field map is empty', 'bit-integrations'));
}

$recordApiHelper = new RecordApiHelper($integrationDetails, $integId);

return $recordApiHelper->execute($fieldValues, $fieldMap, $utilities);
}

/**
* Extract one entry per column from the first row of the stored table markup.
*
* The stored value is rendered HTML, so the header labels are read from the DOM
* rather than from any structured source — WP Table Builder does not keep one.
*
* @param string $content
*
* @return array
*/
private static function readColumnLabels($content)
{
if (empty($content) || !\class_exists('DOMDocument')) {
return [];
}

$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
// The stored markup is a fragment, and it is authored content that routinely
// trips libxml — parse errors here are expected and must not surface.
$dom->loadHTML(
'<?xml encoding="utf-8" ?>' . $content,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);

$xpath = new DOMXPath($dom);
$firstRow = $xpath->query('//tr')->item(0);

if (!$firstRow) {
return [];
}

$columns = [];

foreach ($xpath->query('.//th|.//td', $firstRow) as $index => $cell) {
$label = trim($cell->textContent);

$columns[] = [
'key' => 'cell_' . $index,
'label' => $label === ''
? wp_sprintf(__('Column %d', 'bit-integrations'), $index + 1)
: $label,
'required' => false,
];
}

return $columns;
}
}
1 change: 1 addition & 0 deletions backend/Core/Util/AllTriggersName.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ public static function allTriggersName()
'WishlistMember' => ['name' => 'Wishlist Member', 'isPro' => true, 'is_active' => false],
'WpAllImport' => ['name' => 'WP All Import', 'isPro' => true, 'is_active' => false],
'WpDataTables' => ['name' => 'wpDataTables', 'isPro' => true, 'is_active' => false],
'WpTableBuilder' => ['name' => 'WP Table Builder', 'isPro' => true, 'is_active' => false],
'WpErp' => ['name' => 'WP ERP', 'isPro' => true, 'is_active' => false],
'WPLMS' => ['name' => 'WPLMS', 'isPro' => true, 'is_active' => false],
'WPLoyalty' => ['name' => 'WPLoyalty', 'isPro' => true, 'is_active' => false],
Expand Down
1 change: 1 addition & 0 deletions frontend/src/Utils/StaticData/webhookIntegrations.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const customFormIntegrations = [
'SureDash',
'WpErp',
'WpDataTables',
'WpTableBuilder',
'GiveWp',
'SenseiLMS'
]
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/AllIntegrations/EditInteg.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ const EditHefflCRM = lazy(() => import('./HefflCRM/EditHefflCRM'))
const EditSecureCustomFields = lazy(() => import('./SecureCustomFields/EditSecureCustomFields'))
const EditWordPress = lazy(() => import('./WordPress/EditWordPress'))
const EditWpDataTables = lazy(() => import('./WpDataTables/EditWpDataTables'))
const EditWpTableBuilder = lazy(() => import('./WpTableBuilder/EditWpTableBuilder'))
const EditFormyChat = lazy(() => import('./FormyChat/EditFormyChat'))
const EditIvyForms = lazy(() => import('./IvyForms/EditIvyForms'))
const EditWpErp = lazy(() => import('./WpErp/EditWpErp'))
Expand Down Expand Up @@ -643,6 +644,8 @@ const IntegType = memo(({ allIntegURL, flow }) => {
return <EditWordPress allIntegURL={allIntegURL} />
case 'WpDataTables':
return <EditWpDataTables allIntegURL={allIntegURL} />
case 'WpTableBuilder':
return <EditWpTableBuilder allIntegURL={allIntegURL} />
case 'FormyChat':
return <EditFormyChat allIntegURL={allIntegURL} />
case 'IvyForms':
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/AllIntegrations/IntegInfo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ const SecureCustomFieldsAuthorization = lazy(
const WordPressAuthorization = lazy(() => import('./WordPress/WordPressAuthorization'))
const BookingPressAuthorization = lazy(() => import('./BookingPress/BookingPressAuthorization'))
const WpDataTablesAuthorization = lazy(() => import('./WpDataTables/WpDataTablesAuthorization'))
const WpTableBuilderAuthorization = lazy(
() => import('./WpTableBuilder/WpTableBuilderAuthorization')
)
const FormyChatAuthorization = lazy(() => import('./FormyChat/FormyChatAuthorization'))
const IvyFormsAuthorization = lazy(() => import('./IvyForms/IvyFormsAuthorization'))
const WpErpAuthorization = lazy(() => import('./WpErp/WpErpAuthorization'))
Expand Down Expand Up @@ -646,6 +649,8 @@ const IntegrationInfo = memo(({ integrationConf, location }) => {
return <BookingPressAuthorization bookingPressConf={integrationConf} step={1} isInfo />
case 'WpDataTables':
return <WpDataTablesAuthorization wpDataTablesConf={integrationConf} step={1} isInfo />
case 'WpTableBuilder':
return <WpTableBuilderAuthorization wpTableBuilderConf={integrationConf} step={1} isInfo />
case 'FormyChat':
return <FormyChatAuthorization formyChatConf={integrationConf} step={1} isInfo />
case 'IvyForms':
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/AllIntegrations/NewInteg.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const HefflCRM = lazy(() => import('./HefflCRM/HefflCRM'))
const SecureCustomFields = lazy(() => import('./SecureCustomFields/SecureCustomFields'))
const WordPress = lazy(() => import('./WordPress/WordPress'))
const WpDataTables = lazy(() => import('./WpDataTables/WpDataTables'))
const WpTableBuilder = lazy(() => import('./WpTableBuilder/WpTableBuilder'))
const FormyChat = lazy(() => import('./FormyChat/FormyChat'))
const IvyForms = lazy(() => import('./IvyForms/IvyForms'))
const WpErp = lazy(() => import('./WpErp/WpErp'))
Expand Down Expand Up @@ -1811,6 +1812,15 @@ const NewIntegs = memo(({ integUrlName, allIntegURL, flow, setFlow }) => {
setFlow={setFlow}
/>
)
case 'WpTableBuilder':
return (
<WpTableBuilder
allIntegURL={allIntegURL}
formFields={flow?.triggerData?.fields}
flow={flow}
setFlow={setFlow}
/>
)
case 'FormyChat':
return (
<FormyChat
Expand Down
Loading
Loading