diff --git a/app/controllers/admin/ahoy_activities_controller.rb b/app/controllers/admin/ahoy_activities_controller.rb index bbde0a21f..23c9a5a59 100644 --- a/app/controllers/admin/ahoy_activities_controller.rb +++ b/app/controllers/admin/ahoy_activities_controller.rb @@ -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 diff --git a/app/frontend/javascript/controllers/checkbox_select_controller.js b/app/frontend/javascript/controllers/checkbox_select_controller.js new file mode 100644 index 000000000..f130a858a --- /dev/null +++ b/app/frontend/javascript/controllers/checkbox_select_controller.js @@ -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(); + } + } +} diff --git a/app/frontend/javascript/controllers/collection_controller.js b/app/frontend/javascript/controllers/collection_controller.js index b9f56ce46..ceea92bae 100644 --- a/app/frontend/javascript/controllers/collection_controller.js +++ b/app/frontend/javascript/controllers/collection_controller.js @@ -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(); } }); @@ -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) { @@ -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; diff --git a/app/frontend/javascript/controllers/filter_chip_controller.js b/app/frontend/javascript/controllers/filter_chip_controller.js new file mode 100644 index 000000000..ffdfbd0b5 --- /dev/null +++ b/app/frontend/javascript/controllers/filter_chip_controller.js @@ -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(); + } +} diff --git a/app/frontend/javascript/controllers/header_form_controller.js b/app/frontend/javascript/controllers/header_form_controller.js new file mode 100644 index 000000000..35c0432a2 --- /dev/null +++ b/app/frontend/javascript/controllers/header_form_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="header-form" +// Merges search form (collection controller) values into the header form +// before submitting, so search filters aren't lost when changing audience +// or time period. +export default class extends Controller { + submit() { + const collectionForm = document.querySelector( + '[data-controller="collection"]', + ); + + if (collectionForm) { + // Remove any previously injected hidden fields + this.element + .querySelectorAll("input[data-header-form-injected]") + .forEach((el) => el.remove()); + + const formData = new FormData(collectionForm); + for (const [key, val] of formData.entries()) { + // Skip params already handled by the header form (audience, time_period) + if (key === "audience[]" || key === "time_period") continue; + if (val === "") continue; + + const hidden = document.createElement("input"); + hidden.type = "hidden"; + hidden.name = key; + hidden.value = val; + hidden.setAttribute("data-header-form-injected", "true"); + this.element.appendChild(hidden); + } + } + + this.element.requestSubmit(); + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 1c152e3cb..552df245b 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -9,6 +9,9 @@ application.register("anchor-highlight", AnchorHighlightController) import AssetPickerController from "./asset_picker_controller" application.register("asset-picker", AssetPickerController) +import CheckboxSelectController from "./checkbox_select_controller" +application.register("checkbox-select", CheckboxSelectController) + import AutosaveController from "./autosave_controller" application.register("autosave", AutosaveController) @@ -39,6 +42,12 @@ application.register("dropdown", DropdownController) import FilePreviewController from "./file_preview_controller" application.register("file-preview", FilePreviewController) +import FilterChipController from "./filter_chip_controller" +application.register("filter-chip", FilterChipController) + +import HeaderFormController from "./header_form_controller" +application.register("header-form", HeaderFormController) + import InactiveToggleController from "./inactive_toggle_controller" application.register("inactive-toggle", InactiveToggleController) diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css index 5032fec7e..7097ba619 100644 --- a/app/frontend/stylesheets/application.tailwind.css +++ b/app/frontend/stylesheets/application.tailwind.css @@ -106,6 +106,11 @@ select.select-placeholder { font-size: 0.875rem !important; } +/* Grey out date picker calendar icon */ +input[type="date"]::-webkit-calendar-picker-indicator { + opacity: 0.4; +} + .is-active { @apply text-primary border-b-2 border-primary font-bold bg-gray-100; } diff --git a/app/views/admin/ahoy_activities/_event_count.html.erb b/app/views/admin/ahoy_activities/_event_count.html.erb new file mode 100644 index 000000000..4249e72a7 --- /dev/null +++ b/app/views/admin/ahoy_activities/_event_count.html.erb @@ -0,0 +1,7 @@ +
+
+
+

