diff --git a/ProcessMaker/Console/Commands/TransitionExecutors.php b/ProcessMaker/Console/Commands/TransitionExecutors.php index b28eb5e940..390405c0bd 100644 --- a/ProcessMaker/Console/Commands/TransitionExecutors.php +++ b/ProcessMaker/Console/Commands/TransitionExecutors.php @@ -53,7 +53,7 @@ public function handle(): int // Get the optional timeout $timeout = $this->option('timeout') ?? 300; - if ((int)$timeout <= 60) { + if ((int) $timeout <= 60) { $this->error('Timeout must be a greater or equal to 60'); return 1; @@ -70,8 +70,7 @@ public function handle(): int return 0; } - $client = new Client($url); - $client->setTimeout($timeout); + $client = $this->createBroadcastClient($url, (int) $timeout); $index = 0; $total = $executors->count(); @@ -83,14 +82,14 @@ public function handle(): int try { $response = $this->scriptMicroserviceService->updateCustomExecutor($executors[$index]); Log::debug('Response', ['response' => $response]); - $status = strtolower((string)($response['status'] ?? '')); + $status = strtolower((string) ($response['status'] ?? '')); // Send subscription message (Pusher protocol example) $client->send(json_encode([ 'event' => 'pusher:subscribe', 'data' => [ - 'channel' => "build-image-" . $executors[$index]->uuid - ] + 'channel' => 'build-image-' . $executors[$index]->uuid, + ], ])); $running = true; @@ -118,7 +117,6 @@ public function handle(): int if (!$error) { $this->info("Executor {$executors[$index]->uuid} transitioned successfully." . PHP_EOL); } - } catch (RequestException $e) { $this->error("Request failed for executor {$executors[$index]->uuid}"); $this->line(PHP_EOL); @@ -131,49 +129,58 @@ public function handle(): int $client->close(); $index++; - } while ($index < $total); } - return 0; } private function getExecutors($uuid): Collection { $query = ScriptExecutor::query() - ->where("is_system", 0) + ->where('is_system', 0) ->where(function ($query) { $query - ->whereNotIn("title", [ - "PHP Executor", - "Node Executor", - "Python Executor", - "C# Executor", - "Java Executor" + ->whereNotIn('title', [ + 'PHP Executor', + 'Node Executor', + 'Python Executor', + 'C# Executor', + 'Java Executor', ]) - ->orWhereNotIn("description", [ - "Default PHP Executor", - "Default Javascript/Node Executor", - "Default Python Executor", - "Default C# Executor", - "Default Java Executor" + ->orWhereNotIn('description', [ + 'Default PHP Executor', + 'Default Javascript/Node Executor', + 'Default Python Executor', + 'Default C# Executor', + 'Default Java Executor', ]); }) - ->whereNotIn("language", ["php-nayra", "lua", "javascript-ssr", "sql"]) - ->whereNotNull("config") + ->whereNotIn('language', ['php-nayra', 'lua', 'javascript-ssr', 'sql']) + ->whereNotNull('config') ->where(function ($query) { $query - ->where("type", ScriptExecutorType::Custom) - ->orWhereNull("type"); + ->where('type', ScriptExecutorType::Custom) + ->orWhereNull('type'); }); if (is_array($uuid) && !empty($uuid)) { - $query->whereIn("uuid", $uuid); - } else if (is_string($uuid)) { - $query->where("uuid", $uuid); + $query->whereIn('uuid', $uuid); + } elseif (is_string($uuid)) { + $query->where('uuid', $uuid); } return $query->get(); } + + /** + * Create the websocket client used to listen for build events. + */ + protected function createBroadcastClient(string $url, int $timeout): Client + { + $client = new Client($url); + $client->setTimeout($timeout); + + return $client; + } } diff --git a/ProcessMaker/Managers/LayoutAssetManager.php b/ProcessMaker/Managers/LayoutAssetManager.php new file mode 100644 index 0000000000..a2c4222e69 --- /dev/null +++ b/ProcessMaker/Managers/LayoutAssetManager.php @@ -0,0 +1,58 @@ +resolveProfileName($request); + $profiles = config('layout-assets.profiles', []); + $defaultProfile = $profiles['default'] ?? null; + + if ($defaultProfile === null) { + throw new InvalidArgumentException('layout-assets.profiles.default is not configured.'); + } + + if ($profileName === 'default' || !isset($profiles[$profileName])) { + return array_merge($defaultProfile, ['profile' => 'default']); + } + + return array_merge($defaultProfile, $profiles[$profileName], ['profile' => $profileName]); + } + + /** + * Determine whether a boolean asset flag is enabled for the request. + */ + public function requires(string $asset, ?Request $request = null): bool + { + $profile = $this->forRequest($request); + + if (!array_key_exists($asset, $profile)) { + throw new InvalidArgumentException("Unknown layout asset flag: {$asset}"); + } + + return (bool) $profile[$asset]; + } + + /** + * Resolve profile name from route patterns in config. + */ + public function resolveProfileName(Request $request): string + { + foreach (config('layout-assets.routes', []) as $profileName => $patterns) { + if ($request->is(...$patterns)) { + return $profileName; + } + } + + return 'default'; + } +} diff --git a/ProcessMaker/Services/ScriptMicroserviceService.php b/ProcessMaker/Services/ScriptMicroserviceService.php index b3a8a114ce..87770bf16e 100644 --- a/ProcessMaker/Services/ScriptMicroserviceService.php +++ b/ProcessMaker/Services/ScriptMicroserviceService.php @@ -195,6 +195,7 @@ public function getScriptRunner(string $language, string $executorUuid, bool $cu if (Cache::has($cacheKey)) { Log::debug('Cache hit for script runner', ['cacheKey' => $cacheKey]); + return Cache::get($cacheKey); } @@ -211,7 +212,9 @@ public function getScriptRunner(string $language, string $executorUuid, bool $cu return isset($item['language'], $item['id']) && $item['language'] === $language && $item['id'] === $executorUuid; })->first(); - if (!empty($result)) Cache::put($cacheKey, $result, now()->addHour()); + if (!empty($result)) { + Cache::put($cacheKey, $result, now()->addHour()); + } return $result; } diff --git a/config/layout-assets.php b/config/layout-assets.php new file mode 100644 index 0000000000..4b67808e25 --- /dev/null +++ b/config/layout-assets.php @@ -0,0 +1,45 @@ + [ + 'default' => [ + 'app' => 'js/app.js', + 'app_layout' => 'js/app-layout.js', + 'modeler_vendor' => true, + 'monaco' => true, + ], + 'inbox' => [ + 'app' => 'js/app-core.js', + 'app_layout' => 'js/app-layout-core.js', + 'modeler_vendor' => false, + 'monaco' => false, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Route to profile mapping + |-------------------------------------------------------------------------- + | + | Keys are profile names. Values are patterns accepted by Request::is(). + | First matching profile wins; unmatched routes use "default". + | + */ + 'routes' => [ + 'inbox' => [ + 'inbox', + 'inbox/*', + 'tasks', + ], + ], +]; diff --git a/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php index 370df325dd..eff0bacc73 100644 --- a/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php +++ b/database/migrations/2026_07_20_000000_add_asset_link_to_environment_variables_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { /** * Run the migrations. */ diff --git a/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php index 122397d593..7ed1231a11 100644 --- a/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php +++ b/database/migrations/2026_07_23_000000_add_do_not_update_to_environment_variables_table.php @@ -4,8 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { /** * Run the migrations. */ diff --git a/helpers.php b/helpers.php index cfcc0bc6cf..1fb04733cf 100644 --- a/helpers.php +++ b/helpers.php @@ -207,6 +207,16 @@ function hasPackage($name) } } +if (!function_exists('layoutAssets')) { + /** + * Resolve layout JavaScript asset requirements for the current or given request. + */ + function layoutAssets(?Illuminate\Http\Request $request = null): array + { + return app(ProcessMaker\Managers\LayoutAssetManager::class)->forRequest($request); + } +} + if (!function_exists('pmUser')) { /** * Check both the web and api middleware for an existing user. diff --git a/resources/js/app-core.js b/resources/js/app-core.js new file mode 100644 index 0000000000..dd8532461e --- /dev/null +++ b/resources/js/app-core.js @@ -0,0 +1 @@ +require("./bootstrap-core"); diff --git a/resources/js/app-layout-core.js b/resources/js/app-layout-core.js new file mode 100644 index 0000000000..9cda952db0 --- /dev/null +++ b/resources/js/app-layout-core.js @@ -0,0 +1,399 @@ +import { BNavbar } from "bootstrap-vue"; +import { Multiselect } from "@processmaker/vue-multiselect"; +import moment from "moment-timezone"; +import { sanitizeUrl } from "@braintree/sanitize-url"; +import VueHtml2Canvas from "vue-html2canvas"; +import newRequestModal from "./components/requests/requestModal"; +import requestModal from "./components/requests/modal"; +import requestModalMobile from "./components/requests/modalMobile"; +import notifications from "./notifications/components/notifications"; +import sessionModal from "./components/Session"; +import Sidebaricon from "./components/Sidebaricon"; +import ConfirmationModal from "./components/Confirm"; +import MessageModal from "./components/Message"; +import NavbarProfile from "./components/NavbarProfile"; +import SelectStatus from "./components/SelectStatus"; +import SelectUser from "./components/SelectUser"; +import SelectUserGroup from "./components/SelectUserGroup"; +import CategorySelect from "./processes/categories/components/CategorySelect"; +import ProjectSelect from "./components/shared/ProjectSelect"; +import SelectFromApi from "./components/SelectFromApi"; +import Breadcrumbs from "./components/Breadcrumbs"; +import TimelineItem from "./components/TimelineItem"; +import Required from "./components/shared/Required"; +import WelcomeModal from "./Mobile/WelcomeModal"; +import Menu from "./components/Menu.vue"; +/** **** + * Global adjustment parameters for moment.js. + */ +import __ from "./modules/lang"; + +require("bootstrap"); + +const { Vue } = window; + +Vue.use(VueHtml2Canvas); + +if (window.ProcessMaker && window.ProcessMaker.user) { + moment.tz.setDefault(window.ProcessMaker.user.timezone); + moment.defaultFormat = window.ProcessMaker.user.datetime_format; + moment.defaultFormatUtc = window.ProcessMaker.user.datetime_format; +} +if (document.documentElement.lang) { + moment.locale(document.documentElement.lang); + window.ProcessMaker.user.lang = document.documentElement.lang; +} +Vue.prototype.moment = moment; +// initializing global instance of a moment object +window.moment = moment; +/** ***** */ + +Vue.prototype.$sanitize = sanitizeUrl; + +Vue.component("Multiselect", Multiselect); +Vue.component("Sidebaricon", Sidebaricon); +Vue.component("SelectStatus", SelectStatus); +Vue.component("SelectUser", SelectUser); +Vue.component("SelectUserGroup", SelectUserGroup); +Vue.component("CategorySelect", CategorySelect); +Vue.component("ProjectSelect", ProjectSelect); +Vue.component("SelectFromApi", SelectFromApi); +Vue.component("Breadcrumbs", Breadcrumbs); +Vue.component("TimelineItem", TimelineItem); +Vue.component("Required", Required); +Vue.component("Welcome", WelcomeModal); +Vue.component("LanguageSelectorButton", (resolve) => { + if (window.ProcessMaker.languageSelectorButtonComponent) { + resolve(window.ProcessMaker.languageSelectorButtonComponent); + } else { + window.ProcessMaker.languageSelectorButtonComponentResolve = resolve; + } +}); + +// Event bus ProcessMaker +window.ProcessMaker.events = new Vue(); + +// Verify if is mobile +const browser = navigator.userAgent; +const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(browser); +window.ProcessMaker.mobileApp = false; +if (isMobileDevice) { + window.ProcessMaker.mobileApp = true; +} + +// Verify is in mobile mode +const isMobileNavbar = window.ProcessMaker.events.$cookies.get("isMobile"); + +window.ProcessMaker.nodeTypes = []; +window.ProcessMaker.nodeTypes.get = function (id) { + return this.find((node) => node.id === id); +}; + +// Assign our navbar component to our global ProcessMaker object +window.ProcessMaker.navbar = new Vue({ + el: "#navbar", + components: { + TopMenu: Menu, + "b-navbar": BNavbar, + requestModal, + notifications, + sessionModal, + ConfirmationModal, + MessageModal, + NavbarProfile, + newRequestModal, + GlobalSearch: (resolve) => { + if (window.ProcessMaker.globalSearchComponent) { + resolve(window.ProcessMaker.globalSearchComponent); + } else { + window.ProcessMaker.globalSearchComponentResolve = resolve; + } + }, + }, + data() { + return { + screenBuilder: null, + messages: ProcessMaker.notifications, + alerts: this.loadLocalAlerts(), + confirmTitle: "", + confirmMessage: "", + confirmVariant: "", + confirmCallback: "", + confirmSize: "md", + confirmDataTestClose: "confirm-btn-close", + confirmDataTestOk: "confirm-btn-ok", + messageTitle: "", + messageMessage: "", + messageVariant: "", + messageCallback: "", + confirmShow: false, + sessionShow: false, + messageShow: false, + sessionTitle: "", + sessionMessage: "", + sessionTime: "", + sessionWarnSeconds: "", + sessionIsRenewing: false, + taskTitle: "", + isMobile: false, + isMobileDevice: window.ProcessMaker.mobileApp, + isNavbarExpanded: false, + }; + }, + watch: { + alerts(array) { + this.saveLocalAlerts(array); + }, + }, + mounted() { + Vue.nextTick() // This is needed to override the default alert method. + .then(() => { + this.onResize(); + window.addEventListener("resize", this.onResize, { passive: true }); + + if (document.querySelector("meta[name='alert']")) { + ProcessMaker.alert( + document.querySelector("meta[name='alertMessage']").getAttribute("content"), + document.querySelector("meta[name='alertVariant']").getAttribute("content"), + ); + } + const findSB = setInterval(() => { + this.screenBuilder = window.ProcessMaker.ScreenBuilder; + if (this.screenBuilder) { + clearInterval(findSB); + } + }, 80); + }); + }, + methods: { + alertDownChanged(dismissCountDown, item) { + item.alertShow = dismissCountDown; + this.saveLocalAlerts(this.alerts); + }, + alertDismissed(alert) { + alert.alertShow = 0; + const index = this.alerts.indexOf(alert); + let copy = _.cloneDeep(this.alerts); + index > -1 ? copy.splice(index, 1) : null; + // remove old alerts + copy = copy.filter((item) => ((Date.now() - item.timestamp) / 1000) < item.alertShow); + this.saveLocalAlerts(copy); + }, + loadLocalAlerts() { + try { + return window.localStorage.processmakerAlerts + && window.localStorage.processmakerAlerts.substr(0, 1) === "[" + ? JSON.parse(window.localStorage.processmakerAlerts) : []; + } catch (e) { + return []; + } + }, + saveLocalAlerts(array) { + const nextScreenAlerts = array.filter((alert) => alert.stayNextScreen); + window.localStorage.processmakerAlerts = JSON.stringify(nextScreenAlerts); + }, + switchToMobile() { + this.$cookies.set("isMobile", true); + window.open("/requests", "_self"); + }, + getRoutes() { + if (this.$refs.breadcrumbs) { + return this.$refs.breadcrumbs.list; + } + return []; + }, + setRoutes(routes) { + if (this.$refs.breadcrumbs) { + return this.$refs.breadcrumbs.updateRoutes(routes); + } + return false; + }, + onResize() { + this.isMobile = window.innerWidth < 992; + }, + toggleNavbar() { + this.isNavbarExpanded = !this.isNavbarExpanded; + }, + }, +}); + +// Assign our navbar component to our global ProcessMaker object +if (isMobileNavbar === "true") { + window.ProcessMaker.navbarMobile = new Vue({ + el: "#navbarMobile", + components: { + requestModalMobile, + WelcomeModal, + }, + data() { + return { + display: true, + }; + }, + mounted() { + if (this.$cookies.get("firstMounted") === "true") { + $("#welcomeModal").modal("show"); + } + }, + methods: { + switchToDesktop() { + this.$cookies.set("isMobile", false); + window.location.reload(); + }, + onResize() { + this.isMobile = window.innerWidth < 992; + }, + }, + }); +} + +// Breadcrumbs are now part of the navbar component. Alias it here. +window.ProcessMaker.breadcrumbs = window.ProcessMaker.navbar; + +// Set our own specific alert function at the ProcessMaker global object that could +// potentially be overwritten by some custom theme support +window.ProcessMaker.alert = function (msg, variant, showValue = 5, stayNextScreen = false, showLoader = false, msgLink = "", msgTitle = "") { + if (showValue === 0) { + // Just show it indefinitely, no countdown + showValue = true; + } + // amount of items allowed in array + if (ProcessMaker.navbar.alerts.length > 5) { + ProcessMaker.navbar.alerts.shift(); + } + ProcessMaker.navbar.alerts.push({ + alertText: msg, + alertLink: msgLink, + alertTitle: msgTitle, + alertShow: showValue, + alertVariant: String(variant), + showLoader, + stayNextScreen, + timestamp: Date.now(), + }); +}; + +// Setup our login modal +window.ProcessMaker.sessionModal = function (title, message, time, warnSeconds) { + ProcessMaker.navbar.sessionTitle = title || __("Session Warning"); + ProcessMaker.navbar.sessionMessage = message || __("Your session is about to expire."); + ProcessMaker.navbar.sessionTime = time; + ProcessMaker.navbar.sessionWarnSeconds = warnSeconds; + ProcessMaker.navbar.sessionShow = true; +}; + +window.ProcessMaker.closeSessionModal = function () { + ProcessMaker.navbar.sessionShow = false; + ProcessMaker.navbar.sessionIsRenewing = false; +}; + +// Set out own specific confirm modal. +window.ProcessMaker.confirmModal = function (title, message, variant, callback, size = "md", dataTestClose = "confirm-btn-close", dataTestOk = "confirm-btn-ok") { + ProcessMaker.navbar.confirmTitle = title || __("Confirm"); + ProcessMaker.navbar.confirmMessage = message || __("Are you sure you want to delete?"); + ProcessMaker.navbar.confirmVariant = variant; + ProcessMaker.navbar.confirmCallback = callback; + ProcessMaker.navbar.confirmShow = true; + ProcessMaker.navbar.confirmSize = size; + ProcessMaker.navbar.confirmDataTestClose = dataTestClose; + ProcessMaker.navbar.confirmDataTestOk = dataTestOk; +}; + +// Set out our specific message modal. +window.ProcessMaker.messageModal = function (title, message, variant, callback) { + ProcessMaker.navbar.messageTitle = title || __("Message"); + ProcessMaker.navbar.messageMessage = message || __(""); + ProcessMaker.navbar.messageVariant = variant; + ProcessMaker.navbar.messageCallback = callback; + ProcessMaker.navbar.messageShow = true; +}; + +// flags print forms +window.ProcessMaker.apiClient.requestCount = 0; +window.ProcessMaker.apiClient.requestCountFlag = false; + +window.ProcessMaker.apiClient.interceptors.request.use((request) => { + // flags print forms + if (window.ProcessMaker.apiClient.requestCountFlag) { + window.ProcessMaker.apiClient.requestCount++; + } + + window.ProcessMaker.EventBus.$emit("api-client-loading", request); + return request; +}); + +window.ProcessMaker.apiClient.interceptors.response.use((response) => { + // TODO: this could be used to show a default "created/upated/deleted resource" alert + // response.config.method (PUT, POST, DELETE) + // response.config.url (extract resource name) + window.ProcessMaker.EventBus.$emit("api-client-done", response); + // flags print forms + if (window.ProcessMaker.apiClient.requestCountFlag && window.ProcessMaker.apiClient.requestCount > 0) { + window.ProcessMaker.apiClient.requestCount--; + } + return response; +}, (error) => { + // Set in your .catch to false to not show the alert inside window.ProcessMaker.apiClient + if (!error?.response?.showAlert) { + return Promise.reject(error); + } + + if (error.code && error.code === "ERR_CANCELED") { + return Promise.reject(error); + } + window.ProcessMaker.EventBus.$emit("api-client-error", error); + if (error.response && error.response.status && error.response.status === 401) { + // stop 401 error consuming endpoints with data-sources + const { url } = error.config; + if (url.includes("/data_sources/")) { + if (url.includes("requests/") || url.includes("/test")) { + throw error; + } + } + window.location = "/login"; + } else { + if (_.has(error, "config.url") && !error.config.url.match("/debug")) { + window.ProcessMaker.apiClient.post("/debug", { + name: "Javascript ProcessMaker.apiClient Error", + message: JSON.stringify({ + message: error.message, + code: error.code, + config: error.config, + }), + }); + } + return Promise.reject(error); + } +}); + +// Display any uncaught promise rejections from axios in the Process Maker alert box +window.addEventListener("unhandledrejection", (event) => { + const error = event.reason; + if (error.config && error.config._defaultErrorShown) { + // Already handeled + event.preventDefault(); // stops the unhandled rejection error + } else if (error.response && error.response.data && error.response.data.message) { + if (!(error.code && error.code === "ECONNABORTED")) { + window.ProcessMaker.alert(error.response.data.message, "danger"); + } + } else if (error.message) { + if (!(error.code && error.code === "ECONNABORTED")) { + window.ProcessMaker.alert(error.message, "danger"); + } + } +}); + +new Vue({ + el: "#sidebar", + components: { + Sidebaricon, + }, + data() { + return { + expanded: false, + }; + }, + created() { + this.expanded === false; + }, +}); diff --git a/resources/js/bootstrap-base.js b/resources/js/bootstrap-base.js new file mode 100644 index 0000000000..d1b53c9937 --- /dev/null +++ b/resources/js/bootstrap-base.js @@ -0,0 +1,441 @@ +import "bootstrap-vue/dist/bootstrap-vue.css"; +import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue"; +import * as bootstrap from "bootstrap"; +import TenantAwareEcho from "./common/TenantAwareEcho"; +import { initSessionSync } from "./common/sessionSync"; +import { + applyCsrfToken, + attachCsrfRequestInterceptor, + attachSessionRenewalInterceptor, + getCsrfToken, +} from "./common/csrfToken"; +import Echo from "laravel-echo"; +import Pusher from "pusher-js"; +import Router from "vue-router"; +import ScreenBuilder, { initializeScreenCache } from "@processmaker/screen-builder"; +import * as VueDeepSet from "vue-deepset"; +/** + * Setup Translations + */ +import i18next from "i18next"; +import Backend from "i18next-chained-backend"; +import LocalStorageBackend from "i18next-localstorage-backend"; +import XHR from "i18next-xhr-backend"; +import VueI18Next from "@panter/vue-i18next"; +import { install as VuetableInstall } from "vuetable-2"; +import Vue from "vue"; +import * as vue from "vue"; +import VueCookies from "vue-cookies"; +import GlobalStore from "./globalStore"; +import Pagination from "./components/common/Pagination"; +import translator from "./modules/lang.js"; +import datetime_format from "./data/datetime_formats.json"; +import RequestChannel from "./tasks/components/ProcessRequestChannel"; +import Modal from "./components/shared/Modal"; +import AccessibilityMixin from "./components/common/mixins/accessibility"; +import PmqlInput from "./components/shared/PmqlInput.vue"; +import DataTreeToggle from "./components/common/data-tree-toggle.vue"; +import TreeView from "./components/TreeView.vue"; +import FilterTable from "./components/shared/FilterTable.vue"; +import PaginationTable from "./components/shared/PaginationTable.vue"; +import PMDropdownSuggest from "./components/PMDropdownSuggest"; +import "@processmaker/screen-builder/dist/vue-form-builder.css"; + +window.__ = translator; +window._ = require("lodash"); +window.Popper = require("popper.js").default; + +/** + * Give node plugins access to our custom screen builder components + */ +window.ProcessmakerComponents = require("./processes/screen-builder/components"); + +/** + * Give node plugins access to additional components + */ +window.SharedComponents = require("./components/shared"); + +window.ProcessesComponents = require("./processes/components"); +window.ScreensComponents = require("./processes/screens/components"); +window.ScriptsComponents = require("./processes/scripts/components"); +window.ProcessesCatalogueComponents = require("./processes-catalogue/components/utils"); +window.Utils = require("./utils"); + +window.PMDropdownSuggest = PMDropdownSuggest; + +window.Vue = Vue; +window.vue = vue; +window.bootstrap = bootstrap; +window.Vue.use(BootstrapVue); +window.Vue.use(BootstrapVueIcons); +window.Vue.use(ScreenBuilder); +window.Vue.use(GlobalStore); +window.Vue.use(VueDeepSet); +window.Vue.use(VueCookies); +if (!document.head.querySelector("meta[name=\"is-horizon\"]")) { + window.Vue.use(Router); +} + +window.ScreenBuilder = require("@processmaker/screen-builder"); +window.VueFormElements = require("@processmaker/vue-form-elements"); + +window.VueRouter = Router; + +/** + * We'll load jQuery and the Bootstrap jQuery plugin which provides support + * for JavaScript based Bootstrap features such as modals and tabs. This + * code may be modified to fit the specific needs of your application. + */ +window.$ = window.jQuery = require("jquery"); + +window.Vue.use(VueI18Next); +VuetableInstall(window.Vue); +window.Vue.component("pagination", Pagination); +window.Vue.component("pm-modal", Modal); +window.Vue.component("pmql-input", PmqlInput); +window.Vue.component("data-tree-toggle", DataTreeToggle); +window.Vue.component("tree-view", TreeView); +window.Vue.component("filter-table", FilterTable); +window.Vue.component("pagination-table", PaginationTable); + +let translationsLoaded = false; +const mdates = JSON.parse( + document.head.querySelector("meta[name=\"i18n-mdate\"]")?.content, +); + +// Make $t available to all vue instances +Vue.mixin({ i18n: new VueI18Next(i18next) }); +Vue.mixin(AccessibilityMixin); + +window.ProcessMaker = { + i18n: i18next, + + /** + * A general use global event bus that can be used + */ + EventBus: new Vue(), + /** + * A general use global router that can be used + */ + Router: new Router({ + mode: "history", + }), + /** + * ProcessMaker Notifications + */ + notifications: [], + /** + * Push a notification. + * + * @param {object} notification + * + * @returns {void} + */ + pushNotification(notification) { + if (!notification || window.ProcessMaker.notifications.some((x) => x.id === notification.id)) { + return; + } + window.ProcessMaker.notifications.push(notification); + }, + + /** + * Removes notifications by message ids or urls + * + * @returns {void} + * @param messageIds + * + * @param urls + */ + removeNotifications(messageIds = [], urls = []) { + return window.ProcessMaker.apiClient.put("/read_notifications", { message_ids: messageIds, routes: urls }).then(() => { + messageIds.forEach((messageId) => { + ProcessMaker.notifications.splice(ProcessMaker.notifications.findIndex((x) => x.id === messageId), 1); + }); + + urls.forEach((url) => { + const messageIndex = ProcessMaker.notifications.findIndex((x) => x.url === url); + if (messageIndex >= 0) { + ProcessMaker.removeNotification(ProcessMaker.notifications[messageIndex].id); + } + }); + }); + }, + /** + * Mark as unread a list of notifications + * + * @returns {void} + * @param messageIds + * + * @param urls + */ + unreadNotifications(messageIds = [], urls = []) { + return window.ProcessMaker.apiClient.put("/unread_notifications", { message_ids: messageIds, routes: urls }); + }, + + missingTranslations: new Set(), + missingTranslation(value) { + if (this.missingTranslations.has(value)) { return; } + this.missingTranslations.add(value); + if (!isProd) { + console.warn("Missing Translation:", value); + } + }, + + RequestChannel, + + $notifications: { + icons: {}, + }, +}; + +window.ProcessMaker.setValidatorLanguage = (validator, lang) => { + const availableLanguages = ["ar", "az", "be", "bg", "bs", "ca", "cs", "cy", "da", "de", "el", "en", "es", "et", "eu", "fa", "fi", + "fr", "hr", "hu", "id", "it", "ja", "ka", "km", "ko", "lt", "lv", "mk", "mn", "ms", "nb_NO", "nl", "pl", "pt", "pt_BR", "ro", "ru", + "se", "sl", "sq", "sr", "sv", "tr", "ua", "uk", "uz", "vi", "zh", "zh_TW"]; + const selectedLang = availableLanguages.includes(lang) ? lang : "en"; + if (validator) { + validator.useLang(selectedLang); + } +}; + +window.ProcessMaker.i18nPromise = i18next.use(Backend).init({ + lng: document.documentElement.lang, + fallbackLng: "en", // default language when no translations + returnEmptyString: false, // When a translation is an empty string, return the default language, not empty + nsSeparator: false, + keySeparator: false, + parseMissingKeyHandler(value) { + if (!translationsLoaded) { return value; } + // Report that a translation is missing + window.ProcessMaker.missingTranslation(value); + // Fallback to showing the english version + return value; + }, + backend: { + backends: [ + LocalStorageBackend, // Try cache first + XHR, + ], + backendOptions: [ + { versions: mdates }, + { loadPath: "/i18next/fetch/{{lng}}/_default" }, + ], + }, +}); + +window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); + +/** + * Create a axios instance which any vue component can bring in to call + * REST api endpoints through oauth authentication + * + */ +window.ProcessMaker.apiClient = require("axios"); + +window.ProcessMaker.apiClient.defaults.withCredentials = true; + +window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; + +const token = document.head.querySelector("meta[name=\"csrf-token\"]"); +const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; + +window.ProcessMaker.applyCsrfToken = applyCsrfToken; +window.ProcessMaker.getCsrfToken = getCsrfToken; +// Attach CSRF interceptor before other interceptors so it runs last in the axios chain. +attachCsrfRequestInterceptor(window.ProcessMaker.apiClient); + +/** + * Next we will register the CSRF Token as a common header with Axios so that + * all outgoing HTTP requests automatically have it attached. This is just + * a simple convenience so we don't have to attach every token manually. + */ + +if (token) { + applyCsrfToken(token.content, "page-load"); +} else { + console.error("CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token"); +} + +// Setup api versions +const apiVersionConfig = [ + { version: "1.0", baseURL: "/api/1.0/" }, + { version: "1.1", baseURL: "/api/1.1/" }, +]; + +window.ProcessMaker.apiClient.defaults.baseURL = apiVersionConfig[0].baseURL; +window.ProcessMaker.apiClient.interceptors.request.use((config) => { + if (typeof config.url !== "string" || !config.url) { + throw new Error("Invalid URL in the request configuration"); + } + + apiVersionConfig.forEach(({ version, baseURL }) => { + const versionPrefix = `/api/${version}/`; + if (config.url.startsWith(versionPrefix)) { + // eslint-disable-next-line no-param-reassign + config.baseURL = baseURL; + // eslint-disable-next-line no-param-reassign + config.url = config.url.replace(versionPrefix, ""); + } + }); + + return config; +}); + +attachSessionRenewalInterceptor(window.ProcessMaker.apiClient); + +// Set the default API timeout +let apiTimeout = 5000; +if (window.Processmaker && window.Processmaker.apiTimeout !== undefined) { + apiTimeout = window.Processmaker.apiTimeout; +} +window.ProcessMaker.apiClient.defaults.timeout = apiTimeout; + +// Default alert functionality +window.ProcessMaker.alert = function (text, variant) { + if (typeof text === "string") { + window.alert(text); + } +}; + +const openAiEnabled = document.head.querySelector("meta[name=\"open-ai-nlq-to-pmql\"]"); + +if (openAiEnabled) { + window.ProcessMaker.openAi = { + enabled: openAiEnabled.content, + }; +} else { + window.ProcessMaker.openAi = { + enabled: false, + }; +} + +const userID = document.head.querySelector("meta[name=\"user-id\"]"); +const userFullName = document.head.querySelector("meta[name=\"user-full-name\"]"); +const userAvatar = document.head.querySelector("meta[name=\"user-avatar\"]"); +const formatDate = document.head.querySelector("meta[name=\"datetime-format\"]"); +const timezone = document.head.querySelector("meta[name=\"timezone\"]"); +const appUrl = document.head.querySelector("meta[name=\"app-url\"]"); + +if (appUrl) { + window.ProcessMaker.app = { + url: appUrl.content, + }; +} + +if (userID) { + window.ProcessMaker.user = { + id: userID.content, + datetime_format: formatDate?.content, + calendar_format: formatDate?.content, + timezone: timezone?.content, + fullName: userFullName?.content, + avatar: userAvatar?.content, + }; + datetime_format.forEach((value) => { + if (formatDate.content === value.format) { + window.ProcessMaker.user.datetime_format = value.momentFormat; + window.ProcessMaker.user.calendar_format = value.calendarFormat; + } + }); +} + +if (window.Processmaker && window.Processmaker.broadcasting) { + const config = window.Processmaker.broadcasting; + + if (config.broadcaster == "pusher") { + window.Pusher = require("pusher-js"); + window.Pusher.logToConsole = config.debug; + } + + window.Echo = new TenantAwareEcho(config); +} + +if (window.Processmaker && window.Processmaker.script_microservice && window.Processmaker.script_microservice.enabled) { + const config = window.Processmaker.script_microservice.broadcasting; + + window.ScriptMicroserviceEcho = new Echo({ + ...config, + client: new Pusher(config.key, config), + }); +} + +if (userID) { + const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content; + const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content; + const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content); + const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); + const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; + const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1; + + const sessionSyncState = initSessionSync({ + userId: userID.content, + isProd, + timeoutScript, + accountTimeoutLength, + accountTimeoutWarnSeconds, + accountTimeoutEnabled, + Vue, + Echo: window.Echo, + pushNotification: window.ProcessMaker.pushNotification, + alert: window.ProcessMaker.alert, + getSessionModal: () => window.ProcessMaker.sessionModal, + getCloseSessionModal: () => window.ProcessMaker.closeSessionModal, + getNavbar: () => window.ProcessMaker.navbar, + }); + + if (sessionSyncState) { + window.ProcessMaker.AccountTimeoutLength = sessionSyncState.AccountTimeoutLength; + window.ProcessMaker.AccountTimeoutWarnSeconds = sessionSyncState.AccountTimeoutWarnSeconds; + window.ProcessMaker.AccountTimeoutWarnMinutes = sessionSyncState.AccountTimeoutWarnMinutes; + window.ProcessMaker.AccountTimeoutEnabled = sessionSyncState.AccountTimeoutEnabled; + window.ProcessMaker.AccountTimeoutWorker = sessionSyncState.AccountTimeoutWorker; + window.ProcessMaker.sessionSync = sessionSyncState.sessionSync; + } +} + +// Configuration Global object used by ScreenBuilder +// @link https://processmaker.atlassian.net/browse/FOUR-6833 Cache configuration +const screenCacheEnabled = document.head.querySelector("meta[name=\"screen-cache-enabled\"]")?.content ?? "false"; +const screenCacheTimeout = document.head.querySelector("meta[name=\"screen-cache-timeout\"]")?.content ?? "5000"; +const screenSecureHandlerToggleVisible = document.head.querySelector("meta[name='screen-secure-handler-toggle-visible']"); +window.ProcessMaker.screen = { + cacheEnabled: screenCacheEnabled === "true", + cacheTimeout: Number(screenCacheTimeout), + secureHandlerToggleVisible: !!Number(screenSecureHandlerToggleVisible?.content), +}; +// Initialize screen-builder cache +initializeScreenCache(window.ProcessMaker.apiClient, window.ProcessMaker.screen); + +const clickTab = () => { + const { hash } = window.location; + if (!hash) { + return; + } + const tab = $(`[role="tab"][href="${hash}"]`); + if (tab.length) { + tab.tab("show"); + } +}; +window.addEventListener("hashchange", clickTab); + +// click an active tab after all components have mounted +Vue.use({ + install(vue) { + vue.mixin({ + mounted() { + if (this.$parent) { + // only run on root + return; + } + + // Run after component mounted + this.$nextTick(() => { + clickTab(); + }); + }, + }); + }, +}); + +// Send an event when the global Vue and ProcessMaker instance is available +window.dispatchEvent(new Event("app-bootstrapped")); diff --git a/resources/js/bootstrap-core.js b/resources/js/bootstrap-core.js new file mode 100644 index 0000000000..95874f5350 --- /dev/null +++ b/resources/js/bootstrap-core.js @@ -0,0 +1 @@ +import "./bootstrap-base"; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 0b9352f608..1f0ed36cea 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,459 +1,10 @@ -import "bootstrap-vue/dist/bootstrap-vue.css"; -import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue"; -import * as bootstrap from "bootstrap"; -import Router from "vue-router"; -import ScreenBuilder, { initializeScreenCache } from "@processmaker/screen-builder"; -import * as VueDeepSet from "vue-deepset"; - -/** - * Setup Translations - */ -import i18next from "i18next"; -import Backend from "i18next-chained-backend"; -import LocalStorageBackend from "i18next-localstorage-backend"; -import XHR from "i18next-xhr-backend"; -import VueI18Next from "@panter/vue-i18next"; -import { install as VuetableInstall } from "vuetable-2"; +import "./bootstrap-core"; import MonacoEditor from "vue-monaco"; -import Vue from "vue"; -import * as vue from "vue"; -import VueCookies from "vue-cookies"; -import { initSessionSync } from "./common/sessionSync"; -import { - applyCsrfToken, - attachCsrfRequestInterceptor, - attachSessionRenewalInterceptor, - getCsrfToken, -} from "./common/csrfToken"; -import TenantAwareEcho from "./common/TenantAwareEcho"; -import GlobalStore from "./globalStore"; -import Pagination from "./components/common/Pagination"; import ScreenSelect from "./processes/modeler/components/inspector/ScreenSelect.vue"; -import translator from "./modules/lang.js"; -import datetime_format from "./data/datetime_formats.json"; -import RequestChannel from "./tasks/components/ProcessRequestChannel"; -import Modal from "./components/shared/Modal"; -import AccessibilityMixin from "./components/common/mixins/accessibility"; -import PmqlInput from "./components/shared/PmqlInput.vue"; -import DataTreeToggle from "./components/common/data-tree-toggle.vue"; -import TreeView from "./components/TreeView.vue"; -import FilterTable from "./components/shared/FilterTable.vue"; -import PaginationTable from "./components/shared/PaginationTable.vue"; -import PMDropdownSuggest from "./components/PMDropdownSuggest"; -import "@processmaker/screen-builder/dist/vue-form-builder.css"; -import Echo from "laravel-echo"; -import Pusher from "pusher-js"; - -window.__ = translator; -window._ = require("lodash"); -window.Popper = require("popper.js").default; - -/** - * Give node plugins access to our custom screen builder components - */ -window.ProcessmakerComponents = require("./processes/screen-builder/components"); - -/** - * Give node plugins access to additional components - */ -window.SharedComponents = require("./components/shared"); - -window.ProcessesComponents = require("./processes/components"); -window.ScreensComponents = require("./processes/screens/components"); -window.ScriptsComponents = require("./processes/scripts/components"); -window.ProcessesCatalogueComponents = require("./processes-catalogue/components/utils"); -window.Utils = require("./utils"); - -window.PMDropdownSuggest = PMDropdownSuggest; - -/** - * Exporting Modeler inspector components - */ -window.ModelerInspector = require("./processes/modeler/components/inspector"); -/** - * We'll load jQuery and the Bootstrap jQuery plugin which provides support - * for JavaScript based Bootstrap features such as modals and tabs. This - * code may be modified to fit the specific needs of your application. - */ -window.$ = window.jQuery = require("jquery"); - -/** - * Vue is a modern JavaScript library for building interactive web interfaces - * using reactive data binding and reusable components. Vue's API is clean - * and simple, leaving you to focus on building your next great project. - */ - -window.Vue = Vue; -window.vue = vue; -window.bootstrap = bootstrap; -window.Vue.use(BootstrapVue); -window.Vue.use(BootstrapVueIcons); -window.Vue.use(ScreenBuilder); -window.Vue.use(GlobalStore); -window.Vue.use(VueDeepSet); -window.Vue.use(VueCookies); -if (!document.head.querySelector("meta[name=\"is-horizon\"]")) { - window.Vue.use(Router); -} window.VueMonaco = require("vue-monaco"); - -window.ScreenBuilder = require("@processmaker/screen-builder"); -window.VueFormElements = require("@processmaker/vue-form-elements"); window.Modeler = require("@processmaker/modeler"); +window.ModelerInspector = require("./processes/modeler/components/inspector"); -window.VueRouter = Router; - -window.Vue.use(VueI18Next); -VuetableInstall(window.Vue); -window.Vue.component("pagination", Pagination); -window.Vue.component("monaco-editor", MonacoEditor); window.Vue.component("screen-select", ScreenSelect); -window.Vue.component("pm-modal", Modal); -window.Vue.component("pmql-input", PmqlInput); -window.Vue.component("data-tree-toggle", DataTreeToggle); -window.Vue.component("tree-view", TreeView); -window.Vue.component("filter-table", FilterTable); -window.Vue.component("pagination-table", PaginationTable); - -let translationsLoaded = false; -const mdates = JSON.parse( - document.head.querySelector("meta[name=\"i18n-mdate\"]")?.content, -); - -// Make $t available to all vue instances -Vue.mixin({ i18n: new VueI18Next(i18next) }); -Vue.mixin(AccessibilityMixin); - -window.ProcessMaker = { - i18n: i18next, - - /** - * A general use global event bus that can be used - */ - EventBus: new Vue(), - /** - * A general use global router that can be used - */ - Router: new Router({ - mode: "history", - }), - /** - * ProcessMaker Notifications - */ - notifications: [], - /** - * Push a notification. - * - * @param {object} notification - * - * @returns {void} - */ - pushNotification(notification) { - if (!notification || window.ProcessMaker.notifications.some((x) => x.id === notification.id)) { - return; - } - window.ProcessMaker.notifications.push(notification); - }, - - /** - * Removes notifications by message ids or urls - * - * @returns {void} - * @param messageIds - * - * @param urls - */ - removeNotifications(messageIds = [], urls = []) { - return window.ProcessMaker.apiClient.put("/read_notifications", { message_ids: messageIds, routes: urls }).then(() => { - messageIds.forEach((messageId) => { - ProcessMaker.notifications.splice(ProcessMaker.notifications.findIndex((x) => x.id === messageId), 1); - }); - - urls.forEach((url) => { - const messageIndex = ProcessMaker.notifications.findIndex((x) => x.url === url); - if (messageIndex >= 0) { - ProcessMaker.removeNotification(ProcessMaker.notifications[messageIndex].id); - } - }); - }); - }, - /** - * Mark as unread a list of notifications - * - * @returns {void} - * @param messageIds - * - * @param urls - */ - unreadNotifications(messageIds = [], urls = []) { - return window.ProcessMaker.apiClient.put("/unread_notifications", { message_ids: messageIds, routes: urls }); - }, - - missingTranslations: new Set(), - missingTranslation(value) { - if (this.missingTranslations.has(value)) { return; } - this.missingTranslations.add(value); - if (!isProd) { - console.warn("Missing Translation:", value); - } - }, - - RequestChannel, - - $notifications: { - icons: {}, - }, -}; - -window.ProcessMaker.setValidatorLanguage = (validator, lang) => { - const availableLanguages = ["ar", "az", "be", "bg", "bs", "ca", "cs", "cy", "da", "de", "el", "en", "es", "et", "eu", "fa", "fi", - "fr", "hr", "hu", "id", "it", "ja", "ka", "km", "ko", "lt", "lv", "mk", "mn", "ms", "nb_NO", "nl", "pl", "pt", "pt_BR", "ro", "ru", - "se", "sl", "sq", "sr", "sv", "tr", "ua", "uk", "uz", "vi", "zh", "zh_TW"]; - const selectedLang = availableLanguages.includes(lang) ? lang : "en"; - if (validator) { - validator.useLang(selectedLang); - } -}; - -window.ProcessMaker.i18nPromise = i18next.use(Backend).init({ - lng: document.documentElement.lang, - fallbackLng: "en", // default language when no translations - returnEmptyString: false, // When a translation is an empty string, return the default language, not empty - nsSeparator: false, - keySeparator: false, - parseMissingKeyHandler(value) { - if (!translationsLoaded) { return value; } - // Report that a translation is missing - window.ProcessMaker.missingTranslation(value); - // Fallback to showing the english version - return value; - }, - backend: { - backends: [ - LocalStorageBackend, // Try cache first - XHR, - ], - backendOptions: [ - { versions: mdates }, - { loadPath: "/i18next/fetch/{{lng}}/_default" }, - ], - }, -}); - -window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); - -/** - * Create a axios instance which any vue component can bring in to call - * REST api endpoints through oauth authentication - * - */ -window.ProcessMaker.apiClient = require("axios"); - -window.ProcessMaker.apiClient.defaults.withCredentials = true; - -window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; - -const token = document.head.querySelector("meta[name=\"csrf-token\"]"); -const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; - -window.ProcessMaker.applyCsrfToken = applyCsrfToken; -window.ProcessMaker.getCsrfToken = getCsrfToken; -// Attach CSRF interceptor before other interceptors so it runs last in the axios chain. -attachCsrfRequestInterceptor(window.ProcessMaker.apiClient); - -/** - * Next we will register the CSRF Token as a common header with Axios so that - * all outgoing HTTP requests automatically have it attached. This is just - * a simple convenience so we don't have to attach every token manually. - */ - -if (token) { - applyCsrfToken(token.content, "page-load"); -} else { - console.error("CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token"); -} - -// Setup api versions -const apiVersionConfig = [ - { version: "1.0", baseURL: "/api/1.0/" }, - { version: "1.1", baseURL: "/api/1.1/" }, -]; - -window.ProcessMaker.apiClient.defaults.baseURL = apiVersionConfig[0].baseURL; -window.ProcessMaker.apiClient.interceptors.request.use((config) => { - if (typeof config.url !== "string" || !config.url) { - throw new Error("Invalid URL in the request configuration"); - } - - apiVersionConfig.forEach(({ version, baseURL }) => { - const versionPrefix = `/api/${version}/`; - if (config.url.startsWith(versionPrefix)) { - // eslint-disable-next-line no-param-reassign - config.baseURL = baseURL; - // eslint-disable-next-line no-param-reassign - config.url = config.url.replace(versionPrefix, ""); - } - }); - - return config; -}); - -attachSessionRenewalInterceptor(window.ProcessMaker.apiClient); - -// Set the default API timeout -let apiTimeout = 5000; -if (window.Processmaker && window.Processmaker.apiTimeout !== undefined) { - apiTimeout = window.Processmaker.apiTimeout; -} -window.ProcessMaker.apiClient.defaults.timeout = apiTimeout; - -// Default alert functionality -window.ProcessMaker.alert = function (text, variant) { - if (typeof text === "string") { - window.alert(text); - } -}; - -const openAiEnabled = document.head.querySelector("meta[name=\"open-ai-nlq-to-pmql\"]"); - -if (openAiEnabled) { - window.ProcessMaker.openAi = { - enabled: openAiEnabled.content, - }; -} else { - window.ProcessMaker.openAi = { - enabled: false, - }; -} - -const userID = document.head.querySelector("meta[name=\"user-id\"]"); -const userFullName = document.head.querySelector("meta[name=\"user-full-name\"]"); -const userAvatar = document.head.querySelector("meta[name=\"user-avatar\"]"); -const formatDate = document.head.querySelector("meta[name=\"datetime-format\"]"); -const timezone = document.head.querySelector("meta[name=\"timezone\"]"); -const appUrl = document.head.querySelector("meta[name=\"app-url\"]"); - -if (appUrl) { - window.ProcessMaker.app = { - url: appUrl.content, - }; -} - -if (userID) { - window.ProcessMaker.user = { - id: userID.content, - datetime_format: formatDate?.content, - calendar_format: formatDate?.content, - timezone: timezone?.content, - fullName: userFullName?.content, - avatar: userAvatar?.content, - }; - datetime_format.forEach((value) => { - if (formatDate.content === value.format) { - window.ProcessMaker.user.datetime_format = value.momentFormat; - window.ProcessMaker.user.calendar_format = value.calendarFormat; - } - }); -} - -if (window.Processmaker && window.Processmaker.broadcasting) { - const config = window.Processmaker.broadcasting; - - if (config.broadcaster == "pusher") { - window.Pusher = require("pusher-js"); - window.Pusher.logToConsole = config.debug; - } - - window.Echo = new TenantAwareEcho(config); -} - -if (window.Processmaker && window.Processmaker.script_microservice && window.Processmaker.script_microservice.enabled) { - const config = window.Processmaker.script_microservice.broadcasting; - - window.ScriptMicroserviceEcho = new Echo({ - ...config, - client: new Pusher(config.key, config), - }); -} - -if (userID) { - const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content; - const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content; - const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content); - const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); - const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; - const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1; - - const sessionSyncState = initSessionSync({ - userId: userID.content, - isProd, - timeoutScript, - accountTimeoutLength, - accountTimeoutWarnSeconds, - accountTimeoutEnabled, - Vue, - Echo: window.Echo, - pushNotification: window.ProcessMaker.pushNotification, - alert: window.ProcessMaker.alert, - getSessionModal: () => window.ProcessMaker.sessionModal, - getCloseSessionModal: () => window.ProcessMaker.closeSessionModal, - getNavbar: () => window.ProcessMaker.navbar, - }); - - if (sessionSyncState) { - window.ProcessMaker.AccountTimeoutLength = sessionSyncState.AccountTimeoutLength; - window.ProcessMaker.AccountTimeoutWarnSeconds = sessionSyncState.AccountTimeoutWarnSeconds; - window.ProcessMaker.AccountTimeoutWarnMinutes = sessionSyncState.AccountTimeoutWarnMinutes; - window.ProcessMaker.AccountTimeoutEnabled = sessionSyncState.AccountTimeoutEnabled; - window.ProcessMaker.AccountTimeoutWorker = sessionSyncState.AccountTimeoutWorker; - window.ProcessMaker.sessionSync = sessionSyncState.sessionSync; - } -} - -// Configuration Global object used by ScreenBuilder -// @link https://processmaker.atlassian.net/browse/FOUR-6833 Cache configuration -const screenCacheEnabled = document.head.querySelector("meta[name=\"screen-cache-enabled\"]")?.content ?? "false"; -const screenCacheTimeout = document.head.querySelector("meta[name=\"screen-cache-timeout\"]")?.content ?? "5000"; -const screenSecureHandlerToggleVisible = document.head.querySelector("meta[name='screen-secure-handler-toggle-visible']"); -window.ProcessMaker.screen = { - cacheEnabled: screenCacheEnabled === "true", - cacheTimeout: Number(screenCacheTimeout), - secureHandlerToggleVisible: !!Number(screenSecureHandlerToggleVisible?.content), -}; -// Initialize screen-builder cache -initializeScreenCache(window.ProcessMaker.apiClient, window.ProcessMaker.screen); - -const clickTab = () => { - const { hash } = window.location; - if (!hash) { - return; - } - const tab = $(`[role="tab"][href="${hash}"]`); - if (tab.length) { - tab.tab("show"); - } -}; -window.addEventListener("hashchange", clickTab); - -// click an active tab after all components have mounted -Vue.use({ - install(vue) { - vue.mixin({ - mounted() { - if (this.$parent) { - // only run on root - return; - } - - // Run after component mounted - this.$nextTick(() => { - clickTab(); - }); - }, - }); - }, -}); - -// Send an event when the global Vue and ProcessMaker instance is available -window.dispatchEvent(new Event("app-bootstrapped")); +window.Vue.component("monaco-editor", MonacoEditor); diff --git a/resources/js/tasks/router.js b/resources/js/tasks/router.js index 1444aba313..7dd466f746 100644 --- a/resources/js/tasks/router.js +++ b/resources/js/tasks/router.js @@ -1,11 +1,31 @@ import Vue from "vue"; import VueRouter from "vue-router"; -import DashboardViewer from "./components/DashboardViewer.vue"; -import Process from "../processes-catalogue/components/Process"; Vue.use(VueRouter); -const screen = JSON.parse(sessionStorage.getItem('dashboard_screen')); -const formData = JSON.parse(sessionStorage.getItem('dashboard_formData')); + +const InboxRoutePlaceholder = { template: "
" }; + +const loadProcess = () => import( + /* webpackChunkName: "inbox-process" */ + "../processes-catalogue/components/Process" +); + +const loadDashboardViewer = () => import( + /* webpackChunkName: "inbox-dashboard" */ + "./components/DashboardViewer.vue" +); + +const parseSessionJson = (key) => { + const value = sessionStorage.getItem(key); + if (!value) { + return null; + } + + return JSON.parse(value); +}; + +const screen = parseSessionJson('dashboard_screen'); +const formData = parseSessionJson('dashboard_formData'); const router = new VueRouter({ mode: "history", @@ -14,7 +34,7 @@ const router = new VueRouter({ { path: "/process/:processId", name: "process-browser", - component: Process, + component: loadProcess, props: route => ({ processId: parseInt(route.params.processId) || null, process: null, @@ -24,17 +44,12 @@ const router = new VueRouter({ { path: "", name: "inbox", - component: Process, - props: route => ({ - processId: null, - process: null, - ellipsisPermission: window.ProcessMaker.ellipsisPermission - }) + component: InboxRoutePlaceholder, }, { path: "/dashboard/:dashboardId", name: "dashboard", - component: DashboardViewer, + component: loadDashboardViewer, props: route => ({ dashboardId: route.params.dashboardId || null, screen: route.params.screen || screen, @@ -44,4 +59,4 @@ const router = new VueRouter({ ] }); -export default router; +export default router; diff --git a/resources/views/layouts/layout.blade.php b/resources/views/layouts/layout.blade.php index 3918b420c4..d30aa0945b 100644 --- a/resources/views/layouts/layout.blade.php +++ b/resources/views/layouts/layout.blade.php @@ -145,6 +145,9 @@ class="main flex-grow-1 h-100{{__('Something went wrong. Try refreshing the application')}}
+@php + $layoutAssets = layoutAssets(); +@endphp @if(config('broadcasting.default') == 'redis') @@ -152,9 +155,11 @@ class="main flex-grow-1 h-100 +@if($layoutAssets['modeler_vendor']) +@endif - + - + +@if($layoutAssets['monaco']) @include('shared.monaco') +@endif @foreach(GlobalScripts::getScripts() as $script) @endforeach diff --git a/tests/Feature/Console/TransitionExecutorsTest.php b/tests/Feature/Console/TransitionExecutorsTest.php index 43edb367b1..d12a228aa3 100644 --- a/tests/Feature/Console/TransitionExecutorsTest.php +++ b/tests/Feature/Console/TransitionExecutorsTest.php @@ -2,13 +2,17 @@ namespace Tests\Feature\Console; +use Illuminate\Contracts\Console\Kernel; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; +use Mockery; +use ProcessMaker\Console\Commands\TransitionExecutors; use ProcessMaker\Enums\ScriptExecutorType; use ProcessMaker\Models\ScriptExecutor; use ProcessMaker\Models\ScriptExecutorVersion; use ProcessMaker\Services\ScriptMicroserviceService; use Tests\TestCase; +use WebSocket\Client; class TransitionExecutorsTest extends TestCase { @@ -20,272 +24,345 @@ protected function setUp(): void ScriptExecutor::where('id', '>', 0)->delete(); // Command must work even when the microservice feature flag is off. - config(['script-runner-microservice.enabled' => false]); - } - - public function testInvalidUuidFails(): void - { - $this->mock(ScriptMicroserviceService::class, function ($mock) { - $mock->shouldNotReceive('updateCustomExecutor'); - }); - - $this->artisan('processmaker:transition-executors', ['uuid' => 'not-a-uuid']) - ->expectsOutput('Invalid uuid. Provide a script executor UUID or "all".') - ->assertFailed(); + config([ + 'script-runner-microservice.enabled' => false, + 'script-runner-microservice.broadcasting.app_key' => 'test-app-key', + 'script-runner-microservice.broadcasting.host' => 'broadcast.test', + ]); } - public function testNumericIdIsRejected(): void + public function testTimeoutMustBeGreaterThanSixty(): void { $this->mock(ScriptMicroserviceService::class, function ($mock) { $mock->shouldNotReceive('updateCustomExecutor'); }); - $this->artisan('processmaker:transition-executors', ['uuid' => '5']) - ->expectsOutput('Invalid uuid. Provide a script executor UUID or "all".') + $this->artisan('processmaker:transition-executors', ['--timeout' => 60]) + ->expectsOutput('Timeout must be a greater or equal to 60') ->assertFailed(); } - public function testMissingExecutorFails(): void + public function testWarnsWhenNoExecutorsFound(): void { $this->mock(ScriptMicroserviceService::class, function ($mock) { $mock->shouldNotReceive('updateCustomExecutor'); }); - $missingUuid = '00000000-0000-4000-8000-000000000099'; - - $this->artisan('processmaker:transition-executors', ['uuid' => $missingUuid]) - ->expectsOutput("Script executor [{$missingUuid}] not found.") - ->assertFailed(); + $this->artisan('processmaker:transition-executors') + ->expectsOutput('No script executors found to transition.') + ->assertSuccessful(); } - public function testDefaultExecutorFails(): void + public function testSkipsDefaultSystemAndUnsupportedExecutors(): void { - $default = ScriptExecutor::factory()->create([ - 'language' => 'php', + ScriptExecutor::factory()->create([ 'title' => 'PHP Executor', - 'type' => null, - ]); - - $this->mock(ScriptMicroserviceService::class, function ($mock) { - $mock->shouldNotReceive('updateCustomExecutor'); - }); - - $this->artisan('processmaker:transition-executors', ['uuid' => $default->uuid]) - ->expectsOutput("Script executor [{$default->uuid}] is a default/system executor and cannot be transitioned.") - ->assertFailed(); - } - - public function testAllSkipsDefaultsAndIncludesCustomAndNonDefaultNullType(): void - { - $defaultPhp = ScriptExecutor::factory()->create([ + 'description' => 'Default PHP Executor', 'language' => 'php', - 'title' => 'Default PHP', + 'config' => 'RUN echo default', 'type' => null, - 'created_at' => now()->subDay(), ]); - $defaultJs = ScriptExecutor::factory()->create([ - 'language' => 'javascript', - 'title' => 'Default JS', - 'type' => null, - 'created_at' => now()->subDay(), - ]); - $custom = ScriptExecutor::factory()->create([ + ScriptExecutor::factory()->create([ + 'title' => 'System PHP', + 'description' => 'System executor', 'language' => 'php', - 'title' => 'Custom PHP', - 'type' => ScriptExecutorType::Custom, - 'created_at' => now(), + 'config' => 'RUN echo system', + 'is_system' => true, + 'type' => ScriptExecutorType::System, ]); - $extraNullPhp = ScriptExecutor::factory()->create([ - 'language' => 'php', - 'title' => 'Extra null PHP', - 'type' => null, - 'created_at' => now()->addMinute(), + ScriptExecutor::factory()->create([ + 'title' => 'Nayra', + 'description' => 'Nayra executor', + 'language' => 'php-nayra', + 'config' => 'RUN echo nayra', + 'type' => ScriptExecutorType::Custom, ]); ScriptExecutor::factory()->create([ - 'language' => 'python', - 'title' => 'System Python', - 'type' => ScriptExecutorType::System, + 'title' => 'Null config', + 'description' => 'Missing config', + 'language' => 'php', + 'config' => null, + 'type' => ScriptExecutorType::Custom, ]); - $this->mock(ScriptMicroserviceService::class, function ($mock) use ($custom, $extraNullPhp, $defaultPhp, $defaultJs) { - $mock->shouldReceive('updateCustomExecutor') - ->twice() - ->andReturnUsing(function (ScriptExecutor $executor) use ($custom, $extraNullPhp, $defaultPhp, $defaultJs) { - if (in_array($executor->uuid, [$defaultPhp->uuid, $defaultJs->uuid], true)) { - $this->fail('Default executors should not be transitioned'); - } - - if (!in_array($executor->uuid, [$custom->uuid, $extraNullPhp->uuid], true)) { - $this->fail('Unexpected executor uuid: ' . $executor->uuid); - } - - return ['status' => 'success']; - }); + $this->mock(ScriptMicroserviceService::class, function ($mock) { + $mock->shouldNotReceive('updateCustomExecutor'); }); - $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) - ->expectsOutput("Executor {$custom->uuid} transitioned successfully.") - ->expectsOutput("Executor {$extraNullPhp->uuid} transitioned successfully.") - ->expectsOutput('All script executors transitioned successfully. (2 processed)') + $this->artisan('processmaker:transition-executors') + ->expectsOutput('No script executors found to transition.') ->assertSuccessful(); } - public function testSingleCustomExecutorSuccessByUuid(): void + public function testTransitionsCustomExecutorSuccessfully(): void { - // Seed a default first so custom is not treated as the language default. - ScriptExecutor::factory()->create([ - 'language' => 'php', - 'type' => null, - 'created_at' => now()->subDay(), - ]); - - $executor = ScriptExecutor::factory()->create([ + $executor = $this->createTransitionableExecutor([ + 'title' => 'Custom PHP', + 'description' => 'A custom PHP executor', 'language' => 'php', - 'title' => 'PHP Executor', - 'config' => 'RUN echo custom', 'type' => ScriptExecutorType::Custom, - 'created_at' => now(), ]); - $this->mock(ScriptMicroserviceService::class, function ($mock) use ($executor) { - $mock->shouldReceive('updateCustomExecutor') - ->once() - ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) - ->andReturn(['status' => 'success']); - }); - - $this->artisan('processmaker:transition-executors', ['uuid' => $executor->uuid]) + $this->registerCommandWithMocks( + function ($service) use ($executor) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturn(['status' => 'success']); + }, + [ + ['event' => 'build-image', 'data' => 'Building...'], + ['event' => 'build-finished', 'data' => 'done'], + ] + ); + + $this->artisan('processmaker:transition-executors', [ + '--uuid' => [$executor->uuid], + '--timeout' => 120, + ]) ->expectsOutput("Transitioning executor {$executor->uuid} (php) to the microservice...") - ->expectsOutput("Executor {$executor->uuid} transitioned successfully.") + ->expectsOutput('Building...') + ->expectsOutput("Build finished for executor {$executor->uuid} - done") + ->expectsOutput("Executor {$executor->uuid} transitioned successfully." . PHP_EOL) ->assertSuccessful(); } - public function testSingleNonDefaultNullTypeSucceeds(): void + public function testTransitionsNonDefaultNullTypeExecutor(): void { - ScriptExecutor::factory()->create([ - 'language' => 'php', - 'type' => null, - 'created_at' => now()->subDay(), - ]); - - $executor = ScriptExecutor::factory()->create([ - 'language' => 'php', + $executor = $this->createTransitionableExecutor([ 'title' => 'Extra PHP', + 'description' => 'Extra null type PHP', + 'language' => 'php', 'type' => null, - 'created_at' => now(), ]); - $this->mock(ScriptMicroserviceService::class, function ($mock) use ($executor) { - $mock->shouldReceive('updateCustomExecutor') - ->once() - ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) - ->andReturn(['status' => 'success']); - }); - - $this->artisan('processmaker:transition-executors', ['uuid' => $executor->uuid]) - ->expectsOutput("Executor {$executor->uuid} transitioned successfully.") + $this->registerCommandWithMocks( + function ($service) use ($executor) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturn(['status' => 'updated']); + }, + [ + ['event' => 'build-finished', 'data' => 'ok'], + ] + ); + + $this->artisan('processmaker:transition-executors', ['--uuid' => [$executor->uuid]]) + ->expectsOutput("Executor {$executor->uuid} transitioned successfully." . PHP_EOL) ->assertSuccessful(); } - public function testAllStopsOnStatusError(): void + public function testFiltersByUuidOption(): void { - $first = ScriptExecutor::factory()->create([ + $included = $this->createTransitionableExecutor([ + 'title' => 'Included', + 'description' => 'Included executor', 'language' => 'php', - 'title' => 'First', - 'config' => '', 'type' => ScriptExecutorType::Custom, ]); - $second = ScriptExecutor::factory()->create([ + $excluded = $this->createTransitionableExecutor([ + 'title' => 'Excluded', + 'description' => 'Excluded executor', 'language' => 'php', - 'title' => 'Second', - 'config' => '', 'type' => ScriptExecutorType::Custom, ]); - $third = ScriptExecutor::factory()->create([ + + $this->registerCommandWithMocks( + function ($service) use ($included, $excluded) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(function (ScriptExecutor $passed) use ($included, $excluded) { + $this->assertSame($included->uuid, $passed->uuid); + $this->assertNotSame($excluded->uuid, $passed->uuid); + + return true; + }) + ->andReturn(['status' => 'success']); + }, + [ + ['event' => 'build-finished', 'data' => 'ok'], + ] + ); + + $this->artisan('processmaker:transition-executors', ['--uuid' => [$included->uuid]]) + ->expectsOutput("Executor {$included->uuid} transitioned successfully." . PHP_EOL) + ->assertSuccessful(); + } + + public function testBuildErrorDoesNotReportSuccess(): void + { + $executor = $this->createTransitionableExecutor([ + 'title' => 'Broken build', + 'description' => 'Broken build executor', 'language' => 'php', - 'title' => 'Third', - 'config' => '', 'type' => ScriptExecutorType::Custom, ]); - $this->mock(ScriptMicroserviceService::class, function ($mock) use ($first, $second, $third) { - $mock->shouldReceive('updateCustomExecutor') - ->times(2) - ->andReturnUsing(function (ScriptExecutor $executor) use ($first, $second, $third) { - if ($executor->uuid === $third->uuid) { - $this->fail('Third executor should not be transitioned after a failure'); - } + $this->registerCommandWithMocks( + function ($service) use ($executor) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturn(['status' => 'success']); + }, + [ + ['event' => 'build-error', 'data' => 'docker failed'], + ] + ); + + $this->artisan('processmaker:transition-executors', ['--uuid' => [$executor->uuid]]) + ->expectsOutput("Error occurred while building image for executor {$executor->uuid} - docker failed") + ->doesntExpectOutput("Executor {$executor->uuid} transitioned successfully." . PHP_EOL) + ->assertSuccessful(); + } - if ($executor->uuid === $first->uuid) { - return ['status' => 'success']; - } - - if ($executor->uuid === $second->uuid) { - return [ - 'status' => 'error', - 'sdk_output' => 'SDK generation failed: boom', - 'docker_output' => 'Docker build failed: boom', - ]; - } - - $this->fail('Unexpected executor uuid: ' . $executor->uuid); - }); - }); + public function testRequestExceptionIsReportedAndCommandContinues(): void + { + $executor = $this->createTransitionableExecutor([ + 'title' => 'Request fails', + 'description' => 'Request fails executor', + 'language' => 'php', + 'type' => ScriptExecutorType::Custom, + ]); - $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) - ->expectsOutput("Executor {$first->uuid} transitioned successfully.") - ->expectsOutput("Transition failed for executor {$second->uuid}") - ->expectsOutput('Stopping: 1 remaining executor(s) were not processed.') - ->assertFailed(); + $this->registerCommandWithMocks( + function ($service) use ($executor) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andReturnUsing(function () { + $response = new Response( + new \GuzzleHttp\Psr7\Response(500, [], 'SDK build failed hard') + ); + + throw new RequestException($response); + }); + }, + [] + ); + + $this->artisan('processmaker:transition-executors', ['--uuid' => [$executor->uuid]]) + ->expectsOutput("Request failed for executor {$executor->uuid}") + ->expectsOutput('SDK build failed hard') + ->assertSuccessful(); } - public function testAllStopsOnRequestException(): void + public function testGenericThrowableIsReportedAndCommandContinues(): void { - $first = ScriptExecutor::factory()->create([ + $executor = $this->createTransitionableExecutor([ + 'title' => 'Throwable fails', + 'description' => 'Throwable fails executor', 'language' => 'php', - 'config' => '', 'type' => ScriptExecutorType::Custom, ]); - $second = ScriptExecutor::factory()->create([ + + $this->registerCommandWithMocks( + function ($service) use ($executor) { + $service->shouldReceive('updateCustomExecutor') + ->once() + ->withArgs(fn (ScriptExecutor $passed) => $passed->uuid === $executor->uuid) + ->andThrow(new \RuntimeException('websocket boom')); + }, + [] + ); + + $this->artisan('processmaker:transition-executors', ['--uuid' => [$executor->uuid]]) + ->expectsOutput("Transition failed for executor {$executor->uuid}") + ->expectsOutput('websocket boom') + ->assertSuccessful(); + } + + public function testTransitionsMultipleExecutors(): void + { + $first = $this->createTransitionableExecutor([ + 'title' => 'First custom', + 'description' => 'First custom executor', 'language' => 'php', - 'config' => '', 'type' => ScriptExecutorType::Custom, ]); - $third = ScriptExecutor::factory()->create([ - 'language' => 'php', - 'config' => '', + $second = $this->createTransitionableExecutor([ + 'title' => 'Second custom', + 'description' => 'Second custom executor', + 'language' => 'javascript', 'type' => ScriptExecutorType::Custom, ]); - $this->mock(ScriptMicroserviceService::class, function ($mock) use ($first, $second, $third) { - $mock->shouldReceive('updateCustomExecutor') - ->times(2) - ->andReturnUsing(function (ScriptExecutor $executor) use ($first, $second, $third) { - if ($executor->uuid === $third->uuid) { - $this->fail('Third executor should not be transitioned after a failure'); - } + $this->registerCommandWithMocks( + function ($service) use ($first, $second) { + $service->shouldReceive('updateCustomExecutor') + ->twice() + ->andReturnUsing(function (ScriptExecutor $executor) use ($first, $second) { + if (!in_array($executor->uuid, [$first->uuid, $second->uuid], true)) { + $this->fail('Unexpected executor uuid: ' . $executor->uuid); + } - if ($executor->uuid === $first->uuid) { return ['status' => 'success']; - } - - if ($executor->uuid === $second->uuid) { - $response = new Response( - new \GuzzleHttp\Psr7\Response(500, [], 'SDK build failed hard') - ); - - throw new RequestException($response); - } + }); + }, + [ + ['event' => 'build-finished', 'data' => 'first done'], + ['event' => 'build-finished', 'data' => 'second done'], + ] + ); + + $this->artisan('processmaker:transition-executors') + ->expectsOutput("Executor {$first->uuid} transitioned successfully." . PHP_EOL) + ->expectsOutput("Executor {$second->uuid} transitioned successfully." . PHP_EOL) + ->assertSuccessful(); + } - $this->fail('Unexpected executor uuid: ' . $executor->uuid); - }); - }); + private function createTransitionableExecutor(array $attributes = []): ScriptExecutor + { + return ScriptExecutor::factory()->create(array_merge([ + 'config' => 'RUN echo custom', + 'is_system' => false, + ], $attributes)); + } - $this->artisan('processmaker:transition-executors', ['uuid' => 'all']) - ->expectsOutput("Executor {$first->uuid} transitioned successfully.") - ->expectsOutput("Transition failed for executor {$second->uuid}") - ->expectsOutput('SDK build failed hard') - ->expectsOutput('Stopping: 1 remaining executor(s) were not processed.') - ->assertFailed(); + /** + * Bind a command instance that uses mocked microservice + websocket collaborators. + */ + private function registerCommandWithMocks(callable $configureService, array $messages): void + { + $service = Mockery::mock(ScriptMicroserviceService::class); + $configureService($service); + $this->app->instance(ScriptMicroserviceService::class, $service); + + $client = Mockery::mock(Client::class); + $client->shouldReceive('setTimeout')->andReturnNull(); + $client->shouldReceive('send')->andReturnNull(); + $client->shouldReceive('close')->andReturnNull(); + + if ($messages === []) { + $client->shouldReceive('receive')->never(); + } else { + $client->shouldReceive('receive') + ->times(count($messages)) + ->andReturnValues(array_map( + fn ($message) => json_encode($message), + $messages + )); + } + + $command = new class($service, $client) extends TransitionExecutors { + public function __construct( + ScriptMicroserviceService $scriptMicroserviceService, + private Client $broadcastClient + ) { + parent::__construct($scriptMicroserviceService); + } + + protected function createBroadcastClient(string $url, int $timeout): Client + { + $this->broadcastClient->setTimeout($timeout); + + return $this->broadcastClient; + } + }; + + $command->setLaravel($this->app); + $this->app->instance(TransitionExecutors::class, $command); + $this->app->make(Kernel::class)->registerCommand($command); } } diff --git a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php index 437eb5e912..f05bdbe2a1 100644 --- a/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php +++ b/tests/Feature/ImportExport/Exporters/ScriptExporterTest.php @@ -62,7 +62,7 @@ public function testExportUncategorized() { DB::beginTransaction(); (new CategorySystemSeeder)->run(); - $uncategorizedCategory = \ProcessMaker\Models\ScriptCategory::first(); + $uncategorizedCategory = ScriptCategory::first(); $script = Script::factory()->create(['title' => 'test']); $script->categories()->sync([$uncategorizedCategory->id]); @@ -70,7 +70,7 @@ public function testExportUncategorized() DB::rollBack(); // Delete all created items since DB::beginTransaction (new CategorySystemSeeder)->run(); - $existingUncategorizedCategory = \ProcessMaker\Models\ScriptCategory::first(); + $existingUncategorizedCategory = ScriptCategory::first(); $existingUuid = $existingUncategorizedCategory->uuid; $this->import($payload); diff --git a/tests/unit/ProcessMaker/Managers/LayoutAssetManagerTest.php b/tests/unit/ProcessMaker/Managers/LayoutAssetManagerTest.php new file mode 100644 index 0000000000..c77022c983 --- /dev/null +++ b/tests/unit/ProcessMaker/Managers/LayoutAssetManagerTest.php @@ -0,0 +1,72 @@ +manager = new LayoutAssetManager(); + } + + public function testInboxRouteUsesInboxProfile(): void + { + $assets = $this->manager->forRequest(Request::create('/inbox')); + + $this->assertSame('inbox', $assets['profile']); + $this->assertSame('js/app-core.js', $assets['app']); + $this->assertSame('js/app-layout-core.js', $assets['app_layout']); + $this->assertFalse($assets['modeler_vendor']); + $this->assertFalse($assets['monaco']); + } + + public function testTasksRouteUsesInboxProfile(): void + { + $assets = $this->manager->forRequest(Request::create('/tasks')); + + $this->assertSame('inbox', $assets['profile']); + $this->assertSame('js/app-core.js', $assets['app']); + } + + public function testInboxSubRouteUsesInboxProfile(): void + { + $assets = $this->manager->forRequest(Request::create('/inbox/process/1')); + + $this->assertSame('inbox', $assets['profile']); + } + + public function testDefaultRouteUsesFullProfile(): void + { + $assets = $this->manager->forRequest(Request::create('/processes')); + + $this->assertSame('default', $assets['profile']); + $this->assertSame('js/app.js', $assets['app']); + $this->assertSame('js/app-layout.js', $assets['app_layout']); + $this->assertTrue($assets['modeler_vendor']); + $this->assertTrue($assets['monaco']); + } + + public function testRequiresThrowsForUnknownAssetFlag(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown layout asset flag: missing'); + + $this->manager->requires('missing', Request::create('/inbox')); + } + + public function testLayoutAssetsHelperReturnsResolvedProfile(): void + { + $assets = layoutAssets(Request::create('/tasks')); + + $this->assertSame('inbox', $assets['profile']); + $this->assertFalse($assets['monaco']); + } +} diff --git a/tests/unit/ScriptMicroserviceServiceTest.php b/tests/unit/ScriptMicroserviceServiceTest.php index b2850ee79e..620f64042d 100644 --- a/tests/unit/ScriptMicroserviceServiceTest.php +++ b/tests/unit/ScriptMicroserviceServiceTest.php @@ -70,7 +70,7 @@ public function testHandlePreviewError() 'file' => 'Test error file', 'line' => 'Test line number', 'trace' => 'Test trace', - ] + ], ]); $this->service->handle($request); diff --git a/webpack.mix.js b/webpack.mix.js index bb38ecedd0..1578af8c47 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -160,6 +160,8 @@ mix .js("resources/jscomposition/cases/casesMain/loader.js", "public/js/composition/cases/casesMain") .js("resources/jscomposition/cases/casesDetail/loader.js", "public/js/composition/cases/casesDetail") .js("resources/js/initialLoad.js", "public/js") + .js("resources/js/app-core.js", "public/js") + .js("resources/js/app-layout-core.js", "public/js") .js("resources/js/tasks/loaderMain.js", "public/js/tasks") .js("resources/js/tasks/loaderPreview.js", "public/js/tasks")