Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2bacf6c
Convert events and visits to dynamic Turbo Frame search
maebeale Mar 6, 2026
6dcab64
Move audience and time period to page header
maebeale Mar 6, 2026
f546080
Add resource name, visit ID, and props filters to activity events
maebeale Mar 6, 2026
2edef2b
Use MySQL ->> operator for resource_title JSON search
maebeale Mar 6, 2026
70cc2e0
Update filter badges dynamically via turbo_stream.replace
maebeale Mar 6, 2026
34b611b
Add from/to date chips matching time period color
maebeale Mar 6, 2026
5f15409
Auto-submit date fields on change
maebeale Mar 6, 2026
d1550ce
Handle date and number inputs in collection controller
maebeale Mar 6, 2026
cd24050
Fix trailing comma syntax errors in events and visits views
maebeale Mar 6, 2026
ba42cf0
Reorder filter chips and search fields, add dismissible X buttons
maebeale Mar 6, 2026
5e1ce66
Move filter chips below search boxes and above count row
maebeale Mar 6, 2026
e44d915
Change 'Filtering' label to 'Filters applied'
maebeale Mar 6, 2026
5fd066d
Add placeholder text to date fields and user dropdown
maebeale Mar 6, 2026
e5f9979
Style select placeholder text as grey when blank option is selected
maebeale Mar 6, 2026
3a44c52
Use Tailwind classes for select placeholder styling instead of custom…
maebeale Mar 6, 2026
a033e57
Match placeholder grey across all search inputs (text-gray-500)
maebeale Mar 6, 2026
3a86b93
Grey out date picker calendar icon when input is empty
maebeale Mar 6, 2026
64d02f6
Make date selector fields slightly wider (flex-[1.3])
maebeale Mar 6, 2026
ddc8114
Make date picker calendar icon always grey
maebeale Mar 6, 2026
14f5cfa
Adjust search field widths, rename Resource Name to Resource Title
maebeale Mar 6, 2026
e1410f3
Add autocomplete=off on individual search inputs
maebeale Mar 6, 2026
e456a1b
Add data-lpignore to Event Name field to prevent LastPass icon
maebeale Mar 6, 2026
393cd07
Add data-form-type=other and data-lpignore to search forms
maebeale Mar 6, 2026
27d037d
Improve activities search UI and add cross-linking between events/visits
maebeale Mar 6, 2026
ff2b01f
Fix Brakeman SQL injection warnings by removing string interpolation …
maebeale Mar 9, 2026
8d8c823
Remove orphaned filter code left by rebase in index action
maebeale Mar 11, 2026
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
201 changes: 116 additions & 85 deletions app/controllers/admin/ahoy_activities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,131 +5,162 @@ class AhoyActivitiesController < ApplicationController
def index
authorize! :ahoy_activity, to: :index?

@users = params[:user_id].present? ? User.where(id: params[:user_id].to_s.split("--")) : nil
if turbo_frame_request?
per_page = params[:per_page].presence&.to_i || 20
base_scope = Ahoy::Event.includes(:user, :visit)
filtered = apply_event_filters(base_scope)

sortable = %w[time name user]
@sort = sortable.include?(params[:sort]) ? params[:sort] : "time"
@sort_direction = params[:direction] == "asc" ? "asc" : "desc"
filtered = apply_event_sort(filtered, @sort, @sort_direction)

@events = filtered.paginate(page: params[:page], per_page: per_page)
base_count = base_scope.count
filtered_count = filtered.count
@count_display = filtered_count == base_count ? base_count : "#{filtered_count}/#{base_count}"

render :index_lazy
else
render :index
end
end

page = params[:page].presence&.to_i || 1
per_page = params[:per_page].presence&.to_i || 20
def show
authorize! :ahoy_activity, to: :show?
@event = Ahoy::Event.includes(:user, :visit).find(params[:id])
@resource_path = safe_resource_path(@event.resource_type, @event.resource_id)
end