Events (<%= @count_display %>)

+
+
+
diff --git a/app/views/admin/ahoy_activities/_event_results.html.erb b/app/views/admin/ahoy_activities/_event_results.html.erb new file mode 100644 index 000000000..3d19d36e6 --- /dev/null +++ b/app/views/admin/ahoy_activities/_event_results.html.erb @@ -0,0 +1,151 @@ +<%= turbo_stream.replace("event_count", partial: "event_count") %> +<%= turbo_stream.replace("active_filters_subheader", partial: "admin/shared/active_filters_subheader") %> +<% + sort_base = params.permit(:event_name, :resource_name, :visit_id, :props, :user_id, :time_period, :from, :to, :per_page, audience: []).to_h.symbolize_keys + sort_icon = ->(column) { + if @sort == column + @sort_direction == "asc" ? "fa-arrow-up" : "fa-arrow-down" + else + "fa-sort" + end + } + sort_link = ->(column, label) { + link_to admin_activities_events_path(sort_base.merge(sort: column, direction: (@sort == column && @sort_direction == "desc") ? "asc" : "desc", page: nil)), + data: { turbo_frame: "event_results" }, + class: "inline-flex items-center gap-1 text-gray-700 hover:text-gray-900" do + concat label + concat content_tag(:i, "", class: "fa-solid #{sort_icon.call(column)} text-xs opacity-70") + end + } +%> +
+ + + + + + + + + + + + + + <% if @events.any? %> + <% @events.each do |event| %> + + + + + + + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
<%= sort_link.call("time", "Time") %><%= sort_link.call("name", "Event name") %>Resource Title<%= sort_link.call("user", "User") %>Visit IDProperties
+ <%= event.time.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %> + + <%= event.name %> + + <%= event.properties["resource_title"].presence || "—" %> + + <% if event.user %> + <%= link_to event.user.full_name, + user_path(event.user), + class: "text-indigo-600 hover:underline" %> + <% else %> + Guest + <% end %> + + <%= link_to event.visit_id, + admin_activities_visits_path(visit_id: event.visit_id, time_period: "all_time", audience: %w[visitors users staff]), + class: "text-indigo-600 hover:underline", + data: { turbo: false } %> + + <% if event.properties.present? && event.properties.any? %> + <%= link_to admin_activities_event_path(event), + class: "cursor-default text-indigo-600 text-xs font-medium bg-indigo-50 px-2 py-0.5 rounded hover:bg-indigo-100" do %> + <%= event.properties.size %> <%= "prop".pluralize(event.properties.size) %> + <% end %> + <% + props = event.properties.dup + display_props = [] + res_type = props["resource_type"] + res_id = props["resource_id"] + props.keys.select { |k| k.end_with?("_type") }.each do |type_key| + prefix = type_key.chomp("_type") + id_key = "#{prefix}_id" + if props.key?(id_key) + display_props << ["#{prefix} & ID", "#{props[type_key]} ##{props[id_key]}"] + props.delete(type_key) + props.delete(id_key) + end + end + rt_key = props.keys.find { |k| k.downcase == "resource_title" } + if rt_key + display_props.unshift(["resource_title", props.delete(rt_key)]) + end + props.each do |key, value| + if value.is_a?(Hash) + value.each do |sub_key, sub_val| + label = "#{key}.#{sub_key}" + if sub_val.is_a?(Array) && sub_val.size == 2 + display_props << [label, "#{sub_val[0]} → #{sub_val[1]}"] + else + display_props << [label, sub_val] + end + end + elsif value.is_a?(Array) + display_props << [key, value.join(", ")] + else + display_props << [key, value] + end + end + %> + + <% else %> + none + <% end %> +
+

No events found

