Better insight into settings updates

This commit is contained in:
Gregory Schier
2024-12-16 16:27:13 -08:00
parent 5ff5d6fb1d
commit cb6e3d4ac8
18 changed files with 145 additions and 88 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -8000,6 +8000,7 @@ dependencies = [
"tauri-plugin-yaak-license", "tauri-plugin-yaak-license",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"ts-rs",
"urlencoding", "urlencoding",
"uuid", "uuid",
"yaak_grpc", "yaak_grpc",

View File

@@ -63,6 +63,7 @@ tauri-plugin-updater = "2.0.2"
tauri-plugin-window-state = "2.0.1" tauri-plugin-window-state = "2.0.1"
tokio = { version = "1.36.0", features = ["sync"] } tokio = { version = "1.36.0", features = ["sync"] }
tokio-stream = "0.1.15" tokio-stream = "0.1.15"
ts-rs = { workspace = true }
uuid = "1.7.0" uuid = "1.7.0"
mime_guess = "2.0.5" mime_guess = "2.0.5"
urlencoding = "2.1.3" urlencoding = "2.1.3"

View File

@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnalyticsAction = "cancel" | "click" | "commit" | "create" | "delete" | "delete_many" | "duplicate" | "export" | "hide" | "import" | "launch" | "launch_first" | "launch_update" | "send" | "show" | "toggle" | "update" | "upsert";
export type AnalyticsResource = "app" | "appearance" | "button" | "checkbox" | "cookie_jar" | "dialog" | "environment" | "folder" | "grpc_connection" | "grpc_event" | "grpc_request" | "http_request" | "http_response" | "link" | "key_value" | "plugin" | "select" | "setting" | "sidebar" | "tab" | "theme" | "workspace";

View File

@@ -4,7 +4,7 @@ use log::{debug, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tauri::{Manager, Runtime, WebviewWindow}; use tauri::{Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_models::queries::{generate_id, get_key_value_int, get_key_value_string, get_or_create_settings, set_key_value_int, set_key_value_string}; use yaak_models::queries::{generate_id, get_key_value_int, get_key_value_string, get_or_create_settings, set_key_value_int, set_key_value_string};
use crate::is_dev; use crate::is_dev;
@@ -13,11 +13,14 @@ const NAMESPACE: &str = "analytics";
const NUM_LAUNCHES_KEY: &str = "num_launches"; const NUM_LAUNCHES_KEY: &str = "num_launches";
// serializable // serializable
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "analytics.ts")]
pub enum AnalyticsResource { pub enum AnalyticsResource {
App, App,
Appearance, Appearance,
Button,
Checkbox,
CookieJar, CookieJar,
Dialog, Dialog,
Environment, Environment,
@@ -27,10 +30,13 @@ pub enum AnalyticsResource {
GrpcRequest, GrpcRequest,
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
Link,
KeyValue, KeyValue,
Plugin, Plugin,
Select,
Setting, Setting,
Sidebar, Sidebar,
Tab,
Theme, Theme,
Workspace, Workspace,
} }
@@ -51,10 +57,12 @@ impl Display for AnalyticsResource {
} }
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, TS)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[ts(export, export_to = "analytics.ts")]
pub enum AnalyticsAction { pub enum AnalyticsAction {
Cancel, Cancel,
Click,
Commit, Commit,
Create, Create,
Delete, Delete,
@@ -156,7 +164,7 @@ pub async fn track_event<R: Runtime>(
action: AnalyticsAction, action: AnalyticsAction,
attributes: Option<Value>, attributes: Option<Value>,
) { ) {
let id = get_id(w).await; let id = get_id(w).await;
let event = format!("{}.{}", resource, action); let event = format!("{}.{}", resource, action);
let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string(); let attributes_json = attributes.unwrap_or("{}".to_string().into()).to_string();

View File

@@ -8,4 +8,4 @@ export type ActivateLicenseResponsePayload = { activationId: string, };
export type CheckActivationResponsePayload = { active: boolean, }; export type CheckActivationResponsePayload = { active: boolean, };
export type LicenseCheckStatus = { "type": "personal_use" } | { "type": "commercial_use" } | { "type": "trialing", end: string, } | { "type": "trial_ended", end: string, }; export type LicenseCheckStatus = { "type": "personal_use", trial_ended: string, } | { "type": "commercial_use" } | { "type": "invalid_license" } | { "type": "trialing", end: string, };

View File

@@ -10,9 +10,9 @@ const details: Record<
{ label: string; color: ButtonProps['color'] } | null { label: string; color: ButtonProps['color'] } | null
> = { > = {
commercial_use: null, commercial_use: null,
invalid_license: { label: 'Invalid License', color: 'danger' }, invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'success' }, personal_use: { label: 'Personal Use', color: 'primary' },
trialing: { label: 'Personal Use', color: 'success' }, trialing: { label: 'Personal Use', color: 'primary' },
}; };
export function LicenseBadge() { export function LicenseBadge() {
@@ -35,6 +35,7 @@ export function LicenseBadge() {
className="!rounded-full mx-1" className="!rounded-full mx-1"
onClick={() => openSettings.mutate()} onClick={() => openSettings.mutate()}
color={detail.color} color={detail.color}
event={{ id: 'license-badge', status: check.data.type }}
> >
{detail.label} {detail.label}
</Button> </Button>

View File

@@ -4,7 +4,6 @@ import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme'; import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import { useSettings } from '../../hooks/useSettings'; import { useSettings } from '../../hooks/useSettings';
import { useUpdateSettings } from '../../hooks/useUpdateSettings'; import { useUpdateSettings } from '../../hooks/useUpdateSettings';
import { trackEvent } from '../../lib/analytics';
import { clamp } from '../../lib/clamp'; import { clamp } from '../../lib/clamp';
import { getThemes } from '../../lib/theme/themes'; import { getThemes } from '../../lib/theme/themes';
import { isThemeDark } from '../../lib/theme/window'; import { isThemeDark } from '../../lib/theme/window';
@@ -89,6 +88,7 @@ export function SettingsAppearance() {
value={`${settings.interfaceFontSize}`} value={`${settings.interfaceFontSize}`}
options={fontSizes} options={fontSizes}
onChange={(v) => updateSettings.mutate({ interfaceFontSize: parseInt(v) })} onChange={(v) => updateSettings.mutate({ interfaceFontSize: parseInt(v) })}
event="font-size.interface"
/> />
<Select <Select
size="sm" size="sm"
@@ -98,11 +98,13 @@ export function SettingsAppearance() {
value={`${settings.editorFontSize}`} value={`${settings.editorFontSize}`}
options={fontSizes} options={fontSizes}
onChange={(v) => updateSettings.mutate({ editorFontSize: clamp(parseInt(v) || 14, 8, 30) })} onChange={(v) => updateSettings.mutate({ editorFontSize: clamp(parseInt(v) || 14, 8, 30) })}
event="font-size.editor"
/> />
<Checkbox <Checkbox
checked={settings.editorSoftWrap} checked={settings.editorSoftWrap}
title="Wrap Editor Lines" title="Wrap Editor Lines"
onChange={(editorSoftWrap) => updateSettings.mutate({ editorSoftWrap })} onChange={(editorSoftWrap) => updateSettings.mutate({ editorSoftWrap })}
event="wrap-lines"
/> />
<Separator className="my-4" /> <Separator className="my-4" />
@@ -113,10 +115,8 @@ export function SettingsAppearance() {
labelPosition="top" labelPosition="top"
size="sm" size="sm"
value={settings.appearance} value={settings.appearance}
onChange={(appearance) => { onChange={(appearance) => updateSettings.mutate({ appearance })}
trackEvent('appearance', 'update', { appearance }); event="appearance"
updateSettings.mutateAsync({ appearance });
}}
options={[ options={[
{ label: 'Automatic', value: 'system' }, { label: 'Automatic', value: 'system' },
{ label: 'Light', value: 'light' }, { label: 'Light', value: 'light' },
@@ -134,10 +134,8 @@ export function SettingsAppearance() {
className="flex-1" className="flex-1"
value={activeTheme.light.id} value={activeTheme.light.id}
options={lightThemes} options={lightThemes}
onChange={(themeLight) => { event="theme.light"
trackEvent('theme', 'update', { theme: themeLight, appearance: 'light' }); onChange={(themeLight) => updateSettings.mutate({ ...settings, themeLight })}
updateSettings.mutateAsync({ ...settings, themeLight });
}}
/> />
)} )}
{(settings.appearance === 'system' || settings.appearance === 'dark') && ( {(settings.appearance === 'system' || settings.appearance === 'dark') && (
@@ -150,10 +148,8 @@ export function SettingsAppearance() {
size="sm" size="sm"
value={activeTheme.dark.id} value={activeTheme.dark.id}
options={darkThemes} options={darkThemes}
onChange={(themeDark) => { event="theme.dark"
trackEvent('theme', 'update', { theme: themeDark, appearance: 'dark' }); onChange={(themeDark) => updateSettings.mutate({ ...settings, themeDark })}
updateSettings.mutateAsync({ ...settings, themeDark });
}}
/> />
)} )}
</HStack> </HStack>

View File

@@ -37,6 +37,7 @@ export function SettingsGeneral() {
size="sm" size="sm"
value={settings.updateChannel} value={settings.updateChannel}
onChange={(updateChannel) => updateSettings.mutate({ updateChannel })} onChange={(updateChannel) => updateSettings.mutate({ updateChannel })}
event="update-channel"
options={[ options={[
{ label: 'Stable (less frequent)', value: 'stable' }, { label: 'Stable (less frequent)', value: 'stable' },
{ label: 'Beta (more frequent)', value: 'beta' }, { label: 'Beta (more frequent)', value: 'beta' },
@@ -57,6 +58,7 @@ export function SettingsGeneral() {
labelPosition="left" labelPosition="left"
labelClassName="w-[12rem]" labelClassName="w-[12rem]"
size="sm" size="sm"
event="workspace-open"
value={ value={
settings.openWorkspaceNewWindow === true settings.openWorkspaceNewWindow === true
? 'new' ? 'new'
@@ -80,6 +82,7 @@ export function SettingsGeneral() {
className="mt-3" className="mt-3"
checked={settings.telemetry} checked={settings.telemetry}
title="Send Usage Statistics" title="Send Usage Statistics"
event="usage-statistics"
onChange={(telemetry) => updateSettings.mutate({ telemetry })} onChange={(telemetry) => updateSettings.mutate({ telemetry })}
/> />
@@ -107,6 +110,7 @@ export function SettingsGeneral() {
<Checkbox <Checkbox
checked={workspace.settingValidateCertificates} checked={workspace.settingValidateCertificates}
title="Validate TLS Certificates" title="Validate TLS Certificates"
event="validate-certs"
onChange={(settingValidateCertificates) => onChange={(settingValidateCertificates) =>
updateWorkspace.mutate({ settingValidateCertificates }) updateWorkspace.mutate({ settingValidateCertificates })
} }
@@ -115,6 +119,7 @@ export function SettingsGeneral() {
<Checkbox <Checkbox
checked={workspace.settingFollowRedirects} checked={workspace.settingFollowRedirects}
title="Follow Redirects" title="Follow Redirects"
event="follow-redirects"
onChange={(settingFollowRedirects) => updateWorkspace.mutate({ settingFollowRedirects })} onChange={(settingFollowRedirects) => updateWorkspace.mutate({ settingFollowRedirects })}
/> />
</VStack> </VStack>

View File

@@ -21,29 +21,27 @@ export function SettingsLicense() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{check.data?.type === 'commercial_use' && ( {check.data?.type === 'commercial_use' ? (
<Banner color="success"> <Banner color="success">
<strong>License active!</strong> Enjoy using Yaak for commercial use. <strong>License active!</strong> Enjoy using Yaak for commercial use.
</Banner> </Banner>
)} ) : (
{check.data?.type === 'trialing' && (
<Banner color="success">
<strong>Your trial ends in {formatDistanceToNow(check.data.end)}</strong>. If you&apos;re
using Yaak for commercial use, please purchase a commercial use license.
</Banner>
)}
{check.data?.type === 'personal_use' && (
<Banner color="primary" className="flex flex-col gap-2"> <Banner color="primary" className="flex flex-col gap-2">
<h2 className="text-lg font-semibold">Commercial License</h2> {check.data?.type === 'trialing' && (
<p>
<strong>Your trial ends in {formatDistanceToNow(check.data.end)}.</strong>
</p>
)}
<p> <p>
A commercial license is required if you use Yaak within a for-profit organization of two A commercial license is required if using Yaak within a for-profit organization of two
or more people. or more people. This helps support the ongoing development of Yaak and ensures continued
</p> growth and improvement.
<p>
<Link href="https://yaak.app/pricing" className="text-sm">
Learn More
</Link>
</p> </p>
<p>If you&#39;re using Yaak for personal use, no action is needed.</p>
<p>~ Gregory</p>
<Link href="https://yaak.app/pricing" className="text-sm text-text-subtle">
Learn More
</Link>
</Banner> </Banner>
)} )}
@@ -52,7 +50,13 @@ export function SettingsLicense() {
{check.data?.type === 'commercial_use' ? ( {check.data?.type === 'commercial_use' ? (
<HStack space={2}> <HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}> <Button
variant="border"
color="secondary"
size="sm"
onClick={toggleActivateFormVisible}
event="license.another"
>
Activate Another License Activate Another License
</Button> </Button>
<Button <Button
@@ -60,20 +64,27 @@ export function SettingsLicense() {
size="sm" size="sm"
onClick={() => open('https://yaak.app/dashboard')} onClick={() => open('https://yaak.app/dashboard')}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
event="license.support"
> >
Direct Support Direct Support
</Button> </Button>
</HStack> </HStack>
) : ( ) : (
<HStack space={2}> <HStack space={2}>
<Button color="primary" size="sm" onClick={toggleActivateFormVisible}> <Button
Activate License color="primary"
size="sm"
onClick={toggleActivateFormVisible}
event="license.activate"
>
Activate
</Button> </Button>
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => open('https://yaak.app/pricing')} onClick={() => open('https://yaak.app/pricing?ref=app.yaak.desktop')}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
event="license.purchase"
> >
Purchase Purchase
</Button> </Button>
@@ -98,7 +109,13 @@ export function SettingsLicense() {
onChange={setKey} onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX" placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/> />
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}> <Button
type="submit"
color="primary"
size="sm"
isLoading={activate.isPending}
event="license.submit"
>
Submit Submit
</Button> </Button>
</VStack> </VStack>

View File

@@ -61,7 +61,7 @@ export function SettingsPlugins() {
/> />
<HStack> <HStack>
{directory && ( {directory && (
<Button size="xs" type="submit" color="primary" className="ml-auto"> <Button size="xs" type="submit" color="primary" className="ml-auto" event="plugin.add">
Add Plugin Add Plugin
</Button> </Button>
)} )}
@@ -70,12 +70,14 @@ export function SettingsPlugins() {
icon="refresh" icon="refresh"
title="Reload plugins" title="Reload plugins"
spin={refreshPlugins.isPending} spin={refreshPlugins.isPending}
event="plugin.reload"
onClick={() => refreshPlugins.mutate()} onClick={() => refreshPlugins.mutate()}
/> />
<IconButton <IconButton
size="sm" size="sm"
icon="help" icon="help"
title="View documentation" title="View documentation"
event="plugin.docs"
onClick={() => open('https://feedback.yaak.app/help/articles/6911763-quick-start')} onClick={() => open('https://feedback.yaak.app/help/articles/6911763-quick-start')}
/> />
</HStack> </HStack>
@@ -100,6 +102,7 @@ function PluginInfo({ plugin }: { plugin: Plugin }) {
icon="trash" icon="trash"
title="Uninstall plugin" title="Uninstall plugin"
className="text-text-subtlest" className="text-text-subtlest"
event="plugin.delete"
onClick={() => deletePlugin.mutate()} onClick={() => deletePlugin.mutate()}
/> />
</td> </td>

View File

@@ -19,6 +19,7 @@ export function SettingsProxy() {
hideLabel hideLabel
size="sm" size="sm"
value={settings.proxy?.type ?? 'automatic'} value={settings.proxy?.type ?? 'automatic'}
event="proxy"
onChange={(v) => { onChange={(v) => {
if (v === 'automatic') { if (v === 'automatic') {
updateSettings.mutate({ proxy: undefined }); updateSettings.mutate({ proxy: undefined });

View File

@@ -3,6 +3,7 @@ import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react'; import { forwardRef, useImperativeHandle, useRef } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey'; import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey'; import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon'; import { Icon } from './Icon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & { export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & {
@@ -28,6 +29,7 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
hotkeyAction?: HotkeyAction; hotkeyAction?: HotkeyAction;
event?: string | { id: string; [attr: string]: number | string };
}; };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -48,6 +50,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
hotkeyAction, hotkeyAction,
title, title,
onClick, onClick,
event,
...props ...props
}: ButtonProps, }: ButtonProps,
ref, ref,
@@ -107,7 +110,12 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
type={type} type={type}
className={classes} className={classes}
disabled={disabled || isLoading} disabled={disabled || isLoading}
onClick={onClick} onClick={(e) => {
onClick?.(e);
if (event != null) {
trackEvent('button', 'click', typeof event === 'string' ? { id: event } : event);
}
}}
onDoubleClick={(e) => { onDoubleClick={(e) => {
// Kind of a hack? This prevents double-clicks from going through buttons. For example, when // Kind of a hack? This prevents double-clicks from going through buttons. For example, when
// double-clicking the workspace header to toggle window maximization // double-clicking the workspace header to toggle window maximization

View File

@@ -1,5 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { HStack } from './Stacks'; import { HStack } from './Stacks';
@@ -11,6 +12,7 @@ export interface CheckboxProps {
disabled?: boolean; disabled?: boolean;
inputWrapperClassName?: string; inputWrapperClassName?: string;
hideLabel?: boolean; hideLabel?: boolean;
event?: string;
} }
export function Checkbox({ export function Checkbox({
@@ -21,6 +23,7 @@ export function Checkbox({
disabled, disabled,
title, title,
hideLabel, hideLabel,
event,
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
<HStack <HStack
@@ -37,7 +40,12 @@ export function Checkbox({
)} )}
type="checkbox" type="checkbox"
disabled={disabled} disabled={disabled}
onChange={() => onChange(checked === 'indeterminate' ? true : !checked)} onChange={() => {
onChange(checked === 'indeterminate' ? true : !checked);
if (event != null) {
trackEvent('button', 'click', { id: event, checked: checked ? 'on' : 'off' });
}
}}
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Icon <Icon

View File

@@ -1,13 +1,15 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
import { trackEvent } from '../../lib/analytics';
import { Icon } from './Icon'; import { Icon } from './Icon';
interface Props extends HTMLAttributes<HTMLAnchorElement> { interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string; href: string;
event?: string;
} }
export function Link({ href, children, className, ...other }: Props) { export function Link({ href, children, className, event, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//); const isExternal = href.match(/^https?:\/\//);
className = classNames(className, 'relative underline hover:text-violet-600'); className = classNames(className, 'relative underline hover:text-violet-600');
@@ -19,6 +21,12 @@ export function Link({ href, children, className, ...other }: Props) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={classNames(className, 'pr-4 inline-flex items-center')} className={classNames(className, 'pr-4 inline-flex items-center')}
onClick={(e) => {
e.preventDefault();
if (event != null) {
trackEvent('link', 'click', { id: event });
}
}}
{...other} {...other}
> >
<span className="underline">{children}</span> <span className="underline">{children}</span>

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useOsInfo } from '../../hooks/useOsInfo'; import { useOsInfo } from '../../hooks/useOsInfo';
import { trackEvent } from '../../lib/analytics';
import type { ButtonProps } from './Button'; import type { ButtonProps } from './Button';
import { Button } from './Button'; import { Button } from './Button';
import type { RadioDropdownItem } from './RadioDropdown'; import type { RadioDropdownItem } from './RadioDropdown';
@@ -20,6 +21,7 @@ export interface SelectProps<T extends string> {
onChange: (value: T) => void; onChange: (value: T) => void;
size?: ButtonProps['size']; size?: ButtonProps['size'];
className?: string; className?: string;
event?: string;
} }
export function Select<T extends string>({ export function Select<T extends string>({
@@ -33,6 +35,7 @@ export function Select<T extends string>({
leftSlot, leftSlot,
onChange, onChange,
className, className,
event,
size = 'md', size = 'md',
}: SelectProps<T>) { }: SelectProps<T>) {
const osInfo = useOsInfo(); const osInfo = useOsInfo();
@@ -40,6 +43,13 @@ export function Select<T extends string>({
const id = `input-${name}`; const id = `input-${name}`;
const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null; const isInvalidSelection = options.find((o) => 'value' in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
if (event != null) {
trackEvent('select', 'click', { id: event, value });
}
};
return ( return (
<div <div
className={classNames( className={classNames(
@@ -79,7 +89,7 @@ export function Select<T extends string>({
<select <select
value={value} value={value}
style={selectBackgroundStyles} style={selectBackgroundStyles}
onChange={(e) => onChange(e.target.value as T)} onChange={(e) => handleChange(e.target.value as T)}
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
className={classNames('pr-7 w-full outline-none bg-transparent')} className={classNames('pr-7 w-full outline-none bg-transparent')}
@@ -98,7 +108,7 @@ export function Select<T extends string>({
) : ( ) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in // Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode // light mode
<RadioDropdown value={value} onChange={onChange} items={options}> <RadioDropdown value={value} onChange={handleChange} items={options}>
<Button <Button
className="w-full text-sm font-mono" className="w-full text-sm font-mono"
justify="start" justify="start"

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react'; import { memo, useEffect, useRef } from 'react';
import { trackEvent } from '../../../lib/analytics';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown'; import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown'; import { RadioDropdown } from '../RadioDropdown';
@@ -95,7 +96,14 @@ export function Tabs({
onChange={t.options.onChange} onChange={t.options.onChange}
> >
<button <button
onClick={isActive ? undefined : () => onChangeValue(t.value)} onClick={
isActive
? undefined
: () => {
trackEvent('tab', 'click', { label, tab: t.value });
onChangeValue(t.value);
}
}
className={btnClassName} className={btnClassName}
> >
{option && 'shortLabel' in option {option && 'shortLabel' in option
@@ -113,7 +121,14 @@ export function Tabs({
return ( return (
<button <button
key={t.value} key={t.value}
onClick={() => onChangeValue(t.value)} onClick={
isActive
? undefined
: () => {
trackEvent('tab', 'click', { label, tab: t.value });
onChangeValue(t.value);
}
}
className={btnClassName} className={btnClassName}
> >
{t.label} {t.label}

View File

@@ -1,10 +1,11 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import type { SettingsTab } from '../components/Settings/Settings'; import { SettingsTab } from '../components/Settings/Settings';
import { trackEvent } from '../lib/analytics';
import { invokeCmd } from '../lib/tauri'; import { invokeCmd } from '../lib/tauri';
import { useActiveWorkspace } from './useActiveWorkspace'; import { useActiveWorkspace } from './useActiveWorkspace';
import { useAppRoutes } from './useAppRoutes'; import { useAppRoutes } from './useAppRoutes';
export function useOpenSettings(tab?: SettingsTab) { export function useOpenSettings(tab = SettingsTab.General) {
const routes = useAppRoutes(); const routes = useAppRoutes();
const workspace = useActiveWorkspace(); const workspace = useActiveWorkspace();
return useMutation({ return useMutation({
@@ -12,6 +13,7 @@ export function useOpenSettings(tab?: SettingsTab) {
mutationFn: async () => { mutationFn: async () => {
if (workspace == null) return; if (workspace == null) return;
trackEvent('dialog', 'show', { id: 'settings', tab: `${tab}` });
await invokeCmd('cmd_new_child_window', { await invokeCmd('cmd_new_child_window', {
url: routes.paths.workspaceSettings({ workspaceId: workspace.id, tab }), url: routes.paths.workspaceSettings({ workspaceId: workspace.id, tab }),
label: 'settings', label: 'settings',

View File

@@ -1,41 +1,9 @@
import type { AnalyticsAction, AnalyticsResource } from '../../src-tauri/bindings/analytics';
import { invokeCmd } from './tauri'; import { invokeCmd } from './tauri';
export type TrackResource =
| 'appearance'
| 'app'
| 'cookie_jar'
| 'dialog'
| 'environment'
| 'folder'
| 'grpc_connection'
| 'grpc_event'
| 'grpc_request'
| 'http_request'
| 'http_response'
| 'key_value'
| 'plugin'
| 'setting'
| 'sidebar'
| 'theme'
| 'workspace';
export type TrackAction =
| 'cancel'
| 'commit'
| 'create'
| 'delete'
| 'delete_many'
| 'duplicate'
| 'hide'
| 'launch'
| 'send'
| 'show'
| 'toggle'
| 'update';
export function trackEvent( export function trackEvent(
resource: TrackResource, resource: AnalyticsResource,
action: TrackAction, action: AnalyticsAction,
attributes: Record<string, string | number> = {}, attributes: Record<string, string | number> = {},
) { ) {
invokeCmd('cmd_track_event', { invokeCmd('cmd_track_event', {