scope = Ahoy::Event.includes(:user, :visit).order(time: :desc)
def visits
authorize! :ahoy_activity, to: :visits?

# Only real content interactions (not search/filter noise)
if params[:prefixes].present?
prefixes = params[:prefixes].split("--").map(&:strip)
if turbo_frame_request?
per_page = params[:per_page].presence&.to_i || 20
base_scope = Ahoy::Visit
.includes(:user)
.left_joins(:events)
.select("ahoy_visits.*, COUNT(ahoy_events.id) AS events_count, TIMESTAMPDIFF(MINUTE, ahoy_visits.started_at, MAX(ahoy_events.time)) AS duration_minutes")
.group("ahoy_visits.id")
filtered = apply_visit_filters(base_scope)

sortable = %w[started_at user events_count duration]
@sort = sortable.include?(params[:sort]) ? params[:sort] : "started_at"
@sort_direction = params[:direction] == "asc" ? "asc" : "desc"
filtered = apply_visit_sort(filtered, @sort, @sort_direction)

@visits = filtered.paginate(page: params[:page], per_page: per_page)
base_count = base_scope.reselect("ahoy_visits.id").count.size
filtered_count = filtered.reselect("ahoy_visits.id").count.size
@count_display = filtered_count == base_count ? base_count : "#{filtered_count}/#{base_count}"

render :visits_lazy
else
prefixes = nil # %w[ create update destroy auth ] # view browse print download
end
if prefixes.present?
scope = scope.where(prefixes.map { |p| "ahoy_events.name LIKE ?" }.join(" OR "),
*prefixes.map { |p| "#{p}.%" })
render :visits
end
end

# Filter by event name
if params[:event_name].present?
scope = scope.where("ahoy_events.name LIKE ?", "%#{Ahoy::Event.sanitize_sql_like(params[:event_name])}%")
end
def charts
authorize! :ahoy_activity, to: :charts?
@creation_velocity_data = creation_velocity_data
prepare_chart_data
end

# Filter by user (if viewing specific user activity)
scope = scope.where(user: @users) if @users.present?
private

# Time filter
def apply_event_filters(scope)
scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
scope = scope.where(time: time_range) if time_range.present?

if params[:from].present?
from_time = Time.zone.parse(params[:from]).beginning_of_day
scope = scope.where("ahoy_events.time >= ?", from_time)
scope = scope.where("ahoy_events.time >= ?", Time.zone.parse(params[:from]).beginning_of_day)
end

if params[:to].present?
to_time = Time.zone.parse(params[:to]).end_of_day
scope = scope.where("ahoy_events.time <= ?", to_time)
end

# Filter by visit
if params[:visit_id].present?
scope = scope.where(visit_id: params[:visit_id])
scope = scope.where("ahoy_events.time <= ?", Time.zone.parse(params[:to]).end_of_day)
end

# Filter by props (full-text search across properties JSON)
if params[:props].present?
term = Ahoy::Event.sanitize_sql_like(params[:props])
scope = scope.where(
"CAST(ahoy_events.properties AS CHAR) LIKE ?",
"%#{term}%"
)
if params[:prefixes].present?
prefixes = params[:prefixes].split("--").map(&:strip)
scope = scope.where(prefixes.map { "ahoy_events.name LIKE ?" }.join(" OR "),
*prefixes.map { |p| "#{p}.%" })
end

# Audience filter
scope = scope.where(visit_id: params[:visit_id]) if params[:visit_id].present?
scope = apply_audience_filter(scope)
scope = scope.where(resource_type: params[:resource_type]) if params[:resource_type].present?
scope = scope.where(resource_id: params[:resource_id]) if params[:resource_id].present?

# Filter by resource type and ID
if params[:resource_type].present?
scope = scope.where(resource_type: params[:resource_type])
if params[:event_name].present?
term = Ahoy::Event.sanitize_sql_like(params[:event_name])
scope = scope.where("ahoy_events.name LIKE ?", "%#{term}%")
end