+
+
+ +<% if @events.any? %> +
+ <%= tailwind_paginate @events %> +
+<% end %> diff --git a/app/views/admin/ahoy_activities/_events_skeleton.html.erb b/app/views/admin/ahoy_activities/_events_skeleton.html.erb new file mode 100644 index 000000000..8aa97aae8 --- /dev/null +++ b/app/views/admin/ahoy_activities/_events_skeleton.html.erb @@ -0,0 +1,26 @@ +
+ + + + + + + + + + + + + <% 5.times do %> + + + + + + + + + <% end %> + +
+
diff --git a/app/views/admin/ahoy_activities/_visit_count.html.erb b/app/views/admin/ahoy_activities/_visit_count.html.erb new file mode 100644 index 000000000..3c658ff6a --- /dev/null +++ b/app/views/admin/ahoy_activities/_visit_count.html.erb @@ -0,0 +1,7 @@ +
+
+
+

Visits (<%= @count_display %>)

+
+
+
diff --git a/app/views/admin/ahoy_activities/_visit_results.html.erb b/app/views/admin/ahoy_activities/_visit_results.html.erb new file mode 100644 index 000000000..e1e8163cd --- /dev/null +++ b/app/views/admin/ahoy_activities/_visit_results.html.erb @@ -0,0 +1,99 @@ +<%= turbo_stream.replace("visit_count", partial: "visit_count") %> +<%= turbo_stream.replace("active_filters_subheader", partial: "admin/shared/active_filters_subheader") %> +<% + sort_base = params.permit(:user_id, :visit_id, :time_period, :from, :to, :per_page, audience: []).to_h.symbolize_keys + sort_icon = ->(column) { + if @sort == column + @sort_direction == "asc" ? "fa-arrow-up" : "fa-arrow-down" + else + "fa-sort" + end + } + sort_link = ->(column, label) { + link_to admin_activities_visits_path(sort_base.merge(sort: column, direction: (@sort == column && @sort_direction == "desc") ? "asc" : "desc", page: nil)), + data: { turbo_frame: "visit_results" }, + class: "inline-flex items-center gap-1 text-gray-700 hover:text-gray-900" do + concat label + concat content_tag(:i, "", class: "fa-solid #{sort_icon.call(column)} text-xs opacity-70") + end + } +%> +
+ + + + + + + + + + + + + + + <% if @visits.any? %> + <% @visits.each do |visit| %> + + + + + + + + + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
Visit ID<%= sort_link.call("started_at", "Started") %><%= sort_link.call("user", "User") %>IP<%= sort_link.call("events_count", "Events") %><%= sort_link.call("duration", "Duration") %>User Agent
+ <%= visit.id %> + + <%= visit.started_at.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %> + + <% if visit.user %> + <%= link_to visit.user.full_name, + user_path(visit.user), + class: "text-indigo-600 hover:underline" %> + <% else %> + Guest + <% end %> + + <%= visit.ip %> + + <%= link_to visit.attributes["events_count"], + admin_activities_events_path(visit_id: visit.id, time_period: "all_time", audience: %w[visitors users staff]), + class: "text-indigo-600 hover:underline", + data: { turbo: false } %> + + <% dm = visit.attributes["duration_minutes"] %> + <% if dm.nil? %> + + <% elsif dm < 1 %> + < 1 min + <% else %> + <%= dm %> min + <% end %> + + <%= visit.user_agent %> +
+

No visits found

+
+
+ +<% if @visits.any? %> +
+ <%= tailwind_paginate @visits %> +
+<% end %> diff --git a/app/views/admin/ahoy_activities/_visits_skeleton.html.erb b/app/views/admin/ahoy_activities/_visits_skeleton.html.erb new file mode 100644 index 000000000..d7ee3207c --- /dev/null +++ b/app/views/admin/ahoy_activities/_visits_skeleton.html.erb @@ -0,0 +1,26 @@ +
+ + + + + + + + + + + + + <% 5.times do %> + + + + + + + + + <% end %> + +
+
diff --git a/app/views/admin/ahoy_activities/charts.html.erb b/app/views/admin/ahoy_activities/charts.html.erb index 93e13251b..66d67ace4 100644 --- a/app/views/admin/ahoy_activities/charts.html.erb +++ b/app/views/admin/ahoy_activities/charts.html.erb @@ -3,24 +3,19 @@

Activities

