From 011763223169b9a6e9b755b682ee99a35bb95ce5 Mon Sep 17 00:00:00 2001
From: ruzaqiarkan-eng <289478799+ruzaqiarkan-eng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 14:54:34 +0000
Subject: [PATCH 1/2] refactor: align Rule Builder with Frappe v15 reactive
architecture
- Implemented centralized `watchEffect` in `RuleBuilder` controller for reactive Page API synchronization.
- Decoupled Pinia stores from Frappe Page API (moved breadcrumbs to controller).
- Removed manual DOM manipulation for title badges in favor of standard `set_indicator`.
- Implemented dynamic primary actions (Save, Activate, Edit) based on document state.
- Brokered all Page chrome interactions through the controller.
- Produced architecture report and migration plan in `report/architecture_report_v1.md`.
---
.../js/flexirule/rule_builder/rule_builder.js | 225 ++++++------------
.../rule_builder/stores/useRuleStore.js | 25 --
report/architecture_report_v1.md | 97 ++++++++
3 files changed, 172 insertions(+), 175 deletions(-)
create mode 100644 report/architecture_report_v1.md
diff --git a/flexirule/public/js/flexirule/rule_builder/rule_builder.js b/flexirule/public/js/flexirule/rule_builder/rule_builder.js
index ae4b9bd5..02ab085c 100644
--- a/flexirule/public/js/flexirule/rule_builder/rule_builder.js
+++ b/flexirule/public/js/flexirule/rule_builder/rule_builder.js
@@ -1,4 +1,4 @@
-import { createApp, watch } from "vue";
+import { createApp, watchEffect } from "vue";
import { createPinia } from "pinia";
// Import FlexiRule Utilities
@@ -23,58 +23,16 @@ class RuleBuilder {
init() {
this.setup_app();
this.setup_page();
+ this.watch_changes();
}
setup_page() {
- // Set page title
- this.page.set_title(__(this.rule));
-
// Clear existing actions
this.page.clear_actions();
this.page.clear_menu();
this.page.clear_custom_actions();
- // Primary action - Save button
- const translatedSaveLabel = __("Save Rule");
- this.save_button_label =
- typeof translatedSaveLabel === "string" && translatedSaveLabel.trim()
- ? translatedSaveLabel
- : "Save Rule";
- this.save_btn = this.page.set_primary_action(this.save_button_label, () =>
- this.ruleStore.save_changes()
- );
-
- // Secondary button - Reset
- this.reset_btn = this.page.add_button(
- __("Reset Changes"),
- () => {
- this.ruleStore.fetch();
- },
- { icon: "refresh" }
- );
- this.reset_btn.hide();
-
- // Status Toggle
- this.status_btn = this.page.add_inner_button(__("Draft"), async () => {
- await this.toggle_rule_active();
- });
-
- // Debug
- this.test_btn = this.page.add_inner_button(__("Debug Rule"), () => {
- flexirule.debug.show_dialog();
- });
-
- // Clear visualization if any
- this.clear_test_btn = this.page.add_inner_button(__("Clear Debug Path"), () => {
- this.uiStore.clear_test_result();
- this.update_test_ui([]);
- });
- this.clear_test_btn.hide();
-
- // Custom status area beside title
- this.setup_custom_header();
-
- // Menu items
+ // Static menu items
this.page.add_menu_item(__("Go to Rule"), () => {
frappe.set_route("Form", "Rule", this.rule);
});
@@ -84,6 +42,25 @@ class RuleBuilder {
});
}
+ setup_breadcrumbs() {
+ if (frappe.breadcrumbs && frappe.breadcrumbs.$breadcrumbs) {
+ let breadcrumbs = `
+
${__("Rule")}
+ ${__(this.ruleStore.rule_doc?.rule_name || this.rule)}
+ ${__("Builder")}
+ `;
+ frappe.breadcrumbs.clear();
+ frappe.breadcrumbs.$breadcrumbs.append(breadcrumbs);
+ } else if (frappe.breadcrumbs && frappe.breadcrumbs.add) {
+ // Modern Frappe 15 fallback
+ frappe.breadcrumbs.add({
+ type: "Custom",
+ module: "Rule",
+ workspace: "RuleFlow",
+ });
+ }
+ }
+
show_keyboard_shortcuts() {
if (this.uiStore) {
this.uiStore.show_shortcuts_help = true;
@@ -111,50 +88,68 @@ class RuleBuilder {
this.uiStore = useUIStore(pinia);
this.ruleStore.rule_name = this.rule;
- // Initial sync
- this.update_test_ui(this.uiStore.test_execution_path);
+ this.setup_debug_api();
- // Watch for dirty state changes (computed)
- watch(
- () => this.ruleStore.is_dirty,
- (is_dirty) => {
- this.update_save_button(is_dirty);
- if (this.reset_btn) {
- is_dirty ? this.reset_btn.show() : this.reset_btn.hide();
- }
- }
- );
+ // Mount app
+ this.$rule_builder = app.mount(this.$wrapper.get(0));
+ }
- // Watch for active status changes
- watch(
- () => this.ruleStore.rule_doc?.is_active,
- (is_active) => {
- this.update_status_button(is_active);
+ watch_changes() {
+ watchEffect(() => {
+ const is_dirty = this.ruleStore.is_dirty;
+ const is_active = this.ruleStore.is_active;
+ const has_test_path = this.uiStore.has_test_path;
+
+ // 1. Update Title & Breadcrumbs
+ this.page.set_title(__(this.ruleStore.rule_doc?.rule_name || this.rule));
+ this.setup_breadcrumbs();
+
+ // 2. Indicators
+ this.page.clear_indicator();
+ if (is_dirty) {
+ this.page.set_indicator(__("Not Saved"), "orange");
+ } else if (is_active) {
+ this.page.set_indicator(__("Active"), "green");
+ } else {
+ this.page.set_indicator(__("Draft"), "orange");
}
- );
- this.uiStore.$subscribe((mutation, state) => {
- this.update_test_ui(state.test_execution_path);
+ // 3. Toolbar Actions
+ this.refresh_toolbar(is_dirty, is_active, has_test_path);
});
+ }
- // Initial status update after fetch
- // We might need to wait for fetch, but store.$subscribe handles mutations.
- // We can also watch rule_doc specifically if needed, but the main subscribe is usually enough for state changes.
- // Also manual call after mount if data is already there (it fetches async)
+ refresh_toolbar(is_dirty, is_active, has_test_path) {
+ this.page.clear_actions();
+ this.page.clear_custom_actions();
- // Use a watcher on rule_doc specifically
- const unwatch = this.ruleStore.$onAction(({ name, after }) => {
- if (name === "fetch") {
- after(() => {
- this.update_status_button(this.ruleStore.rule_doc?.is_active);
- });
- }
- });
+ // Primary Action: Save or Activate
+ if (is_dirty) {
+ this.page.set_primary_action(__("Save Rule"), () => this.ruleStore.save_changes());
+ } else if (!is_active) {
+ this.page.set_primary_action(__("Activate Rule"), () => this.toggle_rule_active());
+ } else {
+ // If active and not dirty, primary action is to Unlock
+ this.page.set_primary_action(__("Edit Rule"), () => this.toggle_rule_active());
+ }
- this.setup_debug_api();
+ // Secondary Actions
+ if (is_dirty) {
+ this.page.add_button(__("Reset Changes"), () => this.ruleStore.fetch(), {
+ icon: "refresh",
+ });
+ }
- // Mount app
- this.$rule_builder = app.mount(this.$wrapper.get(0));
+ // Inner Buttons
+ this.page.add_inner_button(__("Debug Rule"), () => {
+ flexirule.debug.show_dialog();
+ });
+
+ if (has_test_path) {
+ this.page.add_inner_button(__("Clear Debug Path"), () => {
+ this.uiStore.clear_test_result();
+ });
+ }
}
async toggle_rule_active() {
@@ -180,76 +175,6 @@ class RuleBuilder {
} finally {
frappe.dom.unfreeze();
}
-
- this.update_status_button(this.ruleStore.rule_doc?.is_active);
- }
-
- update_status_button(is_active) {
- const status_text = is_active ? __("Active") : __("Draft");
- const indicator_color = is_active ? "green" : "orange";
-
- // 1. Update Title with Badges
- // We use a container to avoid overwriting the whole title if possible
- if (!this.page.$title_area.find(".flexirule-status-badges").length) {
- this.page.$title_area.find(".title-text, .page-title").first().append(`
-
- `);
- }
- const $badges = this.page.$title_area.find(".flexirule-status-badges");
-
- let badges_html = `
- ${status_text}
- `;
-
- if (
- this.ruleStore.rule_doc?.trigger_type === "Callable Event" &&
- this.ruleStore.rule_doc?.exposed_as_subrule
- ) {
- badges_html += `
- ${__("Sub-Rule")}
- `;
- }
- $badges.html(badges_html);
-
- // 2. Update Toggle Button (on the right)
- if (this.status_btn) {
- this.status_btn.show().removeClass("hide");
- if (is_active) {
- this.status_btn
- .text(__("Unlock for Editing"))
- .removeClass("btn-default btn-primary btn-success")
- .addClass("btn-warning");
- } else {
- this.status_btn
- .text(__("Set to Active"))
- .removeClass("btn-warning btn-success")
- .addClass("btn-default");
- }
- }
- }
-
- update_save_button(is_dirty) {
- if (this.save_btn?.text()?.trim() !== this.save_button_label) {
- this.save_btn.text(this.save_button_label);
- }
- if (is_dirty) {
- this.save_btn.removeClass("btn-primary-light").addClass("btn-primary");
- this.page.set_indicator(__("Not Saved"), "orange");
- } else {
- this.save_btn.removeClass("btn-primary").addClass("btn-primary-light");
- this.page.clear_indicator();
- }
- }
-
- update_test_ui(test_path) {
- const show = !!(test_path && test_path.length > 0);
- if (this.clear_test_btn) {
- if (show) {
- this.clear_test_btn.show().removeClass("hide");
- } else {
- this.clear_test_btn.hide().addClass("hide");
- }
- }
}
setup_debug_api() {
diff --git a/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js b/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
index 4e8041e9..60974d47 100644
--- a/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
+++ b/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
@@ -184,8 +184,6 @@ export const useRuleStore = defineStore("rule-builder-rule", () => {
});
}
- setup_breadcrumbs();
-
// We wait for nextTick to ensure all reactive changes from
// graphStore sync and normalize have settled before we capture the baseline.
await nextTick();
@@ -913,29 +911,6 @@ export const useRuleStore = defineStore("rule-builder-rule", () => {
}
}
- // ── Breadcrumbs (Frappe pattern) ──
- function setup_breadcrumbs() {
- if (frappe.breadcrumbs && frappe.breadcrumbs.$breadcrumbs) {
- let breadcrumbs = `
- ${__("Rule")}
- ${__(
- rule_doc.value?.rule_name || rule_name.value
- )}
- ${__("Builder")}
- `;
- frappe.breadcrumbs.clear();
- frappe.breadcrumbs.$breadcrumbs.append(breadcrumbs);
- } else if (frappe.breadcrumbs && frappe.breadcrumbs.add) {
- // Modern Frappe 15 fallback (usually handled automatically by standard views,
- // but we can add the workspace breadcrumb if needed)
- frappe.breadcrumbs.add({
- type: "Custom",
- module: "Rule",
- workspace: "RuleFlow",
- });
- }
- }
-
return {
// State
rule_name,
diff --git a/report/architecture_report_v1.md b/report/architecture_report_v1.md
new file mode 100644
index 00000000..90e779b8
--- /dev/null
+++ b/report/architecture_report_v1.md
@@ -0,0 +1,97 @@
+# FlexiRule Architecture Report & Migration Plan (v1.0)
+
+## Phase 1: Analysis of Current Implementation
+
+### 1. Application Initialization & Mounting
+- **Page Entry**: The `rule-builder` Frappe Page (`rule_builder.js`) acts as the entry point. It uses `frappe.ui.make_app_page` to initialize the chrome and then dynamically requires `rule_builder.bundle.js`.
+- **Controller**: The `frappe.ui.RuleBuilder` class in the bundle is the "Controller". It:
+ - Receives the `page` object and `wrapper`.
+ - Initializes Pinia and mounts the Vue 3 app (`App.vue`).
+ - Holds references to `ruleStore` and `uiStore`.
+- **Vue Mounting**: The app is mounted to `.layout-main-section`.
+
+### 2. Page API & Toolbar Implementation
+- **Access**: The `RuleBuilder` class accesses the Page API via `this.page`.
+- **Toolbar Buttons**: Primary (Save) and secondary (Reset, Draft/Active toggle, Debug) buttons are created in `setup_page()`.
+- **State Coupling**:
+ - The controller uses manual Vue `watch` calls on `ruleStore.is_dirty` and `ruleStore.rule_doc.is_active`.
+ - It also uses Pinia `$subscribe` and `$onAction` to trigger UI updates like `update_status_button` and `update_test_ui`.
+- **DOM Manipulation**: The status indicators (Active/Draft/Sub-Rule) are currently injected into the title area using jQuery DOM manipulation (`this.page.$title_area.find(...)`).
+
+### 3. Event & Data Flow
+1. **User Action**: User clicks a button in a Vue component.
+2. **Store Action**: Component calls a Pinia store action (e.g., `graphStore.addNode`).
+3. **State Change**: Store updates state, triggering `is_dirty` computed property.
+4. **Controller Watcher**: `RuleBuilder` class detects change via `watch`.
+5. **Page Update**: Controller calls `page.set_indicator` or `page.set_primary_action`.
+
+---
+
+## Phase 2: Frappe Best Practices (Source of Truth)
+
+### 1. Form Builder / Workflow Builder Patterns
+- **Standardization**: Frappe uses a JS class (e.g., `WorkflowBuilder`) as a wrapper for the Vue app.
+- **Reactivity**:
+ - They use `watchEffect` inside the controller to keep the Frappe Page synchronized with the store.
+ - **Dirty State**: `watchEffect(() => { if (store.dirty) frm.dirty(); })`.
+- **Toolbar**:
+ - Primary actions are set once or updated reactively.
+ - They avoid complex manual DOM manipulation for titles, preferring standard `page.set_title` and `page.set_indicator`.
+
+### 2. Key Findings
+- **Single Source of Truth**: The Pinia store is the source of truth for the *application state*, while the Controller is the source of truth for the *integration*.
+- **No Direct API in Vue**: Vue components are "dumb" regarding the Frappe Page; they only know about the store.
+
+---
+
+## Phase 3: Architecture Comparison
+
+| Feature | Current FlexiRule | Frappe Standard (v15) | Recommendation |
+| :--- | :--- | :--- | :--- |
+| **Toolbar Sync** | Fragmented `watch` + `$subscribe` | Centralized `watchEffect` | **Adopt `watchEffect`** |
+| **Status Display** | Manual jQuery DOM injection | `page.set_indicator` | **Use Standard Indicators** |
+| **Primary Action** | Fixed label ("Save Rule") | Dynamic based on context | **Make Dynamic** |
+| **Breadcrumbs** | Handled in Pinia Store | Handled in Controller | **Move to Controller** |
+
+---
+
+## Phase 4: Migration Design
+
+### 1. Reactive Integration Strategy
+The `RuleBuilder` controller will implement a single `watch_changes()` method using Vue's `watchEffect`. This effect will track:
+- `ruleStore.is_dirty`
+- `ruleStore.is_active`
+- `uiStore.has_test_path`
+- `ruleStore.rule_doc.rule_name`
+
+When any of these change, the controller will call a `refresh_page()` method that updates the title, indicators, and toolbar in one pass.
+
+### 2. State Ownership
+- **Pinia**: Owns the "Dirty" flag, the "Active" status, and the "Has Results" state.
+- **Controller**: Owns the mapping of that state to Frappe Page API calls.
+
+---
+
+## Phase 5: Implementation Plan
+
+### 1. Files to Modify
+- `flexirule/public/js/flexirule/rule_builder/rule_builder.js`:
+ - Remove fragmented watchers.
+ - Implement `watch_changes()` and `refresh_toolbar()`.
+ - Clean up `setup_page()`.
+- `flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js`:
+ - Remove `setup_breadcrumbs` and any other Page API calls.
+
+### 2. New Update Flow
+1. **Change** happens in Store.
+2. **`watchEffect`** in `RuleBuilder` is triggered automatically.
+3. **`refresh_toolbar()`** clears and re-adds actions based on current store state.
+4. **`set_indicator()`** updates the status (Draft/Active) and dirty state.
+
+### 3. Risks & Testing
+- **Risk**: Frequent toolbar clearing might cause a slight flicker.
+ - *Mitigation*: Only clear/re-add if the relevant state actually changed, or rely on Frappe's efficient DOM handling.
+- **Testing**:
+ - Verify "Save" button appears/disappears correctly.
+ - Verify "Activate" button replaces "Save" when not dirty.
+ - Verify "Edit" (Unlock) button appears when Active.
From 510f948437f038025c376c0b65fbdee72bbc1acf Mon Sep 17 00:00:00 2001
From: ruzaqiarkan-eng <289478799+ruzaqiarkan-eng@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:04:29 +0000
Subject: [PATCH 2/2] refactor: fix reactive UI updates for rule lifecycle
transitions
- Fixed bug where UI (indicators/toolbar) wouldn't update after Activation/Deactivation without a hard refresh.
- Removed blocking `is_loading` guard in `fetch()` to allow forced refreshes from lifecycle actions.
- Enhanced `watchEffect` in `RuleBuilder` controller to track `ruleStore.rule_doc` directly.
- Ensured `toggle_rule_active` is the single source of truth for Activation/Deactivation flows in the UI.
- Maintained architectural separation between stores (data) and controller (integration).
---
flexirule/public/js/flexirule/rule_builder/rule_builder.js | 3 +++
.../public/js/flexirule/rule_builder/stores/useRuleStore.js | 4 ++--
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/flexirule/public/js/flexirule/rule_builder/rule_builder.js b/flexirule/public/js/flexirule/rule_builder/rule_builder.js
index 02ab085c..d52c35ce 100644
--- a/flexirule/public/js/flexirule/rule_builder/rule_builder.js
+++ b/flexirule/public/js/flexirule/rule_builder/rule_builder.js
@@ -100,6 +100,9 @@ class RuleBuilder {
const is_active = this.ruleStore.is_active;
const has_test_path = this.uiStore.has_test_path;
+ // Access rule_doc to ensure re-run on full doc refresh
+ const _doc = this.ruleStore.rule_doc;
+
// 1. Update Title & Breadcrumbs
this.page.set_title(__(this.ruleStore.rule_doc?.rule_name || this.rule));
this.setup_breadcrumbs();
diff --git a/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js b/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
index 60974d47..2dbca3d9 100644
--- a/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
+++ b/flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
@@ -80,8 +80,8 @@ export const useRuleStore = defineStore("rule-builder-rule", () => {
const historyStore = useHistoryStore();
const uiStore = useUIStore();
- // Guard to prevent redundant fetches
- if (is_loading.value) return;
+ // Guard to prevent redundant fetches, but allow if it's a forced refresh
+ // (is_loading is often set by the caller like save_changes or activate_rule)
is_loading.value = true;
uiStore.is_initializing = true;