From 9d35bd3ad3d3a267089209974f19ffef5a684f4d Mon Sep 17 00:00:00 2001 From: Zach Latta Date: Wed, 14 May 2025 19:00:05 -0400 Subject: [PATCH] Make users on calendar selectable --- app/controllers/admin/timeline_controller.rb | 112 +++++-- ...admin_timeline_user_selector_controller.js | 304 ++++++++++++++++++ app/views/admin/timeline/show.html.erb | 117 ++++++- config/importmap.rb | 1 + config/routes.rb | 2 + vendor/javascript/@rails--request.js.js | 4 + 6 files changed, 513 insertions(+), 27 deletions(-) create mode 100644 app/javascript/controllers/admin_timeline_user_selector_controller.js create mode 100644 vendor/javascript/@rails--request.js.js diff --git a/app/controllers/admin/timeline_controller.rb b/app/controllers/admin/timeline_controller.rb index c1389f3..0d51ae7 100644 --- a/app/controllers/admin/timeline_controller.rb +++ b/app/controllers/admin/timeline_controller.rb @@ -12,29 +12,31 @@ class Admin::TimelineController < Admin::BaseController @next_date = @date + 1.day @prev_date = @date - 1.day - # Step 1: Consolidate User Loading - # Note: current_user in an admin controller is the admin user. - # The original list of user_ids_to_fetch includes some hardcoded IDs. - # This logic is preserved but might need review for an admin-specific timeline. - user_ids_to_fetch = [ - current_user&.id, # Admin's own data (if they are also a tracked user) - 1, # Example: User.find(1) if it's relevant - 10, 1792, 69, 1476, 805, 2003, 2011 # Original hardcoded IDs - ].compact.uniq + # User selection logic + raw_user_ids = params[:user_ids].present? ? params[:user_ids].split(',').map(&:to_i).uniq : [] + + # Always include current_user (admin) + @selected_user_ids = [current_user.id] + raw_user_ids + @selected_user_ids.uniq! + + user_ids_to_fetch = @selected_user_ids + # Fetch all valid users in one go users_by_id = User.where(id: user_ids_to_fetch).index_by(&:id) + # Ensure we only use IDs of users that actually exist + valid_user_ids_to_fetch = users_by_id.keys - mappings_by_user_project = ProjectRepoMapping.where(user_id: users_by_id.keys) + mappings_by_user_project = ProjectRepoMapping.where(user_id: valid_user_ids_to_fetch) .group_by(&:user_id) .transform_values { |mappings| mappings.index_by(&:project_name) } - users_to_process = user_ids_to_fetch.map { |id| users_by_id[id] }.compact + users_to_process = valid_user_ids_to_fetch.map { |id| users_by_id[id] }.compact start_of_day_timestamp = @date.beginning_of_day.to_f end_of_day_timestamp = @date.end_of_day.to_f all_heartbeats = Heartbeat - .where(user_id: user_ids_to_fetch, deleted_at: nil) + .where(user_id: valid_user_ids_to_fetch, deleted_at: nil) .where('time >= ? AND time <= ?', start_of_day_timestamp, end_of_day_timestamp) .select(:id, :user_id, :time, :entity, :project, :editor, :language) .order(:user_id, :time) @@ -42,7 +44,7 @@ class Admin::TimelineController < Admin::BaseController heartbeats_by_user_id = all_heartbeats.group_by(&:user_id) - @users_with_timeline_data = [] + @users_with_timeline_data_unordered = [] users_to_process.each do |user| user_daily_heartbeats_relation = Heartbeat.where(user_id: user.id, deleted_at: nil) @@ -96,17 +98,85 @@ class Admin::TimelineController < Admin::BaseController end end - if calculated_spans_with_details.any? || total_coded_time_seconds > 0 - @users_with_timeline_data << { - user: user, - spans: calculated_spans_with_details, - total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day - } - end + # Add user data, even if no spans/time, if they were explicitly selected + @users_with_timeline_data_unordered << { + user: user, + spans: calculated_spans_with_details, + total_coded_time: total_coded_time_seconds # Actual coded time for the user for the day + } end + + # Order @users_with_timeline_data according to @selected_user_ids + # This ensures that if a user was explicitly selected they appear in the timeline + # even if they have no heartbeats for the day. + data_map_for_ordering = @users_with_timeline_data_unordered.index_by { |data| data[:user].id } + @users_with_timeline_data = @selected_user_ids.map do |id| + data_map_for_ordering[id] || (users_by_id[id] ? { user: users_by_id[id], spans: [], total_coded_time: 0 } : nil) + end.compact - @primary_user = users_to_process.first || current_user # current_user is the admin + # For Stimulus: provide initial selected users with details + @initial_selected_user_objects = User.where(id: @selected_user_ids) + .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + .map { |u| { id: u.id, display_name: u.display_name, avatar_url: u.avatar_url } } + .sort_by { |u_obj| @selected_user_ids.index(u_obj[:id]) || Float::INFINITY } # Preserve order + + @primary_user = @users_with_timeline_data.first&.[](:user) || current_user render :show # Renders app/views/admin/timeline/show.html.erb end + + def search_users + query_term = params[:query].to_s.downcase + users = User.where("LOWER(username) LIKE :query OR LOWER(slack_username) LIKE :query OR EXISTS (SELECT 1 FROM email_addresses WHERE email_addresses.user_id = users.id AND LOWER(email_addresses.email) LIKE :query)", query: "%#{query_term}%") + .order(Arel.sql("CASE WHEN LOWER(username) = #{ActiveRecord::Base.connection.quote(query_term)} THEN 0 ELSE 1 END, username ASC")) # Prioritize exact match + .limit(20) + .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + + results = users.map do |user| + { + id: user.id, + display_name: user.display_name, + avatar_url: user.avatar_url + } + end + render json: results + end + + def leaderboard_users + period = params[:period] + limit = 25 + + leaderboard_period_type = (period == 'last_7_days') ? :last_7_days : :daily + start_date = Date.current # Leaderboard job for :last_7_days uses Date.current as start_date + + leaderboard = Leaderboard.where.not(finished_generating_at: nil) + .find_by(start_date: start_date, period_type: leaderboard_period_type, deleted_at: nil) + + user_ids_from_leaderboard = leaderboard ? leaderboard.entries.order(total_seconds: :desc).limit(limit).pluck(:user_id) : [] + + all_ids_to_fetch = user_ids_from_leaderboard.dup + all_ids_to_fetch.unshift(current_user.id).uniq! + + users_data = User.where(id: all_ids_to_fetch) + .select(:id, :username, :slack_username, :github_username, :slack_avatar_url, :github_avatar_url) + .index_by(&:id) + + final_user_objects = [] + # Add admin first + if admin_data = users_data[current_user.id] + final_user_objects << { id: admin_data.id, display_name: admin_data.display_name, avatar_url: admin_data.avatar_url } + end + + # Add leaderboard users, ensuring no duplicates and respecting limit + user_ids_from_leaderboard.each do |uid| + break if final_user_objects.size >= limit + next if uid == current_user.id + + if user_data = users_data[uid] + final_user_objects << { id: user_data.id, display_name: user_data.display_name, avatar_url: user_data.avatar_url } + end + end + + render json: { users: final_user_objects } + end end \ No newline at end of file diff --git a/app/javascript/controllers/admin_timeline_user_selector_controller.js b/app/javascript/controllers/admin_timeline_user_selector_controller.js new file mode 100644 index 0000000..12fd020 --- /dev/null +++ b/app/javascript/controllers/admin_timeline_user_selector_controller.js @@ -0,0 +1,304 @@ +import { Controller } from "@hotwired/stimulus" +import { FetchRequest } from '@rails/request.js' + +// Helper for debouncing +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +export default class extends Controller { + static targets = ["searchInput", "searchResults", "selectedUsersContainer", "userIdsInput", "dateInput"] + + static values = { + currentUserJson: Object, + initialSelectedUsersJson: Array, + searchUrl: String, + leaderboardUsersUrl: String + } + + connect() { + this.selectedUsers = new Map(); // Stores selected users {id: userObject} + this._debouncedSearchImpl = debounce(this.search.bind(this), 300); + + try { + console.log("Initializing timeline user selector"); + + // Parse the JSON if it's a string + let adminUser = this.currentUserJsonValue; + if (typeof adminUser === 'string') { + adminUser = JSON.parse(adminUser); + console.log("Parsed admin user:", adminUser); + } + + if (adminUser && adminUser.id) { + // Ensure ID is a number + adminUser.id = parseInt(adminUser.id, 10); + this.addUserToSelection(adminUser, true); // true = isPillForAdmin + } + + // Parse the JSON if it's a string + let initialUsers = this.initialSelectedUsersJsonValue; + if (typeof initialUsers === 'string') { + initialUsers = JSON.parse(initialUsers); + console.log("Parsed initial users:", initialUsers); + } + + // Make sure it's an array + if (Array.isArray(initialUsers)) { + initialUsers.forEach(user => { + // Ensure ID is a number + if (user && user.id) { + user.id = parseInt(user.id, 10); + if (!adminUser || user.id !== adminUser.id) { + this.addUserToSelection(user, false); + } + } + }); + } + + this.updateHiddenInput(); + this.updateDateLinks(); + } catch (error) { + console.error("Error initializing user selector:", error); + } + } + + async search() { + const query = this.searchInputTarget.value; + if (query.length < 2) { + this.searchResultsTarget.innerHTML = ""; + this.searchResultsTarget.classList.remove('active'); + return; + } + + const request = new FetchRequest('get', `${this.searchUrlValue}?query=${encodeURIComponent(query)}`, { responseKind: 'json' }) + const response = await request.perform() + + if (response.ok) { + const users = await response.json; + console.log(`Found ${users.length} users in search results`); + this.renderSearchResults(users); + } else { + this.searchResultsTarget.innerHTML = "
  • Error searching users
  • "; + this.searchResultsTarget.classList.add('active'); + } + } + + renderSearchResults(users) { + if (users.length === 0) { + this.searchResultsTarget.innerHTML = "
  • No users found
  • "; + } else { + this.searchResultsTarget.innerHTML = users.map(user => ` +
  • + ${this.escapeHTML(user.display_name)}${this.escapeHTML(user.display_name)} +
  • + `).join(""); + } + // Make sure the result list is shown + this.searchResultsTarget.style.display = 'block'; + this.searchResultsTarget.classList.add('active'); + console.log("Search results rendered and active class added"); + } + + selectUser(event) { + event.preventDefault(); + // Get values directly from data attributes + const element = event.currentTarget; + const userId = parseInt(element.getAttribute(`data-${this.identifier}-user-id-value`), 10); + const displayName = element.getAttribute(`data-${this.identifier}-user-display-name-value`); + const avatarUrl = element.getAttribute(`data-${this.identifier}-user-avatar-url-value`); + + console.log("Selected user data:", { userId, displayName, avatarUrl }); + + if (isNaN(userId)) { + console.error("Invalid user ID"); + this.clearSearch(); + return; + } + + if (this.selectedUsers.has(userId)) { + this.clearSearch(); + return; + } + + const user = { + id: userId, + display_name: displayName, + avatar_url: avatarUrl + }; + + this.addUserToSelection(user, false); + + this.clearSearch(); + this.updateHiddenInput(); + this.updateDateLinks(); + } + + addUserToSelection(user, isPillForAdmin = false) { + // Make sure we have a valid user ID + if (!user || !user.id || isNaN(parseInt(user.id, 10))) { + console.error("Invalid user object or ID:", user); + return; + } + + // Convert ID to number just to be safe + const userId = parseInt(user.id, 10); + user.id = userId; + + if (this.selectedUsers.has(userId)) return; + + console.log("Adding user to selection:", user); + this.selectedUsers.set(userId, user); + + const pill = document.createElement("span"); + // Using Pico.css friendly classes if available, or simple badges + pill.classList.add("badge", "secondary", "me-1", "mb-1", "d-inline-flex", "align-items-center", "user-pill"); + pill.style.backgroundColor = isPillForAdmin ? 'var(--pico-primary-focus)' : 'var(--pico-muted-border-color)'; + pill.style.color = isPillForAdmin ? 'var(--pico-primary-inverse)' : 'var(--pico-color)'; + pill.style.padding = '0.3em 0.6em'; + pill.style.borderRadius = 'var(--pico-border-radius)'; + + pill.dataset.userId = userId; + + let avatarImg = ''; + if (user.avatar_url) { + avatarImg = `${this.escapeHTML(user.display_name)}`; + } + + pill.innerHTML = `${avatarImg} ${this.escapeHTML(user.display_name)}`; + + if (!isPillForAdmin) { + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.classList.add("btn-close-custom", "ms-1"); // Custom class for styling + removeButton.innerHTML = "×"; // Simple 'x' + removeButton.dataset.action = `click->${this.identifier}#removeUser`; + removeButton.setAttribute("aria-label", "Remove"); + pill.appendChild(removeButton); + } + + this.selectedUsersContainerTarget.appendChild(pill); + } + + removeUser(event) { + const pill = event.target.closest(".user-pill"); + const userId = parseInt(pill.dataset.userId, 10); + + console.log("Removing user with ID:", userId); + + // Don't allow removing if it's the current user or an invalid ID + if (isNaN(userId) || (this.currentUserJsonValue && userId === this.currentUserJsonValue.id)) return; + + this.selectedUsers.delete(userId); + pill.remove(); + this.updateHiddenInput(); + this.updateDateLinks(); + } + + async applyPreset(event) { + event.preventDefault(); + const period = event.currentTarget.dataset.period; + + const request = new FetchRequest('get', `${this.leaderboardUsersUrlValue}?period=${period}`, { responseKind: 'json' }) + const response = await request.perform() + + if (response.ok) { + const data = await response.json; + const presetUsers = data.users; + + this.selectedUsersContainerTarget.querySelectorAll('.user-pill').forEach(pill => { + const uid = parseInt(pill.dataset.userId); + if (uid !== this.currentUserJsonValue.id) { + pill.remove(); + this.selectedUsers.delete(uid); + } + }); + + presetUsers.forEach(user => { + if (user.id !== this.currentUserJsonValue.id) { + this.addUserToSelection(user, false); + } + }); + + this.updateHiddenInput(); + this.updateDateLinks(); + } else { + console.error("Failed to load preset users"); + alert("Could not load preset users. Please try again."); + } + } + + updateHiddenInput() { + this.userIdsInputTarget.value = Array.from(this.selectedUsers.keys()).join(','); + } + + updateDateLinks() { + const selectedIds = Array.from(this.selectedUsers.keys()).join(','); + + document.querySelectorAll('a[data-date-nav-link="true"]').forEach(link => { + const url = new URL(link.href, window.location.origin); + if (selectedIds) { + url.searchParams.set('user_ids', selectedIds); + } else { + url.searchParams.delete('user_ids'); + } + link.href = url.toString(); + }); + } + + submitForm() { + const form = this.element.querySelector('form#timeline-filter-form'); + if (form) { + if(this.hasDateInputTarget && form.elements.date) { + form.elements.date.value = this.dateInputTarget.value; + } + form.requestSubmit(); + } + } + + handleKeydown(event) { + if (event.key === "Escape") { + this.clearSearch(); + } + } + + clearSearch() { + this.searchInputTarget.value = ""; + this.searchResultsTarget.innerHTML = ""; + this.searchResultsTarget.classList.remove('active'); + this.searchResultsTarget.style.display = 'none'; + } + + hideResultsDelayed() { + setTimeout(() => { + if (!this.searchResultsTarget.matches(':hover')) { + this.clearSearch(); + } + }, 200); + } + + escapeHTML(str) { + if (str === null || str === undefined) return ''; + return str.toString().replace(/[&<>"']/g, function (match) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[match]; + }); + } + + // This is just a wrapper to satisfy Stimulus data-action format + // The actual debounced search implementation is stored in _debouncedSearchImpl + debouncedSearch() { + this._debouncedSearchImpl(); + } +} \ No newline at end of file diff --git a/app/views/admin/timeline/show.html.erb b/app/views/admin/timeline/show.html.erb index 30d9d00..b99f590 100644 --- a/app/views/admin/timeline/show.html.erb +++ b/app/views/admin/timeline/show.html.erb @@ -43,21 +43,77 @@ # Add fixed spacers for the header: total_min_scroll_width_px = (activity_col_area_start_rem * 16) + min_header_content_width_px + (activity_col_area_end_rem * 16) - # No longer using flex-based width calculation for headers - # css_header_item_width = "calc((100% - #{(num_users > 1 ? (num_users - 1) * gutter_rem : 0)}rem) / #{num_users})" + # Current admin user and selected users for Stimulus + current_admin_user = { + id: current_user.id, + display_name: current_user.display_name, + avatar_url: current_user.avatar_url + } + current_admin_user_json = current_admin_user.to_json + + initial_selected_users_json = @initial_selected_user_objects.to_json + current_date_for_form = @date.to_s %>
    + +
    +
    + + + +
    +
    + +
    + + +
    +
    + +
    +
    + + +
    + +
    +
    + +
    + <%# Selected user pills will appear here %> +
    +
    +
    +
    <%= @date.in_time_zone(primary_user_tz).strftime("%A, %B %-d, %Y") %>
    - <%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline" %> - <%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline" %> - <%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline" %> + <%= link_to "← Prev", admin_timeline_path(date: @prev_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %> + <%= link_to "Today", admin_timeline_path(date: Time.current.to_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %> + <%= link_to "Next →", admin_timeline_path(date: @next_date.to_s), class: "button button-outline", data: { "date-nav-link": "true" } %>
    @@ -237,4 +293,53 @@
    - \ No newline at end of file + + +<% content_for :head do %> + +<% end %> \ No newline at end of file diff --git a/config/importmap.rb b/config/importmap.rb index 909dfc5..b3cb44f 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" +pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js" diff --git a/config/routes.rb b/config/routes.rb index b383c58..8e7464d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -19,6 +19,8 @@ Rails.application.routes.draw do namespace :admin do get 'timeline', to: 'timeline#show', as: :timeline + get 'timeline/search_users', to: 'timeline#search_users' + get 'timeline/leaderboard_users', to: 'timeline#leaderboard_users' end if Rails.env.development? diff --git a/vendor/javascript/@rails--request.js.js b/vendor/javascript/@rails--request.js.js new file mode 100644 index 0000000..4c5dfd1 --- /dev/null +++ b/vendor/javascript/@rails--request.js.js @@ -0,0 +1,4 @@ +// @rails/request.js@0.0.12 downloaded from https://ga.jspm.io/npm:@rails/request.js@0.0.12/src/index.js + +class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");if(e){const n=e.nonce===""?e.content:e.nonce;n&&t.setAttribute("nonce",n)}t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function t(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function e(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function n(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function s(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function r(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=this.responseKind==="turbo-stream"&&window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:")&&!this.originalUrl.startsWith("https:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect,keepalive:this.keepalive}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return e(Object.assign(t,this.additionalHeaders))}get csrfToken(){return t(n("csrf-param"))||n("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?s(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});r(e,n);const o=e.toString();return o.length>0?`?${o}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get keepalive(){return this.options.keepalive||false}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function o(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function i(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function c(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function a(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function h(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,h as destroy,o as get,a as patch,i as post,c as put}; +