<%= form_with url: admin_activities_charts_path, method: :get, local: true, - class: "flex items-center gap-4" do %> + class: "flex items-center gap-4", + data: { controller: "header-form" } do %> <%= render "admin/shared/audience_dropdown" %>
<%= select_tag :time_period, options_for_select( - [ - ['All time', 'all_time'], - ['Past day', 'past_day'], - ['Past week', 'past_week'], - ['Past month', 'past_month'], - ['Past year', 'past_year'] - ], - params[:time_period] || 'past_month' + [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], + params[:time_period] || "past_month" ), class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700", - onchange: "this.form.submit()" %> + data: { action: "header-form#submit" } %>
<% end %>
diff --git a/app/views/admin/ahoy_activities/index.html.erb b/app/views/admin/ahoy_activities/index.html.erb index 40f817eb7..004e2c8ca 100644 --- a/app/views/admin/ahoy_activities/index.html.erb +++ b/app/views/admin/ahoy_activities/index.html.erb @@ -1,204 +1,141 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %>
- -
-

Activities

+ +
+

Activities

+ <%= form_with url: admin_activities_events_path, method: :get, local: true, + class: "flex items-center gap-4", + data: { controller: "header-form" } do %> + <%= render "admin/shared/audience_dropdown" %> + +
+ + <%= select_tag :time_period, + options_for_select( + [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], + params[:time_period] || "past_month" + ), + class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700", + data: { action: "header-form#submit" } %> +
+ <% end %> +
+ + <%= render "admin/shared/activities_tabs" %> + + + <%= form_with url: admin_activities_events_path, + method: :get, + data: { controller: "collection", + turbo_frame: "event_results", + "form-type": "other", + lpignore: "true" }, + autocomplete: "off", + class: "bg-white border border-gray-200 rounded-xl shadow-sm p-6 mb-6" do %> + + <% # Carry audience + time_period through the turbo frame form %> + <% Array(params[:audience]).reject(&:blank?).each do |aud| %> + <%= hidden_field_tag "audience[]", aud %> + <% end %> + <%= hidden_field_tag :time_period, params[:time_period] || "past_month" %> + +
+ + +
+ + <%= date_field_tag :from, + params[:from], + placeholder: "Start date", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:from].blank? ? 'text-gray-400' : 'text-gray-900'}" %> +
+ + +
+ + <%= date_field_tag :to, + params[:to], + placeholder: "End date", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:to].blank? ? 'text-gray-400' : 'text-gray-900'}" %> +
-
- <%= @events.count %> events -
+ +
+ + <%= text_field_tag :event_name, + params[:event_name], + placeholder: "view.workshop", + autocomplete: "off", + data: { lpignore: "true" }, + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
- <%= render "admin/shared/activities_tabs" %> - - <%= render "admin/shared/active_filters_subheader" %> - - -
- <%= form_with url: request.path, method: :get, local: true do %> - -
- - -
- - <%= text_field_tag :name, - params[:name], - placeholder: "e.g. Viewed Workshop", - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %> -
- - -
- - <%= select_tag :user_id, - options_from_collection_for_select( - User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name), - :id, - :full_name, - params[:user_id] - ), - include_blank: "All Users", - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %> -
- - -
- <%= render "admin/shared/audience_dropdown", auto_submit: false, stacked: true %> -
- - -
- - <%= select_tag :time_period, - options_for_select( - [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], - params[:time_period] || "past_month" - ), - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %> -
- - -
- - <%= date_field_tag :from, - params[:from], - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %> -
- - -
- - <%= date_field_tag :to, - params[:to], - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %> -
- -
- - -
- Quick: - <% safe_params = params.slice(:name, :user_id, :from, :to).to_unsafe_h %> - <%= link_to "24h", - url_for(safe_params.merge(from: 1.day.ago.to_date)), - class: "text-indigo-600 hover:underline" %> - - <%= link_to "7d", - url_for(safe_params.merge(from: 7.days.ago.to_date)), - class: "text-indigo-600 hover:underline" %> - - <%= link_to "30d", - url_for(safe_params.merge(from: 30.days.ago.to_date)), - class: "text-indigo-600 hover:underline" %> -
- -
- <%= submit_tag "Filter", - class: "bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition" %> -
- - <% end %> + +
+ + <%= text_field_tag :resource_name, + params[:resource_name], + placeholder: "My Workshop", + autocomplete: "off", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
- -
- - - - - - - - - - - - - - <% @events.each do |event| %> - - - - - - - - - - - - - - <% end %> - -
TimeEventResourceUserVisit IDProperties
- <%= event.time.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %> - - <%= event.name %> - - <%= event.properties["resource_title"].presence || "—" %> - - <% if event.user %> - <%= link_to event.user.full_name, - user_path(event.user), - class: "text-indigo-600 hover:underline" %> - <% else %> - Guest - <% end %> - - <%= event.visit_id %> - - <% if event.properties.present? && event.properties.any? %> - <%= link_to admin_activities_event_path(event), - class: "cursor-default text-indigo-600 text-xs font-medium bg-indigo-50 px-2 py-0.5 rounded hover:bg-indigo-100" do %> - <%= event.properties.size %> <%= "prop".pluralize(event.properties.size) %> - <% end %> - <% - props = event.properties.dup - display_props = [] - # Combine polymorphic _type/_id pairs - props.keys.select { |k| k.end_with?("_type") }.each do |type_key| - prefix = type_key.chomp("_type") - id_key = "#{prefix}_id" - if props.key?(id_key) - display_props << [prefix, "#{props[type_key]}.find(#{props[id_key]})"] - props.delete(type_key) - props.delete(id_key) - end - end - # Show resource_title first, then remaining props - if props.key?("resource_title") - display_props.unshift(["resource_title", props.delete("resource_title")]) - end - props.each do |key, value| - display_props << [key, value.is_a?(Hash) ? value.to_json : value] - end - %> - - <% else %> - none - <% end %> -
+ +
+ + <%= text_field_tag :props, + params[:props], + placeholder: "props...", + autocomplete: "off", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
- -
- <%= tailwind_paginate @events %> + +
+ + <%= select_tag :user_id, + options_from_collection_for_select( + User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name), + :id, + :full_name, + params[:user_id] + ), + include_blank: "Select user", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:user_id].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
+ + +
+ + <%= number_field_tag :visit_id, + params[:visit_id], + placeholder: "42", + autocomplete: "off", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %> +
+ + +
+ + <%= link_to "Clear filters", + admin_activities_events_path, + class: "btn btn-utility w-full text-center px-3 py-2", + data: { action: "collection#clearAndSubmit" } %> +
+ +
+ + <% end %> + + <%= render "admin/shared/active_filters_subheader" %> + + + <%= render "event_count" %> + + + <% result_src = admin_activities_events_path + "?" + request.query_string %> + <%= turbo_frame_tag "event_results", src: result_src do %> + <%= render "events_skeleton" %> + <% end %>
diff --git a/app/views/admin/ahoy_activities/index_lazy.html.erb b/app/views/admin/ahoy_activities/index_lazy.html.erb new file mode 100644 index 000000000..b9b607ba3 --- /dev/null +++ b/app/views/admin/ahoy_activities/index_lazy.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "event_results" do %> + <%= render "event_results" %> +<% end %> diff --git a/app/views/admin/ahoy_activities/visits.html.erb b/app/views/admin/ahoy_activities/visits.html.erb index 5c3af90ab..a5e33e133 100644 --- a/app/views/admin/ahoy_activities/visits.html.erb +++ b/app/views/admin/ahoy_activities/visits.html.erb @@ -1,153 +1,110 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %>
-
-

