Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ee635e7
Convert events and visits to dynamic Turbo Frame search
maebeale Mar 6, 2026
cf7e021
Move audience and time period to page header
maebeale Mar 6, 2026
7ea8e8a
Add resource name, visit ID, and props filters to activity events
maebeale Mar 6, 2026
095a9a6
Use MySQL ->> operator for resource_title JSON search
maebeale Mar 6, 2026
c5cdc0f
Update filter badges dynamically via turbo_stream.replace
maebeale Mar 6, 2026
2a82c9f
Add from/to date chips matching time period color
maebeale Mar 6, 2026
7e0bcef
Auto-submit date fields on change
maebeale Mar 6, 2026
c5c285e
Handle date and number inputs in collection controller
maebeale Mar 6, 2026
44fdc6c
Fix trailing comma syntax errors in events and visits views
maebeale Mar 6, 2026
ab675c3
Reorder filter chips and search fields, add dismissible X buttons
maebeale Mar 6, 2026
f4b527d
Move filter chips below search boxes and above count row
maebeale Mar 6, 2026
df07559
Change 'Filtering' label to 'Filters applied'
maebeale Mar 6, 2026
12c8751
Add placeholder text to date fields and user dropdown
maebeale Mar 6, 2026
be839ac
Style select placeholder text as grey when blank option is selected
maebeale Mar 6, 2026
8bb7eda
Use Tailwind classes for select placeholder styling instead of custom…
maebeale Mar 6, 2026
e7604a3
Match placeholder grey across all search inputs (text-gray-500)
maebeale Mar 6, 2026
b3674f9
Grey out date picker calendar icon when input is empty
maebeale Mar 6, 2026
bf95994
Make date selector fields slightly wider (flex-[1.3])
maebeale Mar 6, 2026
14ea18a
Make date picker calendar icon always grey
maebeale Mar 6, 2026
32f2e8f
Adjust search field widths, rename Resource Name to Resource Title
maebeale Mar 6, 2026
786a4c1
Add autocomplete=off on individual search inputs
maebeale Mar 6, 2026
0e117fd
Add data-lpignore to Event Name field to prevent LastPass icon
maebeale Mar 6, 2026
47e102a
Add data-form-type=other and data-lpignore to search forms
maebeale Mar 6, 2026
620fbca
Improve activities search UI and add cross-linking between events/visits
maebeale Mar 6, 2026
86fe482
Fix Brakeman SQL injection warnings by removing string interpolation …
maebeale Mar 9, 2026
be5a95e
Remove orphaned filter code left by rebase in index action
maebeale Mar 11, 2026
88e10b6
Fix event filter tests to use Turbo Frame requests
maebeale Apr 5, 2026
c296a76
Fix visit filter tests to use Turbo Frame requests
maebeale Apr 5, 2026
8884077
Add IP and user agent search filters to visits index
maebeale Apr 5, 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
207 changes: 120 additions & 87 deletions app/controllers/admin/ahoy_activities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,133 +5,166 @@ 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?
prepare_chart_data
prepare_portal_usage_data
prepare_content_creation_data
creation_velocity_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)
scope = scope.where("ahoy_events.time <= ?", Time.zone.parse(params[:to]).end_of_day)
end

# Filter by visit
if params[:visit_id].present?
scope = scope.where(visit_id: params[:visit_id])
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(ip: params[:ip]) if params[:ip].present?
scope = scope.where("ahoy_visits.user_agent LIKE ?", "%#{Ahoy::Visit.sanitize_sql_like(params[:user_agent])}%") if params[:user_agent].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?
prepare_chart_data
prepare_portal_usage_data
prepare_content_creation_data
creation_velocity_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
Loading
Loading