mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into zachgoll/plaid-domain-improvements
This commit is contained in:
commit
7ad3ea8223
17 changed files with 242 additions and 91 deletions
|
@ -78,6 +78,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility bg-divider {
|
||||||
|
@apply bg-alpha-black-100;
|
||||||
|
|
||||||
|
@variant theme-dark {
|
||||||
|
@apply bg-alpha-white-100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@utility bg-overlay {
|
@utility bg-overlay {
|
||||||
background-color: --alpha(var(--color-gray-100) / 50%);
|
background-color: --alpha(var(--color-gray-100) / 50%);
|
||||||
|
|
||||||
|
@ -88,4 +96,4 @@
|
||||||
|
|
||||||
@utility bg-loader {
|
@utility bg-loader {
|
||||||
@apply bg-surface-inset animate-pulse;
|
@apply bg-surface-inset animate-pulse;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,14 @@ class RulesController < ApplicationController
|
||||||
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
|
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@rules = Current.family.rules.order(created_at: :desc)
|
@sort_by = params[:sort_by] || "name"
|
||||||
|
@direction = params[:direction] || "asc"
|
||||||
|
|
||||||
|
allowed_columns = [ "name", "updated_at" ]
|
||||||
|
@sort_by = "name" unless allowed_columns.include?(@sort_by)
|
||||||
|
@direction = "asc" unless [ "asc", "desc" ].include?(@direction)
|
||||||
|
|
||||||
|
@rules = Current.family.rules.order(@sort_by => @direction)
|
||||||
render layout: "settings"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,7 +71,7 @@ class RulesController < ApplicationController
|
||||||
|
|
||||||
def rule_params
|
def rule_params
|
||||||
params.require(:rule).permit(
|
params.require(:rule).permit(
|
||||||
:resource_type, :effective_date, :active,
|
:resource_type, :effective_date, :active, :name,
|
||||||
conditions_attributes: [
|
conditions_attributes: [
|
||||||
:id, :condition_type, :operator, :value, :_destroy,
|
:id, :condition_type, :operator, :value, :_destroy,
|
||||||
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
|
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
|
||||||
|
|
|
@ -21,12 +21,23 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(e) {
|
remove(e) {
|
||||||
|
// Find the parent rules controller before removing the condition
|
||||||
|
const rulesEl = this.element.closest('[data-controller~="rules"]');
|
||||||
|
|
||||||
if (e.params.destroy) {
|
if (e.params.destroy) {
|
||||||
this.destroyFieldTarget.value = true;
|
this.destroyFieldTarget.value = true;
|
||||||
this.element.classList.add("hidden");
|
this.element.classList.add("hidden");
|
||||||
} else {
|
} else {
|
||||||
this.element.remove();
|
this.element.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the prefixes of all conditions from the parent rules controller
|
||||||
|
if (rulesEl) {
|
||||||
|
const rulesController = this.application.getControllerForElementAndIdentifier(rulesEl, "rules");
|
||||||
|
if (rulesController && typeof rulesController.updateConditionPrefixes === "function") {
|
||||||
|
rulesController.updateConditionPrefixes();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConditionTypeChange(e) {
|
handleConditionTypeChange(e) {
|
||||||
|
|
|
@ -11,11 +11,17 @@ export default class extends Controller {
|
||||||
"effectiveDateInput",
|
"effectiveDateInput",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Update condition prefixes on first connection (form render on edit)
|
||||||
|
this.updateConditionPrefixes();
|
||||||
|
}
|
||||||
|
|
||||||
addConditionGroup() {
|
addConditionGroup() {
|
||||||
this.#appendTemplate(
|
this.#appendTemplate(
|
||||||
this.conditionGroupTemplateTarget,
|
this.conditionGroupTemplateTarget,
|
||||||
this.conditionsListTarget,
|
this.conditionsListTarget,
|
||||||
);
|
);
|
||||||
|
this.updateConditionPrefixes();
|
||||||
}
|
}
|
||||||
|
|
||||||
addCondition() {
|
addCondition() {
|
||||||
|
@ -23,6 +29,7 @@ export default class extends Controller {
|
||||||
this.conditionTemplateTarget,
|
this.conditionTemplateTarget,
|
||||||
this.conditionsListTarget,
|
this.conditionsListTarget,
|
||||||
);
|
);
|
||||||
|
this.updateConditionPrefixes();
|
||||||
}
|
}
|
||||||
|
|
||||||
addAction() {
|
addAction() {
|
||||||
|
@ -45,4 +52,27 @@ export default class extends Controller {
|
||||||
#uniqueKey() {
|
#uniqueKey() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates the prefix visibility of all conditions and condition groups
|
||||||
|
// This is also called by the rule/conditions_controller when a subcondition is removed
|
||||||
|
updateConditionPrefixes() {
|
||||||
|
const conditions = Array.from(this.conditionsListTarget.children);
|
||||||
|
let conditionIndex = 0;
|
||||||
|
|
||||||
|
conditions.forEach((condition) => {
|
||||||
|
// Only process visible conditions, this prevents conditions that are marked for removal and hidden
|
||||||
|
// from being added to the index. This is important when editing a rule.
|
||||||
|
if (!condition.classList.contains('hidden')) {
|
||||||
|
const prefixEl = condition.querySelector('[data-condition-prefix]');
|
||||||
|
if (prefixEl) {
|
||||||
|
if (conditionIndex === 0) {
|
||||||
|
prefixEl.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
prefixEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
conditionIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,10 @@ class Rule < ApplicationRecord
|
||||||
accepts_nested_attributes_for :conditions, allow_destroy: true
|
accepts_nested_attributes_for :conditions, allow_destroy: true
|
||||||
accepts_nested_attributes_for :actions, allow_destroy: true
|
accepts_nested_attributes_for :actions, allow_destroy: true
|
||||||
|
|
||||||
|
before_validation :normalize_name
|
||||||
|
|
||||||
validates :resource_type, presence: true
|
validates :resource_type, presence: true
|
||||||
|
validates :name, length: { minimum: 1 }, allow_nil: true
|
||||||
validate :no_nested_compound_conditions
|
validate :no_nested_compound_conditions
|
||||||
|
|
||||||
# Every rule must have at least 1 action
|
# Every rule must have at least 1 action
|
||||||
|
@ -46,6 +49,18 @@ class Rule < ApplicationRecord
|
||||||
RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)
|
RuleJob.perform_later(self, ignore_attribute_locks: ignore_attribute_locks)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def primary_condition_title
|
||||||
|
return "No conditions" if conditions.none?
|
||||||
|
|
||||||
|
first_condition = conditions.first
|
||||||
|
if first_condition.compound? && first_condition.sub_conditions.any?
|
||||||
|
first_sub_condition = first_condition.sub_conditions.first
|
||||||
|
"If #{first_sub_condition.filter.label.downcase} #{first_sub_condition.operator} #{first_sub_condition.value_display}"
|
||||||
|
else
|
||||||
|
"If #{first_condition.filter.label.downcase} #{first_condition.operator} #{first_condition.value_display}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def matching_resources_scope
|
def matching_resources_scope
|
||||||
scope = registry.resource_scope
|
scope = registry.resource_scope
|
||||||
|
@ -87,4 +102,8 @@ class Rule < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_name
|
||||||
|
self.name = nil if name.is_a?(String) && name.strip.empty?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Rule::Action < ApplicationRecord
|
class Rule::Action < ApplicationRecord
|
||||||
belongs_to :rule
|
belongs_to :rule, touch: true
|
||||||
|
|
||||||
validates :action_type, presence: true
|
validates :action_type, presence: true
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class Rule::Condition < ApplicationRecord
|
class Rule::Condition < ApplicationRecord
|
||||||
belongs_to :rule, optional: -> { where.not(parent_id: nil) }
|
belongs_to :rule, touch: true, optional: -> { where.not(parent_id: nil) }
|
||||||
belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions
|
belongs_to :parent, class_name: "Rule::Condition", optional: true, inverse_of: :sub_conditions
|
||||||
|
|
||||||
has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
|
has_many :sub_conditions, class_name: "Rule::Condition", foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
<%# Initial rendering based on rule.action_executors.first from the rule form. %>
|
<%# Initial rendering based on rule.action_executors.first from the rule form. %>
|
||||||
<%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %>
|
<%# This is currently always SetTransactionCategory from transaction_resource.rb, which is a select type. %>
|
||||||
<%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>
|
<%# Subsequent renders are injected by the Stimulus controller, which uses the templates from below. %>
|
||||||
<span class="font-medium uppercase text-xs">to</span>
|
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||||
<%= form.select :value, action.options || [], {} %>
|
<%= form.select :value, action.options || [], {} %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,12 +29,12 @@
|
||||||
|
|
||||||
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
|
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
|
||||||
<template data-rule--actions-target="selectTemplate">
|
<template data-rule--actions-target="selectTemplate">
|
||||||
<span class="font-medium uppercase text-xs">to</span>
|
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||||
<%= form.select :value, [], {} %>
|
<%= form.select :value, [], {} %>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template data-rule--actions-target="textTemplate">
|
<template data-rule--actions-target="textTemplate">
|
||||||
<span class="font-medium uppercase text-xs">to</span>
|
<span class="font-medium text-primary uppercase text-xs">to</span>
|
||||||
<%= form.text_field :value, placeholder: "Enter a value" %>
|
<%= form.text_field :value, placeholder: "Enter a value" %>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,11 @@
|
||||||
<% rule = condition.rule %>
|
<% rule = condition.rule %>
|
||||||
|
|
||||||
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
|
<li data-controller="rule--conditions" data-rule--conditions-condition-filters-value="<%= rule.condition_filters.to_json %>" class="flex items-center gap-3">
|
||||||
<% if form.index.to_i > 0 && show_prefix %>
|
|
||||||
<div class="pl-4">
|
<%# Conditionally render the prefix %>
|
||||||
|
<%# Condition groups pass in show_prefix: false for subconditions since the ANY/ALL selector makes that clear %>
|
||||||
|
<% if show_prefix %>
|
||||||
|
<div class="pl-2" data-condition-prefix>
|
||||||
<span class="font-medium uppercase text-xs">and</span>
|
<span class="font-medium uppercase text-xs">and</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -3,17 +3,16 @@
|
||||||
<% condition = form.object %>
|
<% condition = form.object %>
|
||||||
<% rule = condition.rule %>
|
<% rule = condition.rule %>
|
||||||
|
|
||||||
<li data-controller="rule--conditions element-removal" class="border border-secondary rounded-md p-4 space-y-3">
|
<li data-controller="rule--conditions" class="border border-secondary rounded-md p-4 space-y-3">
|
||||||
|
|
||||||
<%= form.hidden_field :condition_type, value: "compound" %>
|
<%= form.hidden_field :condition_type, value: "compound" %>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<% unless form.index == 0 %>
|
<%# Show prefix on condition groups, except the first one %>
|
||||||
<div class="pl-2">
|
<div class="pl-2" data-condition-prefix>
|
||||||
<span class="font-medium uppercase text-xs">and</span>
|
<span class="font-medium uppercase text-xs">and</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
<p class="text-sm text-secondary">match</p>
|
<p class="text-sm text-secondary">match</p>
|
||||||
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
|
<%= form.select :operator, [["all", "and"], ["any", "or"]], { container_class: "w-fit" }, data: { rules_target: "operatorField" } %>
|
||||||
<p class="text-sm text-secondary">of the following conditions</p>
|
<p class="text-sm text-secondary">of the following conditions</p>
|
||||||
|
@ -21,16 +20,16 @@
|
||||||
|
|
||||||
<%= icon(
|
<%= icon(
|
||||||
"trash-2",
|
"trash-2",
|
||||||
size: "sm",
|
|
||||||
as_button: true,
|
as_button: true,
|
||||||
data: { action: "element-removal#remove" }
|
size: "sm",
|
||||||
|
data: { action: "rule--conditions#remove" }
|
||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
|
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
|
||||||
<template data-rule--conditions-target="subConditionTemplate">
|
<template data-rule--conditions-target="subConditionTemplate">
|
||||||
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
|
<%= form.fields_for :sub_conditions, Rule::Condition.new(parent: condition, condition_type: rule.condition_filters.first.key), child_index: "IDX_PLACEHOLDER" do |scf| %>
|
||||||
<%= render "rule/conditions/condition", form: scf %>
|
<%= render "rule/conditions/condition", form: scf, show_prefix: false %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -44,6 +43,7 @@
|
||||||
text: "Add condition",
|
text: "Add condition",
|
||||||
leading_icon: "plus",
|
leading_icon: "plus",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
|
type: "button",
|
||||||
data: { action: "rule--conditions#addSubCondition" }
|
data: { action: "rule--conditions#addSubCondition" }
|
||||||
) %>
|
) %>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (rule:) %>
|
<%# locals: (rule:) %>
|
||||||
|
|
||||||
<%= styled_form_with model: rule, class: "space-y-4",
|
<%= styled_form_with model: rule, class: "space-y-6",
|
||||||
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
|
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
|
||||||
|
|
||||||
<%= f.hidden_field :resource_type, value: rule.resource_type %>
|
<%= f.hidden_field :resource_type, value: rule.resource_type %>
|
||||||
|
@ -10,9 +10,21 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h3 class="text-sm font-medium text-primary">If <%= rule.resource_type %></h3>
|
<div class="flex items-center gap-1 text-secondary">
|
||||||
|
<%= icon "tag", size: "sm" %>
|
||||||
|
<h3 class="text-sm font-medium text-primary">Rule name (optional)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="ml-6">
|
||||||
|
<%= f.text_field :name, placeholder: "Enter a name for this rule", class: "form-field__input" %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<%# Condition template, used by Stimulus controller to add new conditions dynamically %>
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center gap-1 text-secondary">
|
||||||
|
<h3 class="text-sm font-medium text-primary">IF</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Condition Group template, used by Stimulus controller to add new conditions dynamically %>
|
||||||
<template data-rules-target="conditionGroupTemplate">
|
<template data-rules-target="conditionGroupTemplate">
|
||||||
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
|
<%= f.fields_for :conditions, Rule::Condition.new(rule: rule, condition_type: "compound", operator: "and"), child_index: "IDX_PLACEHOLDER" do |cf| %>
|
||||||
<%= render "rule/conditions/condition_group", form: cf %>
|
<%= render "rule/conditions/condition_group", form: cf %>
|
||||||
|
@ -26,24 +38,27 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ul data-rules-target="conditionsList" class="space-y-3 mb-4">
|
<div class="ml-6 space-y-4">
|
||||||
<%= f.fields_for :conditions do |cf| %>
|
<ul data-rules-target="conditionsList" class="space-y-3">
|
||||||
<% if cf.object.compound? %>
|
<%= f.fields_for :conditions do |cf| %>
|
||||||
<%= render "rule/conditions/condition_group", form: cf %>
|
<% if cf.object.compound? %>
|
||||||
<% else %>
|
<%= render "rule/conditions/condition_group", form: cf %>
|
||||||
<%= render "rule/conditions/condition", form: cf %>
|
<% else %>
|
||||||
|
<%= render "rule/conditions/condition", form: cf %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
</ul>
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %>
|
<%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %>
|
||||||
<%= render ButtonComponent.new(text: "Add condition group", icon: "boxes", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
|
<%= render ButtonComponent.new(text: "Add condition group", icon: "copy-plus", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h3 class="text-sm font-medium text-primary">Then</h3>
|
<div class="flex items-center gap-1 text-secondary">
|
||||||
|
<h3 class="text-sm font-medium text-primary">THEN</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%# Action template, used by Stimulus controller to add new actions dynamically %>
|
<%# Action template, used by Stimulus controller to add new actions dynamically %>
|
||||||
<template data-rules-target="actionTemplate">
|
<template data-rules-target="actionTemplate">
|
||||||
|
@ -52,22 +67,25 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ul data-rules-target="actionsList" class="space-y-3">
|
<div class="ml-6 space-y-4">
|
||||||
<%= f.fields_for :actions do |af| %>
|
<ul data-rules-target="actionsList" class="space-y-3">
|
||||||
<%= render "rule/actions/action", form: af %>
|
<%= f.fields_for :actions do |af| %>
|
||||||
<% end %>
|
<%= render "rule/actions/action", form: af %>
|
||||||
</ul>
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
|
<%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h3 class="text-sm font-medium text-primary">Apply this</h3>
|
<div class="flex items-center gap-1 text-secondary">
|
||||||
|
<h3 class="text-sm font-medium text-primary">FOR</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="ml-6 space-y-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
|
<%= f.radio_button :effective_date_enabled, false, checked: rule.effective_date.nil?, data: { action: "rules#clearEffectiveDate" } %>
|
||||||
<%= f.label :effective_date_enabled_false, "To all past and future #{rule.resource_type}s", class: "text-sm text-primary" %>
|
<%= f.label :effective_date_enabled_false, "All past and future #{rule.resource_type}s", class: "text-sm text-primary" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
|
@ -1,51 +1,64 @@
|
||||||
<%# locals: (rule:) %>
|
<%# locals: (rule:) %>
|
||||||
|
<div class="flex justify-between items-center p-4 <%= rule.active? ? 'text-primary' : 'text-secondary' %>">
|
||||||
<div class="flex justify-between items-center gap-4 bg-white shadow-border-xs rounded-md p-4">
|
|
||||||
<div class="text-sm space-y-1.5">
|
<div class="text-sm space-y-1.5">
|
||||||
|
<% if rule.name.present? %>
|
||||||
|
<h3 class="font-medium text-md"><%= rule.name %></h3>
|
||||||
|
<% end %>
|
||||||
<% if rule.conditions.any? %>
|
<% if rule.conditions.any? %>
|
||||||
<p class="flex items-center flex-wrap gap-1.5">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
|
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||||
<% if rule.conditions.first.compound? %>
|
<span class="font-mono text-xs">IF</span>
|
||||||
If <%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
|
</div>
|
||||||
|
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||||
|
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||||
|
<% if rule.conditions.first.compound? %>
|
||||||
|
<%= rule.conditions.first.sub_conditions.first.filter.label %> <%= rule.conditions.first.sub_conditions.first.operator %> <%= rule.conditions.first.sub_conditions.first.value_display %>
|
||||||
|
<% else %>
|
||||||
|
<%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<% if rule.conditions.count > 1 %>
|
||||||
|
and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||||
|
<span class="font-mono text-xs">THEN</span>
|
||||||
|
</div>
|
||||||
|
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||||
|
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||||
|
<% if rule.actions.first.value && rule.actions.first.options %>
|
||||||
|
<%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>
|
||||||
<% else %>
|
<% else %>
|
||||||
If <%= rule.conditions.first.filter.label %> <%= rule.conditions.first.operator %> <%= rule.conditions.first.value_display %>
|
<%= rule.actions.first.executor.label %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
|
<% if rule.actions.count > 1 %>
|
||||||
<% if rule.conditions.count > 1 %>
|
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
|
||||||
and <%= rule.conditions.count - 1 %> more <%= rule.conditions.count - 1 == 1 ? "condition" : "conditions" %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<p class="flex items-center flex-wrap gap-1.5">
|
<div class="flex items-center gap-1 text-secondary w-16 shrink-0">
|
||||||
<span class="px-2 py-1 border border-alpha-black-200 rounded-full">
|
<span class="font-mono text-xs">FOR</span>
|
||||||
<% if rule.actions.first.value && rule.actions.first.options %>
|
</div>
|
||||||
<%= rule.actions.first.executor.label %> to <%= rule.actions.first.value_display %>
|
<p class="flex items-center flex-wrap gap-1.5 m-0">
|
||||||
<% else %>
|
<span class="px-2 py-1 border border-secondary rounded-full">
|
||||||
<%= rule.actions.first.executor.label %>
|
<% if rule.effective_date.nil? %>
|
||||||
<% end %>
|
All past and future <%= rule.resource_type.pluralize %>
|
||||||
</span>
|
<% else %>
|
||||||
|
<%= rule.resource_type.pluralize %> on or after <%= rule.effective_date.strftime('%b %-d, %Y') %>
|
||||||
<% if rule.actions.count > 1 %>
|
<% end %>
|
||||||
and <%= rule.actions.count - 1 %> more <%= rule.actions.count - 1 == 1 ? "action" : "actions" %>
|
</span>
|
||||||
<% end %>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<p class="flex items-center flex-wrap gap-1.5">
|
|
||||||
<% if rule.effective_date.nil? %>
|
|
||||||
To all past and future <%= rule.resource_type.pluralize %>
|
|
||||||
<% else %>
|
|
||||||
To all <%= rule.resource_type.pluralize %> on or after <%= rule.effective_date %>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
|
<%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
|
||||||
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
|
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render MenuComponent.new do |menu| %>
|
<%= render MenuComponent.new do |menu| %>
|
||||||
<% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
|
<% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
|
||||||
<% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>
|
<% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
|
<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
|
||||||
<% dialog.with_header(title: "Confirm changes") %>
|
<%
|
||||||
|
title = if @rule.name.present?
|
||||||
|
"Confirm changes to \"#{@rule.name}\""
|
||||||
|
else
|
||||||
|
"Confirm changes"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<% dialog.with_header(title: title) %>
|
||||||
|
|
||||||
<% dialog.with_body do %>
|
<% dialog.with_body do %>
|
||||||
<p class="text-secondary text-sm mb-4">
|
<p class="text-secondary text-sm mb-4">
|
||||||
You are about to apply this rule to
|
You are about to apply this rule to
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
<%= link_to "Back to rules", rules_path %>
|
<%= link_to "Back to rules", rules_path %>
|
||||||
|
|
||||||
<%= render DialogComponent.new do |dialog| %>
|
<%= render DialogComponent.new do |dialog| %>
|
||||||
<% dialog.with_header(title: "Edit #{@rule.resource_type} rule") %>
|
<%
|
||||||
|
title = if @rule.name.present?
|
||||||
|
"Edit #{@rule.resource_type} rule \"#{@rule.name}\""
|
||||||
|
else
|
||||||
|
"Edit #{@rule.resource_type} rule"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<% dialog.with_header(title: title) %>
|
||||||
|
|
||||||
<% dialog.with_body do %>
|
<% dialog.with_body do %>
|
||||||
<%= render "rules/form", rule: @rule %>
|
<%= render "rules/form", rule: @rule %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h1 class="text-primary text-xl font-medium">Rules</h1>
|
<h1 class="text-primary text-xl font-medium">Rules</h1>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<% if @rules.any? %>
|
<% if @rules.any? %>
|
||||||
<%= render MenuComponent.new do |menu| %>
|
<%= render MenuComponent.new do |menu| %>
|
||||||
|
@ -13,7 +12,6 @@
|
||||||
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
|
confirm: CustomConfirm.for_resource_deletion("all rules", high_severity: true)) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render LinkComponent.new(
|
<%= render LinkComponent.new(
|
||||||
text: "New rule",
|
text: "New rule",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
|
@ -23,7 +21,6 @@
|
||||||
) %>
|
) %>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<% if self_hosted? %>
|
<% if self_hosted? %>
|
||||||
<div class="flex items-center gap-2 mb-2 py-4">
|
<div class="flex items-center gap-2 mb-2 py-4">
|
||||||
<%= icon("circle-alert", size: "sm") %>
|
<%= icon("circle-alert", size: "sm") %>
|
||||||
|
@ -32,19 +29,43 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
<div class="bg-container shadow-border-xs rounded-xl p-4">
|
||||||
|
|
||||||
<% if @rules.any? %>
|
<% if @rules.any? %>
|
||||||
<div class="rounded-xl bg-gray-25 space-y-1">
|
<div class="bg-container-inset rounded-xl">
|
||||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
<div class="flex justify-between px-4 py-2 text-xs uppercase">
|
||||||
<p>Rules</p>
|
<div class="flex items-center gap-1.5 font-medium text-secondary">
|
||||||
<span class="text-subdued">·</span>
|
<p>Rules</p>
|
||||||
<p><%= @rules.count %></p>
|
<span class="text-subdued">·</span>
|
||||||
|
<p><%= @rules.count %></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-secondary">Sort by:</span>
|
||||||
|
<%= form_with url: rules_path, method: :get, local: true, class: "flex items-center", data: { controller: "auto-submit-form" } do |form| %>
|
||||||
|
<%= form.select :sort_by,
|
||||||
|
options_for_select([["Name", "name"], ["Updated At", "updated_at"]], @sort_by),
|
||||||
|
{},
|
||||||
|
class: "min-w-[120px] bg-transparent rounded border-none cursor-pointer text-primary uppercase text-xs w-auto",
|
||||||
|
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change" } %>
|
||||||
|
<%= form.hidden_field :direction, value: @direction %>
|
||||||
|
<% end %>
|
||||||
|
<%= render LinkComponent.new(
|
||||||
|
href: rules_path(direction: @direction == "asc" ? "desc" : "asc", sort_by: @sort_by),
|
||||||
|
variant: "icon",
|
||||||
|
icon: "arrow-up-down",
|
||||||
|
size: :sm,
|
||||||
|
title: "Toggle sort direction"
|
||||||
|
) %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="p-1">
|
||||||
<div class="space-y-1 p-1">
|
<div class="flex flex-col bg-container rounded-xl shadow-border-xs first_child:rounded-t-xl last_child:rounded-b-xl">
|
||||||
<%= render @rules %>
|
<% @rules.each_with_index do |rule, idx| %>
|
||||||
|
<%= render "rule", rule: rule%>
|
||||||
|
<% unless idx == @rules.size - 1 %>
|
||||||
|
<div class="h-px bg-divider ml-4 mr-6"></div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
5
db/migrate/20250429021255_add_name_to_rules.rb
Normal file
5
db/migrate/20250429021255_add_name_to_rules.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class AddNameToRules < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :rules, :name, :string
|
||||||
|
end
|
||||||
|
end
|
1
db/schema.rb
generated
1
db/schema.rb
generated
|
@ -498,6 +498,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_13_122703) do
|
||||||
t.boolean "active", default: false, null: false
|
t.boolean "active", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "name"
|
||||||
t.index ["family_id"], name: "index_rules_on_family_id"
|
t.index ["family_id"], name: "index_rules_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue