@@ -218,6 +247,29 @@ const confirmPublishNewVersionText = computed(() => vue.$t(
{ bundleName: bundle.value?.name },
));
+const invalidAssets = computed(
+ () => bundle.value?.assets?.filter((asset) => asset.integrity_status !== "valid") || [],
+);
+
+const removeInvalidAsset = async (asset) => {
+ const confirm = await vue.$bvModal.msgBoxConfirm(
+ vue.$t("Remove this unavailable association from the bundle? The underlying asset will not be deleted."),
+ {
+ okTitle: vue.$t("Remove from bundle"),
+ okVariant: "danger",
+ cancelTitle: vue.$t("Cancel"),
+ },
+ );
+ if (!confirm) {
+ return;
+ }
+
+ await window.ProcessMaker.apiClient.delete(
+ `/api/1.0/devlink/local-bundles/assets/${asset.id}`,
+ );
+ await loadAssets();
+};
+
const publishBundle = () => {
selected.value = bundle.value;
confirmPublishNewVersion.value.show();
@@ -277,6 +329,15 @@ width: 100%;
.btn-publish {
width: 104px;
}
+.integrity-alert {
+ margin: 16px 24px;
+}
+.integrity-asset {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+}
.icon-button {
background-color: #E9ECF1;
width: 40px;
diff --git a/tests/Feature/Api/DevLinkTest.php b/tests/Feature/Api/DevLinkTest.php
index f0a0af7b0e..2d9c244d92 100644
--- a/tests/Feature/Api/DevLinkTest.php
+++ b/tests/Feature/Api/DevLinkTest.php
@@ -8,7 +8,9 @@
use PHPUnit\Framework\Attributes\DataProvider;
use ProcessMaker\Http\Controllers\Api\DevLinkController;
use ProcessMaker\Models\Bundle;
+use ProcessMaker\Models\BundleAsset;
use ProcessMaker\Models\DevLink;
+use ProcessMaker\Models\Process;
use ProcessMaker\Models\Screen;
use ProcessMaker\Package\PackageDynamicUI\Models\Dashboard;
use ProcessMaker\Package\PackageDynamicUI\Models\Menu;
@@ -257,6 +259,82 @@ public function testShowBundle()
$this->assertEquals($bundle->id, $response->json()['id']);
}
+ public function testShowBundleReportsUnavailableAssetsWithoutFailing()
+ {
+ $bundle = Bundle::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.local-bundle', ['bundle' => $bundle->id]));
+
+ $response->assertOk()
+ ->assertJsonPath('assets.0.id', $bundleAsset->id)
+ ->assertJsonPath('assets.0.name', "Missing Process #$missingProcessId")
+ ->assertJsonPath('assets.0.url', null)
+ ->assertJsonPath('assets.0.integrity_status', BundleAsset::INTEGRITY_MISSING);
+ }
+
+ public function testExportBundleRejectsUnavailableAssets()
+ {
+ $bundle = Bundle::factory()->create(['name' => 'Corrupt Bundle']);
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $response = $this->apiCall('GET', route('api.devlink.export-local-bundle', [
+ 'bundle' => $bundle->id,
+ ]));
+
+ $response->assertStatus(422)
+ ->assertJsonPath('error.code', 422)
+ ->assertJsonPath(
+ 'error.message',
+ 'The bundle Corrupt Bundle contains unavailable assets and cannot be exported.'
+ )
+ ->assertJsonPath('errors.assets.0.bundle_asset_id', $bundleAsset->id)
+ ->assertJsonPath('errors.assets.0.asset_type', Process::class)
+ ->assertJsonPath('errors.assets.0.asset_id', $missingProcessId)
+ ->assertJsonPath('errors.assets.0.integrity_status', BundleAsset::INTEGRITY_MISSING);
+ }
+
+ public function testInstalledBundleAllowsRemovingOnlyUnavailableAssets()
+ {
+ $devLink = DevLink::factory()->create();
+ $bundle = Bundle::factory()->create([
+ 'dev_link_id' => $devLink->id,
+ 'remote_id' => 123,
+ ]);
+ $screen = Screen::factory()->create();
+ $validBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Screen::class,
+ 'asset_id' => $screen->id,
+ ]);
+ $missingProcessId = Process::max('id') + 1000;
+ $invalidBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $this->apiCall('DELETE', route('api.devlink.delete-bundle-asset', [
+ 'bundle_asset' => $validBundleAsset->id,
+ ]))->assertStatus(422);
+ $this->assertDatabaseHas('bundle_assets', ['id' => $validBundleAsset->id]);
+
+ $this->apiCall('DELETE', route('api.devlink.delete-bundle-asset', [
+ 'bundle_asset' => $invalidBundleAsset->id,
+ ]))->assertOk();
+ $this->assertDatabaseMissing('bundle_assets', ['id' => $invalidBundleAsset->id]);
+ }
+
public function testGetBundleAllSettingsReturnsDashboardAndMenuOptions()
{
if (!hasPackage('package-dynamic-ui')) {
diff --git a/tests/Model/BundleAssetTest.php b/tests/Model/BundleAssetTest.php
index 88aa6b0184..42179b1042 100644
--- a/tests/Model/BundleAssetTest.php
+++ b/tests/Model/BundleAssetTest.php
@@ -6,6 +6,7 @@
use ProcessMaker\Models\Bundle;
use ProcessMaker\Models\BundleAsset;
use ProcessMaker\Models\Group;
+use ProcessMaker\Models\Process;
use ProcessMaker\Models\Screen;
use Tests\TestCase;
@@ -16,6 +17,7 @@ public function testCanExport()
$screen = Screen::factory()->create();
$this->assertTrue(BundleAsset::canExport($screen));
+ $this->assertFalse(BundleAsset::canExport(null));
}
public function testExporterNotSupported()
@@ -26,4 +28,55 @@ public function testExporterNotSupported()
$this->expectException(ExporterNotSupported::class);
$bundle->addAsset($group);
}
+
+ public function testMissingAssetCanBeSerialized()
+ {
+ $bundle = Bundle::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ $bundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+
+ $serialized = $bundleAsset->toArray();
+
+ $this->assertSame(BundleAsset::INTEGRITY_MISSING, $serialized['integrity_status']);
+ $this->assertSame("Missing Process #$missingProcessId", $serialized['name']);
+ $this->assertNull($serialized['url']);
+ $this->assertNull($serialized['owner_name']);
+ $this->assertSame([], $serialized['categories']);
+ }
+
+ public function testUnavailableAssetTypeCanBeSerialized()
+ {
+ $bundleAsset = BundleAsset::factory()->create([
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ ]);
+
+ $serialized = $bundleAsset->toArray();
+
+ $this->assertSame(BundleAsset::INTEGRITY_TYPE_UNAVAILABLE, $serialized['integrity_status']);
+ $this->assertSame('Missing Asset #1234', $serialized['name']);
+ $this->assertNull($serialized['url']);
+ }
+
+ public function testDeletingAnAssetLeavesItsBundleAssociationForManualRepair()
+ {
+ $process = Process::factory()->create();
+ $bundleAsset = BundleAsset::factory()->create([
+ 'asset_type' => Process::class,
+ 'asset_id' => $process->id,
+ ]);
+
+ $process->delete();
+
+ $this->assertTrue($process->trashed());
+ $this->assertDatabaseHas('bundle_assets', ['id' => $bundleAsset->id]);
+ $this->assertSame(
+ BundleAsset::INTEGRITY_MISSING,
+ $bundleAsset->refresh()->integrity_status
+ );
+ }
}
diff --git a/tests/Model/BundleTest.php b/tests/Model/BundleTest.php
index d0bee366ee..aa85265b30 100644
--- a/tests/Model/BundleTest.php
+++ b/tests/Model/BundleTest.php
@@ -3,6 +3,7 @@
namespace Tests\Model;
use Illuminate\Support\Facades\Storage;
+use ProcessMaker\Exception\BundleIntegrityException;
use ProcessMaker\ImportExport\Exporters\ScreenExporter;
use ProcessMaker\ImportExport\Logger;
use ProcessMaker\Models\Bundle;
@@ -17,6 +18,10 @@ class BundleTest extends TestCase
{
use HelperTrait;
+ private const ALPHA_DASHBOARD_NAME = 'Alpha Dashboard';
+
+ private const ALPHA_MENU_NAME = 'Alpha Menu';
+
public function testExport()
{
$this->addGlobalSignalProcess();
@@ -44,6 +49,52 @@ public function testExport()
$this->assertEquals($screen->title, $payload[1]['name']);
}
+ public function testExportRejectsBundleWithUnavailableAssetsBeforeExporting()
+ {
+ $bundle = Bundle::factory()->create(['name' => 'Corrupt Bundle']);
+ $screen = Screen::factory()->create();
+ $missingProcessId = Process::max('id') + 1000;
+ BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Screen::class,
+ 'asset_id' => $screen->id,
+ ]);
+ $missingBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ ]);
+ $unavailableBundleAsset = BundleAsset::factory()->create([
+ 'bundle_id' => $bundle->id,
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ ]);
+
+ try {
+ $bundle->export();
+ $this->fail('Expected bundle integrity validation to fail.');
+ } catch (BundleIntegrityException $exception) {
+ $this->assertSame(
+ 'The bundle Corrupt Bundle contains unavailable assets and cannot be exported.',
+ $exception->getMessage()
+ );
+ $this->assertSame([
+ [
+ 'bundle_asset_id' => $missingBundleAsset->id,
+ 'asset_type' => Process::class,
+ 'asset_id' => $missingProcessId,
+ 'integrity_status' => BundleAsset::INTEGRITY_MISSING,
+ ],
+ [
+ 'bundle_asset_id' => $unavailableBundleAsset->id,
+ 'asset_type' => 'ProcessMaker\Missing\Asset',
+ 'asset_id' => 1234,
+ 'integrity_status' => BundleAsset::INTEGRITY_TYPE_UNAVAILABLE,
+ ],
+ ], $exception->invalidAssets());
+ }
+ }
+
public function testSyncAssets()
{
$screen1 = Screen::factory()->create(['title' => 'Screen 1']);
@@ -166,8 +217,8 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
$bundle->savePayloadsToFile([], [[
self::settingPayload('dashboard_package', 'dashboard-zulu', 'Zulu Dashboard'),
self::settingPayload('menu_package', 'menu-bravo', 'Bravo Menu'),
- self::settingPayload('dashboard_package', 'dashboard-alpha', 'Alpha Dashboard'),
- self::settingPayload('menu_package', 'menu-alpha', 'Alpha Menu'),
+ self::settingPayload('dashboard_package', 'dashboard-alpha', self::ALPHA_DASHBOARD_NAME),
+ self::settingPayload('menu_package', 'menu-alpha', self::ALPHA_MENU_NAME),
]]);
$this->assertTrue($bundle->newestVersionFile()->getCustomProperty('settings_payloads_complete'));
@@ -177,7 +228,7 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
'selection' => 'partial',
'available' => true,
'items' => [
- ['key' => 'dashboard-alpha', 'name' => 'Alpha Dashboard'],
+ ['key' => 'dashboard-alpha', 'name' => self::ALPHA_DASHBOARD_NAME],
['key' => 'dashboard-zulu', 'name' => 'Zulu Dashboard'],
],
], $bundle->settingPreview('ui_dashboards'));
@@ -187,7 +238,7 @@ public function testSettingPreviewUsesInstalledPayloadMetadata()
'selection' => 'all',
'available' => true,
'items' => [
- ['key' => 'menu-alpha', 'name' => 'Alpha Menu'],
+ ['key' => 'menu-alpha', 'name' => self::ALPHA_MENU_NAME],
['key' => 'menu-bravo', 'name' => 'Bravo Menu'],
],
], $bundle->settingPreview('ui_menus'));
@@ -213,8 +264,8 @@ public function testSettingPreviewIsUnavailableForUnmarkedLegacySnapshot()
$bundle->addSettings('ui_dashboards', null);
$bundle->addSettings('ui_menus', json_encode(['id' => [9001]]));
$bundle->addMediaFromString(gzencode(json_encode([
- self::settingPayload('dashboard_package', 'dashboard-alpha', 'Alpha Dashboard'),
- self::settingPayload('menu_package', 'menu-alpha', 'Alpha Menu'),
+ self::settingPayload('dashboard_package', 'dashboard-alpha', self::ALPHA_DASHBOARD_NAME),
+ self::settingPayload('menu_package', 'menu-alpha', self::ALPHA_MENU_NAME),
])))
->usingFileName('payloads.json.gz')
->withCustomProperties(['version' => $bundle->version])
diff --git a/tests/Model/DevLinkTest.php b/tests/Model/DevLinkTest.php
index dc6ca7e332..f0efaf12d3 100644
--- a/tests/Model/DevLinkTest.php
+++ b/tests/Model/DevLinkTest.php
@@ -4,6 +4,7 @@
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
+use ProcessMaker\Exception\DevLinkRemoteBundleException;
use ProcessMaker\Models\Bundle;
use ProcessMaker\Models\DevLink;
use ProcessMaker\Models\Screen;
@@ -23,6 +24,10 @@ class DevLinkTest extends TestCase
private const SECOND_MENU_DESCRIPTION = 'Second menu description';
+ private const LOCAL_BUNDLE_API_PATH = 'local-bundles/123';
+
+ private const EXPORT_LOCAL_BUNDLE_API_PATH = 'export-local-bundle/123';
+
public function testGetClientUrl()
{
$devLink = DevLink::factory()->create([
@@ -86,8 +91,8 @@ public function testInstallRemoteBundle()
$bundle->delete();
Http::fake([
- self::remoteApiUrl('local-bundles/123') => Http::response(self::remoteBundleResponse('5')),
- self::remoteApiUrl('export-local-bundle/123') => Http::response([
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::response(self::remoteBundleResponse('5')),
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::response([
'payloads' => $exports,
]),
self::remoteApiUrl('export-local-bundle/123/settings') => Http::response([
@@ -123,6 +128,40 @@ public function testInstallRemoteBundle()
$this->assertCount(3, $payloads);
}
+ public function testInstallRemoteBundleReportsUnavailableRemoteAssets()
+ {
+ Http::preventStrayRequests();
+ Http::fake([
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::response(self::remoteBundleResponse('5')),
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::response([
+ 'error' => [
+ 'code' => 422,
+ 'message' => 'The bundle contains unavailable assets.',
+ ],
+ 'errors' => [
+ 'assets' => [[
+ 'bundle_asset_id' => 34,
+ 'asset_type' => 'ProcessMaker\Plugins\Collections\Models\Collection',
+ 'asset_id' => 20,
+ 'integrity_status' => 'missing',
+ ]],
+ ],
+ ], 422),
+ ]);
+
+ $devLink = DevLink::factory()->create([
+ 'url' => self::REMOTE_INSTANCE_URL,
+ ]);
+
+ $this->expectException(DevLinkRemoteBundleException::class);
+ $this->expectExceptionMessage(
+ 'The remote bundle contains unavailable assets: Collection #20 (bundle asset #34). ' .
+ 'Repair the bundle on the source instance and try again.'
+ );
+
+ $devLink->installRemoteBundle(123, 'update');
+ }
+
public function testInstallRemoteBundleImportsAndReinstallsEverySelectedMenu()
{
if (!hasPackage('package-dynamic-ui')) {
@@ -310,13 +349,13 @@ public function testUpdateBundle()
]);
Http::fake([
- self::remoteApiUrl('local-bundles/123') => Http::sequence()
+ self::remoteApiUrl(self::LOCAL_BUNDLE_API_PATH) => Http::sequence()
->push(self::remoteBundleResponse('2'), 200)
->push(self::remoteBundleResponse('3'), 200)
->push(self::remoteBundleResponse('4'), 200)
->push(self::remoteBundleResponse('8'), 200)
->push(self::remoteBundleResponse('9'), 200),
- self::remoteApiUrl('export-local-bundle/123') => Http::sequence()
+ self::remoteApiUrl(self::EXPORT_LOCAL_BUNDLE_API_PATH) => Http::sequence()
->push([
'payloads' => $exports,
], 200)
From 46df95a1d270462f1b303c3aa930a34940b4a75f Mon Sep 17 00:00:00 2001
From: Eleazar Resendez
Date: Mon, 27 Jul 2026 09:30:41 -0600
Subject: [PATCH 26/52] FOUR-32397: Add Smart Extract configuration fallback
---
ProcessMaker/ScriptRunners/Base.php | 12 ++++
.../ScriptMicroserviceRunner.php | 13 +++-
.../Services/SmartExtractConfiguration.php | 29 +++++++-
.../SmartExtractEnvironmentVariablesTest.php | 71 +++++++++++++++++++
.../SmartExtractConfigurationTest.php | 59 +++++++++++++++
5 files changed, 180 insertions(+), 4 deletions(-)
diff --git a/ProcessMaker/ScriptRunners/Base.php b/ProcessMaker/ScriptRunners/Base.php
index f24d4e2a0f..8d6b767053 100644
--- a/ProcessMaker/ScriptRunners/Base.php
+++ b/ProcessMaker/ScriptRunners/Base.php
@@ -12,6 +12,7 @@
use ProcessMaker\Models\ScriptDockerNayraTrait;
use ProcessMaker\Models\ScriptExecutor;
use ProcessMaker\Models\User;
+use ProcessMaker\Services\SmartExtractConfiguration;
use RuntimeException;
abstract class Base
@@ -177,6 +178,10 @@ private function getEnvironmentVariables($useEscape = true)
foreach ($variables as $variable) {
// Fix variables that have spaces
$variable['name'] = str_replace(' ', '_', $variable['name']);
+ if ($variable['name'] === SmartExtractConfiguration::API_HOST) {
+ continue;
+ }
+
if ($useEscape) {
$variablesParameter[] = escapeshellarg($variable['name']) . '=' . escapeshellarg($variable['value']);
} else {
@@ -185,6 +190,13 @@ private function getEnvironmentVariables($useEscape = true)
}
});
+ $smartExtractApiHost = app(SmartExtractConfiguration::class)->apiHost();
+ if ($smartExtractApiHost !== null) {
+ $variablesParameter[] = $useEscape
+ ? SmartExtractConfiguration::API_HOST . '=' . escapeshellarg($smartExtractApiHost)
+ : SmartExtractConfiguration::API_HOST . '=' . $smartExtractApiHost;
+ }
+
// Add the url to the host
if ($useEscape) {
$variablesParameter[] = 'HOST_URL=' . escapeshellarg(config('app.docker_host_url'));
diff --git a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php
index ad3b4f8690..dc4d5f3a96 100644
--- a/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php
+++ b/ProcessMaker/ScriptRunners/ScriptMicroserviceRunner.php
@@ -13,6 +13,7 @@
use ProcessMaker\Models\Script;
use ProcessMaker\Models\User;
use ProcessMaker\Services\ScriptMicroserviceService;
+use ProcessMaker\Services\SmartExtractConfiguration;
use stdClass;
class ScriptMicroserviceRunner
@@ -85,10 +86,20 @@ private function getEnvironmentVariables(User $user)
EnvironmentVariable::chunk(50, function (Collection $variables) use (&$variablesParameter) {
foreach ($variables as $variable) {
// Fix variables that have spaces
- $variablesParameter[str_replace(' ', '_', $variable->name)] = $variable->value;
+ $name = str_replace(' ', '_', $variable->name);
+ if ($name === SmartExtractConfiguration::API_HOST) {
+ continue;
+ }
+
+ $variablesParameter[$name] = $variable->value;
}
});
+ $smartExtractApiHost = app(SmartExtractConfiguration::class)->apiHost();
+ if ($smartExtractApiHost !== null) {
+ $variablesParameter[SmartExtractConfiguration::API_HOST] = $smartExtractApiHost;
+ }
+
// Add the url to the host
$variablesParameter['HOST_URL'] = config('app.docker_host_url');
diff --git a/ProcessMaker/Services/SmartExtractConfiguration.php b/ProcessMaker/Services/SmartExtractConfiguration.php
index 5ddf03bb98..a55146c56b 100644
--- a/ProcessMaker/Services/SmartExtractConfiguration.php
+++ b/ProcessMaker/Services/SmartExtractConfiguration.php
@@ -16,6 +16,14 @@ class SmartExtractConfiguration
public const HITL_ENABLED = 'SMART_EXTRACT_HITL_ENABLED';
+ private const CONFIG_KEYS = [
+ self::API_HOST => 'smart-extract.api_host',
+ self::CLIENT_ID => 'smart-extract.client_id',
+ self::CLIENT_SECRET => 'smart-extract.client_secret',
+ self::DASHBOARD_URL => 'smart-extract.dashboard_url',
+ self::HITL_ENABLED => 'smart-extract.hitl_enabled',
+ ];
+
private ?array $values = null;
public function apiHost(): ?string
@@ -40,9 +48,13 @@ public function dashboardUrl(): ?string
public function hitlEnabled(): bool
{
- $value = $this->stringValue(self::HITL_ENABLED);
+ $value = $this->value(self::HITL_ENABLED);
+
+ if (is_bool($value)) {
+ return $value;
+ }
- if ($value === null) {
+ if (!is_string($value)) {
return false;
}
@@ -51,11 +63,22 @@ public function hitlEnabled(): bool
private function stringValue(string $name): ?string
{
- $value = $this->values()[$name] ?? null;
+ $value = $this->value($name);
return is_string($value) && trim($value) !== '' ? $value : null;
}
+ private function value(string $name): mixed
+ {
+ $values = $this->values();
+
+ if (array_key_exists($name, $values)) {
+ return $values[$name];
+ }
+
+ return config(self::CONFIG_KEYS[$name]);
+ }
+
private function values(): array
{
if ($this->values !== null) {
diff --git a/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php b/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php
index 2d361a19e4..2cb283786e 100644
--- a/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php
+++ b/tests/unit/ProcessMaker/ScriptRunners/SmartExtractEnvironmentVariablesTest.php
@@ -58,6 +58,77 @@ public function test_microservice_runner_preserves_database_api_host(): void
);
}
+ public function test_both_runners_propagate_the_legacy_api_host_fallback(): void
+ {
+ config(['smart-extract.api_host' => 'https://legacy.example.com']);
+
+ $executor = ScriptExecutor::factory()->create(['language' => 'php']);
+ $localRunner = new class ($executor) extends Base {
+ public function config($code, array $dockerConfig)
+ {
+ return $dockerConfig;
+ }
+ };
+ $localMethod = new ReflectionMethod(Base::class, 'getEnvironmentVariables');
+ $localMethod->setAccessible(true);
+ $localVariables = $localMethod->invoke($localRunner, false);
+
+ $this->assertSame([
+ SmartExtractConfiguration::API_HOST . '=https://legacy.example.com',
+ ], array_values(array_filter(
+ $localVariables,
+ fn (string $variable) => str_starts_with($variable, SmartExtractConfiguration::API_HOST . '=')
+ )));
+
+ $script = Script::factory()->create(['language' => 'php']);
+ $user = User::factory()->create();
+ Cache::put('script-runner-' . $user->id, 'access-token');
+ $microserviceRunner = new ScriptMicroserviceRunner($script);
+ $microserviceMethod = new ReflectionMethod(ScriptMicroserviceRunner::class, 'getEnvironmentVariables');
+ $microserviceMethod->setAccessible(true);
+ $microserviceVariables = $microserviceMethod->invoke($microserviceRunner, $user);
+
+ $this->assertSame(
+ 'https://legacy.example.com',
+ $microserviceVariables[SmartExtractConfiguration::API_HOST]
+ );
+ }
+
+ public function test_existing_empty_database_api_host_suppresses_fallback_in_both_runners(): void
+ {
+ config(['smart-extract.api_host' => 'https://legacy.example.com']);
+ EnvironmentVariable::factory()->create([
+ 'name' => SmartExtractConfiguration::API_HOST,
+ 'value' => '',
+ ]);
+
+ $executor = ScriptExecutor::factory()->create(['language' => 'php']);
+ $localRunner = new class ($executor) extends Base {
+ public function config($code, array $dockerConfig)
+ {
+ return $dockerConfig;
+ }
+ };
+ $localMethod = new ReflectionMethod(Base::class, 'getEnvironmentVariables');
+ $localMethod->setAccessible(true);
+ $localVariables = $localMethod->invoke($localRunner, false);
+
+ $this->assertSame([], array_values(array_filter(
+ $localVariables,
+ fn (string $variable) => str_starts_with($variable, SmartExtractConfiguration::API_HOST . '=')
+ )));
+
+ $script = Script::factory()->create(['language' => 'php']);
+ $user = User::factory()->create();
+ Cache::put('script-runner-' . $user->id, 'access-token');
+ $microserviceRunner = new ScriptMicroserviceRunner($script);
+ $microserviceMethod = new ReflectionMethod(ScriptMicroserviceRunner::class, 'getEnvironmentVariables');
+ $microserviceMethod->setAccessible(true);
+ $microserviceVariables = $microserviceMethod->invoke($microserviceRunner, $user);
+
+ $this->assertArrayNotHasKey(SmartExtractConfiguration::API_HOST, $microserviceVariables);
+ }
+
private function createApiHost(): void
{
EnvironmentVariable::factory()->create([
diff --git a/tests/unit/ProcessMaker/Services/SmartExtractConfigurationTest.php b/tests/unit/ProcessMaker/Services/SmartExtractConfigurationTest.php
index bcebe58d0a..63af1793e6 100644
--- a/tests/unit/ProcessMaker/Services/SmartExtractConfigurationTest.php
+++ b/tests/unit/ProcessMaker/Services/SmartExtractConfigurationTest.php
@@ -32,6 +32,14 @@ public function test_it_loads_and_decrypts_all_values_with_one_query(): void
public function test_missing_empty_and_invalid_values_fail_closed(): void
{
+ config([
+ 'smart-extract.api_host' => null,
+ 'smart-extract.client_id' => null,
+ 'smart-extract.client_secret' => null,
+ 'smart-extract.dashboard_url' => null,
+ 'smart-extract.hitl_enabled' => false,
+ ]);
+
EnvironmentVariable::factory()->create([
'name' => SmartExtractConfiguration::HITL_ENABLED,
'value' => 'not-a-boolean',
@@ -50,6 +58,57 @@ public function test_missing_empty_and_invalid_values_fail_closed(): void
$this->assertNull($configuration->dashboardUrl());
}
+ public function test_missing_database_values_fall_back_to_legacy_configuration(): void
+ {
+ config([
+ 'smart-extract.api_host' => 'https://legacy.example.com',
+ 'smart-extract.client_id' => 'legacy-client-id',
+ 'smart-extract.client_secret' => 'legacy-client-secret',
+ 'smart-extract.dashboard_url' => 'https://legacy.example.com/edit.html',
+ 'smart-extract.hitl_enabled' => true,
+ ]);
+
+ $configuration = new SmartExtractConfiguration();
+
+ $this->assertSame('https://legacy.example.com', $configuration->apiHost());
+ $this->assertSame('legacy-client-id', $configuration->clientId());
+ $this->assertSame('legacy-client-secret', $configuration->clientSecret());
+ $this->assertSame('https://legacy.example.com/edit.html', $configuration->dashboardUrl());
+ $this->assertTrue($configuration->hitlEnabled());
+ }
+
+ public function test_existing_database_values_never_revive_the_legacy_fallback(): void
+ {
+ config([
+ 'smart-extract.api_host' => 'https://legacy.example.com',
+ 'smart-extract.client_id' => 'legacy-client-id',
+ 'smart-extract.client_secret' => 'legacy-client-secret',
+ 'smart-extract.dashboard_url' => 'https://legacy.example.com/edit.html',
+ 'smart-extract.hitl_enabled' => true,
+ ]);
+
+ foreach ([
+ SmartExtractConfiguration::API_HOST => '',
+ SmartExtractConfiguration::CLIENT_ID => null,
+ SmartExtractConfiguration::CLIENT_SECRET => false,
+ SmartExtractConfiguration::DASHBOARD_URL => ' ',
+ SmartExtractConfiguration::HITL_ENABLED => 'false',
+ ] as $name => $value) {
+ EnvironmentVariable::factory()->create([
+ 'name' => $name,
+ 'value' => $value,
+ ]);
+ }
+
+ $configuration = new SmartExtractConfiguration();
+
+ $this->assertNull($configuration->apiHost());
+ $this->assertNull($configuration->clientId());
+ $this->assertNull($configuration->clientSecret());
+ $this->assertNull($configuration->dashboardUrl());
+ $this->assertFalse($configuration->hitlEnabled());
+ }
+
public function test_scoped_configuration_refreshes_on_the_next_lifecycle(): void
{
$apiHost = EnvironmentVariable::factory()->create([
From 5edb691636838fada53c1903b56c1597b93f9f70 Mon Sep 17 00:00:00 2001
From: Rodrigo
Date: Mon, 27 Jul 2026 12:24:42 -0400
Subject: [PATCH 27/52] fix(FOUR-29427): Menu Style Discrepancy Between
Homepage and Request View
---
resources/js/Mobile/FilterMobile.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/resources/js/Mobile/FilterMobile.vue b/resources/js/Mobile/FilterMobile.vue
index c7452454a0..1df7b30c56 100644
--- a/resources/js/Mobile/FilterMobile.vue
+++ b/resources/js/Mobile/FilterMobile.vue
@@ -269,7 +269,7 @@ export default {
},
};
-
diff --git a/resources/js/components/templates/WizardHelperProcessModal.vue b/resources/js/components/templates/WizardHelperProcessModal.vue
deleted file mode 100644
index 5e141ccccd..0000000000
--- a/resources/js/components/templates/WizardHelperProcessModal.vue
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/resources/js/components/templates/WizardTemplateCard.vue b/resources/js/components/templates/WizardTemplateCard.vue
deleted file mode 100644
index 06f3737ba3..0000000000
--- a/resources/js/components/templates/WizardTemplateCard.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
-

-
-
-
- {{ template.name | str_limit(30) }}
- {{ template.description | str_limit(150) }}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/resources/js/components/templates/WizardTemplateDetails.vue b/resources/js/components/templates/WizardTemplateDetails.vue
deleted file mode 100644
index 28d39a1971..0000000000
--- a/resources/js/components/templates/WizardTemplateDetails.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ templateDetails['modal-title']| str_limit(30) }}
-
{{ templateDetails['modal-excerpt'] | str_limit(150) }}
-
-
-
-
-
{{ item | str_limit(150) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/resources/js/components/templates/mixins/wizardHelperProcessModal.js b/resources/js/components/templates/mixins/wizardHelperProcessModal.js
deleted file mode 100644
index a7be3a36a7..0000000000
--- a/resources/js/components/templates/mixins/wizardHelperProcessModal.js
+++ /dev/null
@@ -1,144 +0,0 @@
-export default {
- data() {
- return {
- task: null,
- currentUserId: null,
- formData: {},
- importingProcessTemplate: false,
- };
- },
- methods: {
- getHelperProcessStartEvent(triggeredBy = null) {
- if (triggeredBy === "wizard-details-modal") {
- this.startEvents = this.template.process.start_events
- .filter((event) => !event.eventDefinitions || event.eventDefinitions.length === 0);
- this.helperProcessId = this.template.process.id;
-
- this.triggerHelperProcessStartEvent();
- } else if (this.wizardTemplateUuid !== null) {
- ProcessMaker.apiClient.get(`wizard-templates/${this.wizardTemplateUuid}/get-helper-process`)
- .then((response) => {
- if (response.data) {
- this.helperProcessId = response.data.helper_process_id;
- this.startEvents = JSON.parse(response.data.start_events)
- .filter((event) => !event.eventDefinitions || event.eventDefinitions.length === 0);
-
- this.triggerHelperProcessStartEvent();
- }
- });
- }
- },
- triggerHelperProcessStartEvent() {
- const startEventId = this.startEvents[0].id;
- const url = `/process_events/${this.helperProcessId}?event=${startEventId}`;
- // Start the helper process
- window.ProcessMaker.apiClient.post(url, {
- process_launchpad_id: this.processLaunchpadId ? this.processLaunchpadId : null
- }).then((response) => {
- const processRequestId = response.data.id;
- this.getFirstTask(processRequestId);
- }).catch((error) => {
- ProcessMaker.alert(error.message, "danger");
- });
- },
- getFirstTask(processRequestId) {
- ProcessMaker.apiClient.get("tasks", {
- params: {
- page: 1,
- include: "user,assignableUsers",
- process_request_id: processRequestId,
- status: "ACTIVE",
- per_page: 10,
- order_by: "due_at",
- order_direction: "asc",
- },
- }).then((response) => {
- const [firstTask] = response.data.data;
- if (firstTask) {
- this.task = firstTask;
- this.currentUserId = parseInt(document.head.querySelector("meta[name=\"user-id\"]").content, 10);
- this.$bvModal.show("processWizard");
- this.showHelperProcess = true;
- } else {
- // No task found close modal
- this.showHelperProcess = false;
- this.close();
- }
- }).catch((error) => {
- ProcessMaker.alert(error.message, "danger");
- });
- },
- close() {
- this.$bvModal.hide("processWizard");
- // Cancels the associated process request to prevent orphaned processes.
- this.cancelHelperProcessRequest();
- },
- cancelHelperProcessRequest() {
- const processRequestId = this.task.process_request.id;
- ProcessMaker.apiClient.put(`requests/${processRequestId}`, {
- status: "CANCELED",
- }).then(() => {
- this.showHelperProcess = false;
- }).catch((error) => {
- ProcessMaker.alert(error.message, "danger");
- });
- },
- taskUpdated(task) {
- this.task = task;
- },
- completed() {
- if (!this.importingProcessTemplate && this.shouldImportProcessTemplate) {
- this.importProcessTemplate();
- } else if (!this.importingProcessTemplate && !this.shouldImportProcessTemplate) {
- this.showHelperProcess = false;
- this.$bvModal.hide("processWizard");
- }
- },
- submit(task) {
- const { id: taskId } = task;
- ProcessMaker.apiClient.put(`tasks/${taskId}`, {
- status: "COMPLETED",
- data: this.formData,
- }).catch((error) => {
- ProcessMaker.alert(error.message, "danger");
- });
- },
- importProcessTemplate() {
- this.importingProcessTemplate = true;
- ProcessMaker.apiClient.post(`template/create/process/${this.template.process_template_id}`, {
- name: this.template.name,
- description: this.template.description,
- version: "1.0.0", // TODO: Wizards should have a versions property
- process_category_id: this.template.process.process_category_id,
- projects: null,
- wizardTemplateUuid: this.template.uuid,
- helperProcessRequestId: this.task.process_request_id,
- }).then((response) => {
- this.importingProcessTemplate = false;
- if (response.data?.existingAssets) {
- this.handleExistingAssets(response.data);
- } else {
- // redirect to the new process launchpad
- window.location = `/process-browser/${response.data.processId}`;
- }
- }).catch((error) => {
- ProcessMaker.alert(error.message, "danger");
- });
- },
- handleExistingAssets(data) {
- // Use local storage to pass the data to the assets page.
- const stateData = {
- assets: JSON.stringify(data.existingAssets),
- name: this.template.name,
- responseId: data.id,
- request: JSON.stringify(data.request),
- redirectTo: "process-launchpad",
- wizardTemplateUuid: this.template.uuid,
- };
- localStorage.setItem("templateAssetsState", JSON.stringify(stateData));
-
- // Redirect to the assets page.
- window.location = "/template/assets";
- },
- },
-};
diff --git a/resources/js/processes-catalogue/components/CatalogueEmpty.vue b/resources/js/processes-catalogue/components/CatalogueEmpty.vue
index 6e5a5994a3..5555e7b4ae 100644
--- a/resources/js/processes-catalogue/components/CatalogueEmpty.vue
+++ b/resources/js/processes-catalogue/components/CatalogueEmpty.vue
@@ -21,7 +21,7 @@
@@ -39,16 +39,13 @@ export default {
components: { EmptySearch },
props: ["showEmpty", "isBookmarkEmpty"],
methods: {
- /**
- * go to wizard templates section
- */
- wizardLinkSelected() {
+ showTemplates() {
window.ProcessMaker.EventBus.$emit(
- "wizard-templates-selected",
+ "all-templates-selected",
{
- label: this.$t("Guided Templates"),
+ label: this.$t("All Templates"),
selected: false,
- id: "guided_templates",
+ id: "all_templates",
},
);
},
diff --git a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue
index 2b076d345a..fc491ef35c 100644
--- a/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue
+++ b/resources/js/processes-catalogue/components/ProcessCollapseInfo.vue
@@ -154,15 +154,9 @@ export default {
};
},
computed: {
- createdFromWizardTemplate() {
- return !!this.process?.properties?.wizardTemplateUuid;
- },
isArchived() {
return this.process?.status === "ARCHIVED";
},
- wizardTemplateUuid() {
- return this.process?.properties?.wizardTemplateUuid;
- },
},
mounted() {
this.verifyDescription();
@@ -200,9 +194,6 @@ export default {
activateReadMore() {
this.readActivated = true;
},
- getHelperProcess() {
- this.$refs.wizardHelperProcessModal.getHelperProcessStartEvent();
- },
toggleInfo() {
this.showProcessInfo = !this.showProcessInfo;
this.$emit("toggle-info");
@@ -261,26 +252,10 @@ export default {
.custom-class {
margin-top: -13px;
}
-.wizard-link {
- text-transform: none;
-}
-.wizard-container {
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-.wizard {
- display: flex;
- justify-items: end;
- width: 294px;
-}
@media (width < 1200px) {
.process-options {
margin-top: 32px;
}
- .wizard {
- width: 170px;
- }
}
@media (1460px <= width < 1600px) {
.col-pm-9 {
diff --git a/resources/js/processes-catalogue/components/ProcessHeader.vue b/resources/js/processes-catalogue/components/ProcessHeader.vue
deleted file mode 100644
index 89745b9a85..0000000000
--- a/resources/js/processes-catalogue/components/ProcessHeader.vue
+++ /dev/null
@@ -1,275 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ $t("Process Info") }}
-
-
-
-
-
- {{ $t("Process Info") }}
-
-
-
-
-
-
-

-
- {{ $t('Re-run Wizard') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/processes-catalogue/components/ProcessInfo.vue b/resources/js/processes-catalogue/components/ProcessInfo.vue
index 689ed743b2..309d9eec04 100644
--- a/resources/js/processes-catalogue/components/ProcessInfo.vue
+++ b/resources/js/processes-catalogue/components/ProcessInfo.vue
@@ -25,8 +25,6 @@
:title="title"
:process="process"
:full-carousel="fullCarousel"
- :is-wizard-template="createdFromWizardTemplate"
- @getHelperProcess="getHelperProcess"
@closeCarousel="closeFullCarousel"
@close="closeProcessInfo"
>
@@ -44,13 +42,6 @@