Activities

+ +
+

Activities

+ <%= form_with url: admin_activities_visits_path, method: :get, local: true, + class: "flex items-center gap-4", + data: { controller: "header-form" } do %> + <%= render "admin/shared/audience_dropdown" %> + +
+ + <%= select_tag :time_period, + options_for_select( + [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], + params[:time_period] || "past_month" + ), + class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700", + data: { action: "header-form#submit" } %> +
+ <% end %> +
+ + <%= render "admin/shared/activities_tabs" %> + + + <%= form_with url: admin_activities_visits_path, + method: :get, + data: { controller: "collection", + turbo_frame: "visit_results", + "form-type": "other", + lpignore: "true" }, + autocomplete: "off", + class: "bg-white border border-gray-200 rounded-xl shadow-sm p-6 mb-6" do %> + + <% # Carry audience + time_period through the turbo frame form %> + <% Array(params[:audience]).reject(&:blank?).each do |aud| %> + <%= hidden_field_tag "audience[]", aud %> + <% end %> + <%= hidden_field_tag :time_period, params[:time_period] || "past_month" %> + +
+ + +
+ + <%= date_field_tag :from, + params[:from], + placeholder: "Start date", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:from].blank? ? 'text-gray-400' : 'text-gray-900'}" %> +
+ + +
+ + <%= date_field_tag :to, + params[:to], + placeholder: "End date", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:to].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
- <%= render "admin/shared/activities_tabs" %> - - <%= render "admin/shared/active_filters_subheader" %> - - -
- <%= form_with url: request.path, method: :get, local: true do %> - -
- - -
- - <%= select_tag :user_id, - options_from_collection_for_select( - User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name), - :id, - :full_name, - params[:user_id] - ), - include_blank: "All Users", - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %> -
- - -
- <%= render "admin/shared/audience_dropdown", auto_submit: false, stacked: true %> -
- - -
- - <%= select_tag :time_period, - options_for_select( - [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], - params[:time_period] || "past_month" - ), - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %> -
- - -
- - <%= date_field_tag :from, params[:from], - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %> -
- - -
- - <%= date_field_tag :to, params[:to], - class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %> -
- -
- - -
- Quick: - <% safe_params = params.slice(:user_id, :from, :to).to_unsafe_h %> - <%= link_to "24h", - url_for(safe_params.merge(from: 1.day.ago.to_date)), - class: "text-indigo-600 hover:underline" %> - - <%= link_to "7d", - url_for(safe_params.merge(from: 7.days.ago.to_date)), - class: "text-indigo-600 hover:underline" %> - - <%= link_to "30d", - url_for(safe_params.merge(from: 30.days.ago.to_date)), - class: "text-indigo-600 hover:underline" %> -
- -
- <%= submit_tag "Filter", - class: "bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition" %> -
- - <% end %> + +
+ + <%= select_tag :user_id, + options_from_collection_for_select( + User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name), + :id, + :full_name, + params[:user_id] + ), + include_blank: "Select user", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:user_id].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
- -
- - - - - - - - - - - - - - <% @visits.each do |visit| %> - - - - - - - - - - - - - - <% end %> - -
IDStartedUserIPEventsUser Agent
- <%= visit.id %> - - <%= visit.started_at.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %> - - <% if visit.user %> - <%= link_to visit.user.full_name, - user_path(visit.user), - class: "text-indigo-600 hover:underline" %> - <% else %> - Guest - <% end %> - - <%= visit.ip %> - - <%= link_to visit.attributes["events_count"], - admin_activities_events_path(visit_id: visit.id) %> - - <%= visit.user_agent %> -
+ +
+ + <%= number_field_tag :visit_id, + params[:visit_id], + placeholder: "42", + autocomplete: "off", + class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
-
- <%= tailwind_paginate @visits %> + +
+ + <%= link_to "Clear filters", + admin_activities_visits_path, + class: "btn btn-utility w-full text-center px-3 py-2", + data: { action: "collection#clearAndSubmit" } %>
+ +
+ + <% end %> + + <%= render "admin/shared/active_filters_subheader" %> + + + <%= render "visit_count" %> + + + <% result_src = admin_activities_visits_path + "?" + request.query_string %> + <%= turbo_frame_tag "visit_results", src: result_src do %> + <%= render "visits_skeleton" %> + <% end %>
diff --git a/app/views/admin/ahoy_activities/visits_lazy.html.erb b/app/views/admin/ahoy_activities/visits_lazy.html.erb new file mode 100644 index 000000000..ef168388c --- /dev/null +++ b/app/views/admin/ahoy_activities/visits_lazy.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag "visit_results" do %> + <%= render "visit_results" %> +<% end %> diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb index 7b0ab7921..0dfc6b33a 100644 --- a/app/views/admin/analytics/index.html.erb +++ b/app/views/admin/analytics/index.html.erb @@ -3,22 +3,21 @@