if params[:resource_id].present?
scope = scope.where(resource_id: params[:resource_id])
if params[:resource_name].present?
term = Ahoy::Event.sanitize_sql_like(params[:resource_name])
scope = scope.where(
"LOWER(ahoy_events.properties->>'$.resource_title') LIKE LOWER(?)",
"%#{term}%"
)
end

@events = scope.paginate(page: page, per_page: per_page)
end
if params[:props].present?
term = Ahoy::Event.sanitize_sql_like(params[:props])
scope = scope.where("CAST(ahoy_events.properties AS CHAR) LIKE ?", "%#{term}%")
end

def show
authorize! :ahoy_activity, to: :show?
@event = Ahoy::Event.includes(:user, :visit).find(params[:id])
@resource_path = safe_resource_path(@event.resource_type, @event.resource_id)
scope
end

def visits
authorize! :ahoy_activity, to: :visits?

page = params[:page].presence&.to_i || 1
per_page = params[:per_page].presence&.to_i || 20

scope = Ahoy::Visit
.includes(:user)
.left_joins(:events)
.select("ahoy_visits.*, COUNT(ahoy_events.id) AS events_count")
.group("ahoy_visits.id")
.order(started_at: :desc)

# Filter by user
if params[:user_id].present?
scope = scope.where(user_id: params[:user_id])
end

# Filter by visit
if params[:visit_id].present?
scope = scope.where(id: params[:visit_id])
def apply_event_sort(scope, column, direction)
dir = direction.to_sym
case column
when "time"
scope.reorder(time: dir)
when "name"
scope.reorder(name: dir)
when "user"
user_sort = { "asc" => "users.first_name ASC, users.last_name ASC",
"desc" => "users.first_name DESC, users.last_name DESC" }
scope.left_joins(:user).reorder(Arel.sql(user_sort[direction]))
else
scope.reorder(time: :desc)
end
end

# Time period filter
def apply_visit_filters(scope)
scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
scope = scope.where(id: params[:visit_id]) if params[:visit_id].present?
scope = scope.where(started_at: time_range) if time_range

# Audience filter
scope = apply_audience_filter(scope)

# Date filtering
if params[:from].present?
from_time = Time.zone.parse(params[:from]).beginning_of_day
scope = scope.where("ahoy_visits.started_at >= ?", from_time)
scope = scope.where("ahoy_visits.started_at >= ?", Time.zone.parse(params[:from]).beginning_of_day)
end

if params[:to].present?
to_time = Time.zone.parse(params[:to]).end_of_day
scope = scope.where("ahoy_visits.started_at <= ?", to_time)
scope = scope.where("ahoy_visits.started_at <= ?", Time.zone.parse(params[:to]).end_of_day)
end

@visits = scope.paginate(page: page, per_page: per_page)
scope
end

def charts
authorize! :ahoy_activity, to: :charts?
@creation_velocity_data = creation_velocity_data
prepare_chart_data
def apply_visit_sort(scope, column, direction)
dir = direction.to_sym
case column
when "started_at"
scope.reorder(started_at: dir)
when "user"
user_sort = { "asc" => "users.first_name ASC, users.last_name ASC",
"desc" => "users.first_name DESC, users.last_name DESC" }
scope.left_joins(:user).reorder(Arel.sql(user_sort[direction]))
when "events_count"
scope.reorder(Arel.sql(dir == :asc ? "events_count ASC" : "events_count DESC"))
when "duration"
scope.reorder(Arel.sql(dir == :asc ? "duration_minutes ASC" : "duration_minutes DESC"))
else
scope.reorder(started_at: :desc)
end
end

private

def prepare_chart_data
events = scoped_events

Expand Down
32 changes: 32 additions & 0 deletions app/frontend/javascript/controllers/checkbox_select_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="checkbox-select"
// Updates a label from checked checkboxes and optionally submits the parent form.
export default class extends Controller {
static targets = ["label"];
static values = {
labels: Object,
fieldName: String,
allLabel: { type: String, default: "All" },
autoSubmit: { type: Boolean, default: true }
};

update() {
const name = this.fieldNameValue || "audience[]";
const checkboxes = this.element.querySelectorAll(
`input[name="${name}"]`,
);
const checked = [...checkboxes]
.filter((c) => c.checked)
.map((c) => this.labelsValue[c.value]);
const total = Object.keys(this.labelsValue).length;

this.labelTarget.textContent =
checked.length === total ? this.allLabelValue : checked.join(", ");

if (this.autoSubmitValue) {
const form = this.element.closest("form");
if (form) form.requestSubmit();
}
}
}
19 changes: 16 additions & 3 deletions app/frontend/javascript/controllers/collection_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ export default class extends Controller {
this.toggleClass(event.target);
}

if (type === "select-one" || type === "select-multiple" || type === "date") {
this.stylePlaceholder(event.target);
}

if (
type === "checkbox" ||
type === "radio" ||
type === "select-one" ||
type === "select-multiple"
type === "select-multiple" ||
type === "date"
) {
this.submitForm();
}
});
this.element.addEventListener("input", (event) => {
if (event.target.type === "text") {
if (event.target.type === "text" || event.target.type === "number") {
this.debouncedSubmit();
}
});
Expand Down Expand Up @@ -57,11 +62,13 @@ export default class extends Controller {
clearAndSubmit(event) {
event.preventDefault();

this.element.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
this.element.querySelectorAll('input[type="text"], input[type="search"], input[type="number"], input[type="date"]').forEach(input => {
input.value = '';
if (input.type === "date") this.stylePlaceholder(input);
});
this.element.querySelectorAll('select').forEach(select => {
select.selectedIndex = 0;
this.stylePlaceholder(select);
});
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
if (input.checked) {
Expand All @@ -73,6 +80,12 @@ export default class extends Controller {
this.submitForm();
}

stylePlaceholder(el) {
const isBlank = !el.value;
el.classList.toggle("text-gray-500", isBlank);
el.classList.toggle("text-gray-900", !isBlank);
}

blurOldResults() {
const frame = this.element.closest("turbo-frame");
const scope = frame || document;
Expand Down
63 changes: 63 additions & 0 deletions app/frontend/javascript/controllers/filter_chip_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="filter-chip"
// Dismisses filter chips by clearing the matching input in the collection
// form (for search params) or navigating to a modified URL (for header params).
export default class extends Controller {
remove(event) {
event.preventDefault();
const button = event.currentTarget;
const param = button.dataset.param;
const value = button.dataset.value;

const collectionForm = document.querySelector(
'[data-controller="collection"]',
);

// Search params: clear input in collection form and re-submit
if (collectionForm && param !== "audience" && param !== "time_period") {
const input = collectionForm.querySelector(`[name="${param}"]`);
if (input) {
if (input.tagName === "SELECT") {
input.selectedIndex = 0;
} else {
input.value = "";
}
collectionForm.requestSubmit();
return;
}
}

// Header-level params (audience, time_period): rebuild URL and navigate
const url = new URL(window.location);

// Merge search form values into the URL so they aren't lost on navigation
if (collectionForm) {
const formData = new FormData(collectionForm);
for (const [key, val] of formData.entries()) {
if (!url.searchParams.has(key) && val !== "") {
url.searchParams.set(key, val);
}
}
}

if (param === "audience" && value) {
const defaults = ["visitors", "users"];
const current = url.searchParams.has("audience[]")
? url.searchParams.getAll("audience[]")
: defaults;
const remaining = current.filter((v) => v !== value);
url.searchParams.delete("audience[]");
remaining.forEach((v) => url.searchParams.append("audience[]", v));
} else if (param === "time_period") {
const current = url.searchParams.get("time_period") || "past_month";
url.searchParams.set(
"time_period",
current === "all_time" ? "past_month" : "all_time",
);
} else {
url.searchParams.delete(param);
}
window.location = url.toString();
}
}
Loading
Loading