Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 78 additions & 150 deletions flexirule/public/js/flexirule/rule_builder/rule_builder.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createApp, watch } from "vue";
import { createApp, watchEffect } from "vue";
import { createPinia } from "pinia";

// Import FlexiRule Utilities
Expand All @@ -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);
});
Expand All @@ -84,6 +42,25 @@ class RuleBuilder {
});
}

setup_breadcrumbs() {
if (frappe.breadcrumbs && frappe.breadcrumbs.$breadcrumbs) {
let breadcrumbs = `
<li><a href="/app/rule">${__("Rule")}</a></li>
<li><a href="/app/rule/${this.rule}">${__(this.ruleStore.rule_doc?.rule_name || this.rule)}</a></li>
<li class="disabled"><a href="#">${__("Builder")}</a></li>
`;
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;
Expand Down Expand Up @@ -111,50 +88,71 @@ 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;

this.uiStore.$subscribe((mutation, state) => {
this.update_test_ui(state.test_execution_path);
});
// Access rule_doc to ensure re-run on full doc refresh
const _doc = this.ruleStore.rule_doc;

// 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)
// 1. Update Title & Breadcrumbs
this.page.set_title(__(this.ruleStore.rule_doc?.rule_name || this.rule));
this.setup_breadcrumbs();

// 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);
});
// 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");
}

// 3. Toolbar Actions
this.refresh_toolbar(is_dirty, is_active, has_test_path);
});
}

this.setup_debug_api();
refresh_toolbar(is_dirty, is_active, has_test_path) {
this.page.clear_actions();
this.page.clear_custom_actions();

// Mount app
this.$rule_builder = app.mount(this.$wrapper.get(0));
// 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());
}

// Secondary Actions
if (is_dirty) {
this.page.add_button(__("Reset Changes"), () => this.ruleStore.fetch(), {
icon: "refresh",
});
}

// 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() {
Expand All @@ -180,76 +178,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(`
<span class="flexirule-status-badges ml-2" style="display: inline-flex; gap: 4px; vertical-align: middle;"></span>
`);
}
const $badges = this.page.$title_area.find(".flexirule-status-badges");

let badges_html = `<span class="indicator-pill ${indicator_color}" style="font-size: 10px; padding: 2px 10px; font-weight: 700;">
${status_text}
</span>`;

if (
this.ruleStore.rule_doc?.trigger_type === "Callable Event" &&
this.ruleStore.rule_doc?.exposed_as_subrule
) {
badges_html += ` <span class="indicator-pill blue" style="font-size: 10px; padding: 2px 10px; font-weight: 700;">
${__("Sub-Rule")}
</span>`;
}
$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() {
Expand Down
29 changes: 2 additions & 27 deletions flexirule/public/js/flexirule/rule_builder/stores/useRuleStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = `
<li><a href="/app/rule">${__("Rule")}</a></li>
<li><a href="/app/rule/${rule_name.value}">${__(
rule_doc.value?.rule_name || rule_name.value
)}</a></li>
<li class="disabled"><a href="#">${__("Builder")}</a></li>
`;
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,
Expand Down
Loading
Loading