Activities

- <%= form_with url: admin_activities_counts_path, method: :get, local: true, class: "flex items-center gap-4" do |f| %> + <%= form_with url: admin_activities_counts_path, method: :get, local: true, + class: "flex items-center gap-4", + data: { controller: "header-form" } do |f| %> <%= render "admin/shared/audience_dropdown" %>
<%= f.label :time_period, "Time period:", class: "text-sm font-medium text-gray-700" %> <%= f.select :time_period, - options_for_select([ - ['All time', 'all_time'], - ['Past day', 'past_day'], - ['Past week', 'past_week'], - ['Past month', 'past_month'], - ['Past year', 'past_year'] - ], params[:time_period] || 'past_month'), + options_for_select( + [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]], + params[:time_period] || "past_month" + ), {}, class: "px-3 py-2 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm text-gray-700", - onchange: "this.form.submit()" %> + data: { action: "header-form#submit" } %>
<% end %>
diff --git a/app/views/admin/shared/_active_filters_subheader.html.erb b/app/views/admin/shared/_active_filters_subheader.html.erb index 215792cf5..8d148518f 100644 --- a/app/views/admin/shared/_active_filters_subheader.html.erb +++ b/app/views/admin/shared/_active_filters_subheader.html.erb @@ -1,6 +1,8 @@ +
<% time_period = params[:time_period].presence || "past_month" time_label = case time_period + when "all_time" then "All time" when "past_day" then "Past day" when "past_week" then "Past week" when "past_month" then "Past month" @@ -15,20 +17,82 @@ default_audiences = %w[visitors users] audience_values = Array(params[:audience]).reject(&:blank?).presence || default_audiences - audience_labels = audience_values.filter_map { |v| audience_labels_map[v] } + audience_labels = audience_values.filter_map { |v| [v, audience_labels_map[v]] }.select(&:last) + + has_search_filters = params[:event_name].present? || params[:resource_name].present? || + params[:visit_id].present? || params[:props].present? || + params[:user_id].present? || params[:from].present? || + params[:to].present? %> -<% if time_label || audience_labels.any? %> -
- Filtering: +<% if time_label || audience_labels.any? || has_search_filters %> +
+ Filters applied: + <% audience_labels.each do |value, label| %> + + <%= label %> + + + <% end %> <% if time_label %> - + <%= time_label %> + <% end %> - <% audience_labels.each do |label| %> - - <%= label %> + <% if params[:from].present? %> + + From: <%= params[:from] %> + + + <% end %> + <% if params[:to].present? %> + + To: <%= params[:to] %> + + + <% end %> + <% if params[:event_name].present? %> + + Event: <%= params[:event_name] %> + + + <% end %> + <% if params[:resource_name].present? %> + + Resource title: <%= params[:resource_name] %> + + + <% end %> + <% if params[:visit_id].present? %> + + Visit: <%= params[:visit_id] %> + + + <% end %> + <% if params[:props].present? %> + + Props: <%= params[:props] %> + <% end %> + <% if params[:user_id].present? %> + <% user = User.find_by(id: params[:user_id]) %> + <% if user %> + + User: <%= user.full_name %> + + + <% end %> + <% end %>
<% end %> +
diff --git a/app/views/admin/shared/_audience_dropdown.html.erb b/app/views/admin/shared/_audience_dropdown.html.erb index 43ce56238..c8edd1ada 100644 --- a/app/views/admin/shared/_audience_dropdown.html.erb +++ b/app/views/admin/shared/_audience_dropdown.html.erb @@ -10,16 +10,20 @@ else selected.filter_map { |v| labels_map[v] }.join(", ") end - dropdown_id = "audience-dropdown-#{SecureRandom.hex(4)}" + dropdown_id = "checkbox-select-#{SecureRandom.hex(4)}" %>
"> - -
+ +