Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5d5dbe3
Add resource name, visit ID, and props filters to activity events
maebeale Mar 6, 2026
f399208
Convert events and visits to dynamic Turbo Frame search
maebeale Mar 6, 2026
b6a7665
Move audience and time period to page header
maebeale Mar 6, 2026
5d18670
Use MySQL ->> operator for resource_title JSON search
maebeale Mar 6, 2026
00ff7cb
Merge parent PR #1348 and adopt ->> JSON operator
maebeale Mar 6, 2026
31bbe86
Update filter badges dynamically via turbo_stream.replace
maebeale Mar 6, 2026
6574e0d
Add from/to date chips matching time period color
maebeale Mar 6, 2026
a43ed81
Auto-submit date fields on change
maebeale Mar 6, 2026
b1204a6
Handle date and number inputs in collection controller
maebeale Mar 6, 2026
c0b6931
Fix trailing comma syntax errors in events and visits views
maebeale Mar 6, 2026
5d26c0e
Reorder filter chips and search fields, add dismissible X buttons
maebeale Mar 6, 2026
8ac081e
Move filter chips below search boxes and above count row
maebeale Mar 6, 2026
5303bf4
Change 'Filtering' label to 'Filters applied'
maebeale Mar 6, 2026
54ed660
Add placeholder text to date fields and user dropdown
maebeale Mar 6, 2026
51e1b75
Style select placeholder text as grey when blank option is selected
maebeale Mar 6, 2026
7214f9a
Use Tailwind classes for select placeholder styling instead of custom…
maebeale Mar 6, 2026
82de604
Match placeholder grey across all search inputs (text-gray-500)
maebeale Mar 6, 2026
1d918cd
Grey out date picker calendar icon when input is empty
maebeale Mar 6, 2026
1b0bf8b
Make date selector fields slightly wider (flex-[1.3])
maebeale Mar 6, 2026
14c53fc
Make date picker calendar icon always grey
maebeale Mar 6, 2026
9968ce4
Adjust search field widths, rename Resource Name to Resource Title
maebeale Mar 6, 2026
e228de4
Add autocomplete=off on individual search inputs
maebeale Mar 6, 2026
8048635
Add data-lpignore to Event Name field to prevent LastPass icon
maebeale Mar 6, 2026
cbcdad5
Add data-form-type=other and data-lpignore to search forms
maebeale Mar 6, 2026
f12a04f
Improve activities search UI and add cross-linking between events/visits
maebeale Mar 6, 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
187 changes: 115 additions & 72 deletions app/controllers/admin/ahoy_activities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,117 +5,160 @@ 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 user (if viewing specific user activity)
scope = scope.where(user: @users) if @users.present?
def charts
authorize! :ahoy_activity, to: :charts?
@creation_velocity_data = creation_velocity_data
prepare_chart_data
end

# Time filter
private

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])
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"
scope.left_joins(:user)
.reorder(Arel.sql("users.first_name #{direction}, users.last_name #{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"
scope.left_joins(:user)
.reorder(Arel.sql("users.first_name #{direction}, users.last_name #{direction}"))
when "events_count"
scope.reorder(Arel.sql("events_count #{direction}"))
when "duration"
scope.reorder(Arel.sql("duration_minutes #{direction}"))
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