chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import type { loader } from "../root";
|
||||
|
||||
export function useApiOrigin() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch!.apiOrigin;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import type { loader } from "../root";
|
||||
|
||||
export function useAppOrigin() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch!.appOrigin;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type UseAutoRevalidateOptions = {
|
||||
interval?: number; // in milliseconds
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
|
||||
const { interval = 5000, onFocus = true, disabled = false } = options;
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (revalidator.state === "loading") {
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate when the page becomes visible
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
// Revalidate when the window gains focus
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [onFocus, disabled]);
|
||||
|
||||
return revalidator;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
const AT_BOTTOM_TOLERANCE_PX = 16;
|
||||
|
||||
/**
|
||||
* Chat-style sticky-bottom auto-scroll behavior.
|
||||
*
|
||||
* Behavior:
|
||||
* - On mount, finds the closest scrollable ancestor of the returned ref
|
||||
* (the inspector content panel, the playground messages panel, etc.).
|
||||
* - Tracks whether the user is currently "at the bottom" of that scroll
|
||||
* container via a passive scroll listener. Default is `true` so the very
|
||||
* first render of an existing conversation lands at the bottom, and the
|
||||
* "content fits without scrolling" case stays in auto-scroll mode.
|
||||
* - Whenever the dependency array changes (typically the messages array),
|
||||
* if the user was at the bottom, programmatically scrolls to the new
|
||||
* bottom. Uses `useLayoutEffect` so the scroll happens before paint and
|
||||
* there's no one-frame flicker showing new content above the viewport.
|
||||
* - Scrolling away from the bottom flips the ref to `false` → auto-scroll
|
||||
* pauses. Scrolling back into the bottom band (within
|
||||
* `AT_BOTTOM_TOLERANCE_PX`) flips it back to `true` → auto-scroll
|
||||
* resumes.
|
||||
*
|
||||
* The programmatic scroll fires its own scroll event, which immediately
|
||||
* re-runs the stickiness check and confirms we're still at the bottom
|
||||
* (distance ≈ 0 ≤ tolerance), so the ref stays `true`. No special
|
||||
* "ignore programmatic scroll" flag needed.
|
||||
*
|
||||
* @param deps Pass the rendered list (or any dependency that should
|
||||
* trigger a re-scroll). Typically `[messages]`.
|
||||
* @returns A ref to attach to the component's root element. The hook
|
||||
* walks up from this element's parent to locate the scroll
|
||||
* container, so the root must be mounted *inside* the
|
||||
* scrollable region.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ChatPanel({ messages }) {
|
||||
* const rootRef = useAutoScrollToBottom([messages]);
|
||||
* return (
|
||||
* <div className="overflow-y-auto h-full">
|
||||
* <div ref={rootRef}>
|
||||
* {messages.map((m) => <Message key={m.id} message={m} />)}
|
||||
* </div>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAutoScrollToBottom(deps: ReadonlyArray<unknown>) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
// Default true so initial mount + replay land at the bottom, and the
|
||||
// no-overflow case stays sticky once content starts to grow.
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
// Locate the scroll container on mount and attach a passive scroll
|
||||
// listener that updates `stickToBottomRef`.
|
||||
useEffect(() => {
|
||||
const findScrollContainer = (start: HTMLElement | null): HTMLElement | null => {
|
||||
let current: HTMLElement | null = start;
|
||||
while (current) {
|
||||
const style = getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = findScrollContainer(rootRef.current?.parentElement ?? null);
|
||||
if (!container) return;
|
||||
containerRef.current = container;
|
||||
|
||||
const updateStickiness = () => {
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
stickToBottomRef.current = distanceFromBottom <= AT_BOTTOM_TOLERANCE_PX;
|
||||
};
|
||||
|
||||
// Seed from current position so the first messages-effect uses an
|
||||
// accurate value rather than the default `true` if the user happened
|
||||
// to mount the view already scrolled.
|
||||
updateStickiness();
|
||||
|
||||
container.addEventListener("scroll", updateStickiness, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", updateStickiness);
|
||||
containerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// After each commit that changes the deps (typically the messages
|
||||
// array), if we were at the bottom, scroll to the new bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return rootRef;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { type loader as canViewLogsPageLoader } from "~/routes/resources.orgs.$organizationSlug.can-view-logs-page/route";
|
||||
|
||||
export function useCanViewLogsPage(): boolean | undefined {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useTypedFetcher<typeof canViewLogsPageLoader>();
|
||||
|
||||
useEffect(() => {
|
||||
const url = `/resources/orgs/${organization.slug}/can-view-logs-page`;
|
||||
fetcher.load(url);
|
||||
}, [organization.slug]);
|
||||
|
||||
return fetcher.data?.canViewLogsPage;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Call a function when the id of the item changes */
|
||||
export function useChanged<T extends { id: string }>(
|
||||
getItem: () => T | undefined,
|
||||
action: (item: T | undefined) => void,
|
||||
sendInitialUndefined = true
|
||||
) {
|
||||
const previousItemId = useRef<string | undefined>();
|
||||
const item = getItem();
|
||||
|
||||
//when the value changes, call the action
|
||||
useEffect(() => {
|
||||
if (previousItemId.current !== item?.id) {
|
||||
action(item);
|
||||
}
|
||||
|
||||
previousItemId.current = item?.id;
|
||||
}, [item]);
|
||||
|
||||
//if sendInitialUndefined is true, call the action when the component first renders
|
||||
useEffect(() => {
|
||||
if (item !== undefined || sendInitialUndefined === false) return;
|
||||
action(item);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useCopy(value: string, duration = 1500) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, duration);
|
||||
},
|
||||
[value, duration]
|
||||
);
|
||||
|
||||
return { copy, copied };
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useReducer, useCallback, useRef, useEffect } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import type {
|
||||
DashboardLayout,
|
||||
LayoutItem,
|
||||
Widget,
|
||||
} from "~/presenters/v3/MetricDashboardPresenter.server";
|
||||
import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type EditorMode = null | { type: "add" } | { type: "edit"; widgetId: string; widget: WidgetData };
|
||||
|
||||
type DashboardState = {
|
||||
/** The layout items (positions/sizes) */
|
||||
layout: LayoutItem[];
|
||||
/** The widget configurations keyed by widget ID */
|
||||
widgets: Record<string, Widget>;
|
||||
/** Current editor mode (add/edit/closed) */
|
||||
editorMode: EditorMode;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Actions
|
||||
// ============================================================================
|
||||
|
||||
type DashboardAction =
|
||||
| { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } }
|
||||
| { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } }
|
||||
| { type: "RENAME_WIDGET"; payload: { id: string; title: string } }
|
||||
| { type: "DELETE_WIDGET"; payload: { id: string } }
|
||||
| { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } }
|
||||
| { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } }
|
||||
| { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record<string, Widget> } }
|
||||
| { type: "OPEN_ADD_EDITOR" }
|
||||
| { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } }
|
||||
| { type: "CLOSE_EDITOR" };
|
||||
|
||||
// ============================================================================
|
||||
// Reducer
|
||||
// ============================================================================
|
||||
|
||||
function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
|
||||
switch (action.type) {
|
||||
case "ADD_WIDGET":
|
||||
return {
|
||||
...state,
|
||||
layout: [...state.layout, action.payload.layoutItem],
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: action.payload.widget,
|
||||
},
|
||||
editorMode: null,
|
||||
};
|
||||
|
||||
case "UPDATE_WIDGET":
|
||||
return {
|
||||
...state,
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: action.payload.widget,
|
||||
},
|
||||
editorMode: null,
|
||||
};
|
||||
|
||||
case "RENAME_WIDGET": {
|
||||
const existingWidget = state.widgets[action.payload.id];
|
||||
if (!existingWidget) return state;
|
||||
return {
|
||||
...state,
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.id]: {
|
||||
...existingWidget,
|
||||
title: action.payload.title,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "DELETE_WIDGET": {
|
||||
const { [action.payload.id]: _, ...remainingWidgets } = state.widgets;
|
||||
return {
|
||||
...state,
|
||||
layout: state.layout.filter((item) => item.i !== action.payload.id),
|
||||
widgets: remainingWidgets,
|
||||
};
|
||||
}
|
||||
|
||||
case "DUPLICATE_WIDGET": {
|
||||
const original = state.widgets[action.payload.id];
|
||||
const originalLayout = state.layout.find((l) => l.i === action.payload.id);
|
||||
if (!original || !originalLayout) return state;
|
||||
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
|
||||
// Deep copy the widget to ensure no shared references
|
||||
// This prevents edits to one widget from affecting the duplicate
|
||||
const duplicatedWidget: Widget = {
|
||||
title: `${original.title} (Copy)`,
|
||||
query: original.query,
|
||||
display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
layout: [
|
||||
...state.layout,
|
||||
{ ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 },
|
||||
],
|
||||
widgets: {
|
||||
...state.widgets,
|
||||
[action.payload.newId]: duplicatedWidget,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case "UPDATE_LAYOUT":
|
||||
return { ...state, layout: action.payload.layout };
|
||||
|
||||
case "RESET_STATE":
|
||||
return {
|
||||
...state,
|
||||
layout: action.payload.layout,
|
||||
widgets: action.payload.widgets,
|
||||
};
|
||||
|
||||
case "OPEN_ADD_EDITOR":
|
||||
return { ...state, editorMode: { type: "add" } };
|
||||
|
||||
case "OPEN_EDIT_EDITOR":
|
||||
return {
|
||||
...state,
|
||||
editorMode: {
|
||||
type: "edit",
|
||||
widgetId: action.payload.widgetId,
|
||||
widget: action.payload.widget,
|
||||
},
|
||||
};
|
||||
|
||||
case "CLOSE_EDITOR":
|
||||
return { ...state, editorMode: null };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook Options
|
||||
// ============================================================================
|
||||
|
||||
export type UseDashboardEditorOptions = {
|
||||
/** Initial dashboard layout data from the server */
|
||||
initialData: DashboardLayout;
|
||||
/** URL for widget actions (add, update, delete, duplicate) */
|
||||
widgetActionUrl: string;
|
||||
/** URL for layout updates. If empty or not provided, uses current page URL. */
|
||||
layoutActionUrl?: string;
|
||||
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
|
||||
widgetLimit?: number;
|
||||
/** Callback when a sync error occurs */
|
||||
onSyncError?: (error: Error, action: string) => void;
|
||||
/** Callback when a widget action is blocked by the limit */
|
||||
onWidgetLimitReached?: () => void;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sync Queue Types
|
||||
// ============================================================================
|
||||
|
||||
type WidgetSyncTask = {
|
||||
type: "widget";
|
||||
action: string;
|
||||
data: Record<string, string>;
|
||||
};
|
||||
|
||||
type LayoutSyncTask = {
|
||||
type: "layout";
|
||||
layout: LayoutItem[];
|
||||
};
|
||||
|
||||
type SyncTask = WidgetSyncTask | LayoutSyncTask;
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useDashboardEditor({
|
||||
initialData,
|
||||
widgetActionUrl,
|
||||
layoutActionUrl,
|
||||
widgetLimit,
|
||||
onSyncError,
|
||||
onWidgetLimitReached,
|
||||
}: UseDashboardEditorOptions) {
|
||||
const [state, dispatch] = useReducer(dashboardReducer, {
|
||||
layout: initialData.layout,
|
||||
widgets: initialData.widgets,
|
||||
editorMode: null,
|
||||
});
|
||||
|
||||
// Refs for debouncing and tracking initialization
|
||||
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isInitializedRef = useRef(false);
|
||||
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
|
||||
|
||||
// Sync queue to prevent race conditions
|
||||
const syncQueueRef = useRef<SyncTask[]>([]);
|
||||
const isSyncingRef = useRef(false);
|
||||
|
||||
// Reset state when initialData changes (e.g., navigating to different dashboard)
|
||||
const initialDataJson = JSON.stringify({
|
||||
layout: initialData.layout,
|
||||
widgets: initialData.widgets,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Cancel any pending layout save
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
layoutDebounceRef.current = null;
|
||||
}
|
||||
|
||||
// Clear the sync queue when switching dashboards
|
||||
syncQueueRef.current = [];
|
||||
|
||||
// Reset state to new initial data
|
||||
dispatch({
|
||||
type: "RESET_STATE",
|
||||
payload: { layout: initialData.layout, widgets: initialData.widgets },
|
||||
});
|
||||
|
||||
// Update refs
|
||||
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
|
||||
isInitializedRef.current = false;
|
||||
|
||||
// Allow saves after a short delay to skip initial mount callbacks
|
||||
const initTimeout = setTimeout(() => {
|
||||
isInitializedRef.current = true;
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout);
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [initialDataJson]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sync queue processor - ensures only one sync runs at a time
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const processNextSync = useCallback(async () => {
|
||||
// If already syncing or queue is empty, do nothing
|
||||
if (isSyncingRef.current || syncQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSyncingRef.current = true;
|
||||
const task = syncQueueRef.current.shift()!;
|
||||
|
||||
try {
|
||||
if (task.type === "widget") {
|
||||
const formData = new FormData();
|
||||
formData.set("action", task.action);
|
||||
Object.entries(task.data).forEach(([k, v]) => formData.set(k, v));
|
||||
|
||||
const response = await fetch(widgetActionUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to ${task.action} widget: ${errorText}`);
|
||||
}
|
||||
} else if (task.type === "layout") {
|
||||
const formData = new FormData();
|
||||
formData.set("action", "layout");
|
||||
formData.set("layout", JSON.stringify(task.layout));
|
||||
|
||||
// Use current page URL if layoutActionUrl is not provided
|
||||
const url = layoutActionUrl || window.location.pathname;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error("Failed to update layout: " + errorText);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Dashboard sync error:`, error);
|
||||
const actionName = task.type === "widget" ? task.action : "layout";
|
||||
onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName);
|
||||
} finally {
|
||||
isSyncingRef.current = false;
|
||||
// Process next item in queue
|
||||
processNextSync();
|
||||
}
|
||||
}, [widgetActionUrl, layoutActionUrl, onSyncError]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Queue helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const queueWidgetSync = useCallback(
|
||||
(action: string, data: Record<string, string>) => {
|
||||
syncQueueRef.current.push({ type: "widget", action, data });
|
||||
processNextSync();
|
||||
},
|
||||
[processNextSync]
|
||||
);
|
||||
|
||||
const queueLayoutSync = useCallback(
|
||||
(layout: LayoutItem[]) => {
|
||||
// For layout syncs, we only care about the latest state
|
||||
// Remove any pending layout syncs and add the new one
|
||||
syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout");
|
||||
syncQueueRef.current.push({ type: "layout", layout });
|
||||
processNextSync();
|
||||
},
|
||||
[processNextSync]
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Count only non-title widgets for limit checks (title widgets are free)
|
||||
const countedWidgets = Object.values(state.widgets).filter(
|
||||
(w) => w.display.type !== "title"
|
||||
).length;
|
||||
|
||||
const addWidget = useCallback(
|
||||
(title: string, query: string, config: QueryWidgetConfig) => {
|
||||
// Guard: check widget limit (title widgets don't count)
|
||||
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
|
||||
onWidgetLimitReached?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = nanoid(8);
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
|
||||
const widget: Widget = { title, query, display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated ID so the server uses the same ID
|
||||
queueWidgetSync("add", {
|
||||
widgetId: id,
|
||||
title,
|
||||
query,
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
|
||||
);
|
||||
|
||||
const addTitleWidget = useCallback(
|
||||
(title: string) => {
|
||||
const id = nanoid(8);
|
||||
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
|
||||
// Title widgets are fixed at h=2 and full width
|
||||
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
|
||||
const config: QueryWidgetConfig = { type: "title" };
|
||||
const widget: Widget = { title, query: "", display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated ID so the server uses the same ID
|
||||
queueWidgetSync("add", {
|
||||
widgetId: id,
|
||||
title,
|
||||
query: "",
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[state.layout, queueWidgetSync]
|
||||
);
|
||||
|
||||
const updateWidget = useCallback(
|
||||
(widgetId: string, title: string, query: string, config: QueryWidgetConfig) => {
|
||||
const widget: Widget = { title, query, display: config };
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("update", {
|
||||
widgetId,
|
||||
title,
|
||||
query,
|
||||
config: JSON.stringify(config),
|
||||
});
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const deleteWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
// Update local state immediately
|
||||
dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("delete", { widgetId });
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const duplicateWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
// Guard: check widget limit (title widgets don't count)
|
||||
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
|
||||
onWidgetLimitReached?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = nanoid(8);
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
// Send the client-generated newId so the server uses the same ID for the duplicate
|
||||
queueWidgetSync("duplicate", { widgetId, newId });
|
||||
},
|
||||
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
|
||||
);
|
||||
|
||||
const renameWidget = useCallback(
|
||||
(widgetId: string, title: string) => {
|
||||
// Update local state immediately
|
||||
dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } });
|
||||
|
||||
// Queue sync to server (processed sequentially)
|
||||
queueWidgetSync("rename", { widgetId, title });
|
||||
},
|
||||
[queueWidgetSync]
|
||||
);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: LayoutItem[]) => {
|
||||
// Skip if not yet initialized (prevents saving during mount/navigation)
|
||||
if (!isInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newLayoutJson = JSON.stringify(newLayout);
|
||||
|
||||
// Skip if layout hasn't actually changed
|
||||
if (newLayoutJson === currentLayoutJsonRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state immediately
|
||||
dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } });
|
||||
|
||||
// Clear existing debounce timeout
|
||||
if (layoutDebounceRef.current) {
|
||||
clearTimeout(layoutDebounceRef.current);
|
||||
}
|
||||
|
||||
// Debounce before queueing - this ensures rapid layout changes
|
||||
// (like dragging) don't queue up many requests
|
||||
layoutDebounceRef.current = setTimeout(() => {
|
||||
currentLayoutJsonRef.current = newLayoutJson;
|
||||
// Queue layout sync (replaces any pending layout sync in queue)
|
||||
queueLayoutSync(newLayout);
|
||||
}, 500);
|
||||
},
|
||||
[queueLayoutSync]
|
||||
);
|
||||
|
||||
const openAddEditor = useCallback(() => {
|
||||
dispatch({ type: "OPEN_ADD_EDITOR" });
|
||||
}, []);
|
||||
|
||||
const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => {
|
||||
dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } });
|
||||
}, []);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
dispatch({ type: "CLOSE_EDITOR" });
|
||||
}, []);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Return value
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
/** Current dashboard state */
|
||||
state,
|
||||
/** Action dispatchers */
|
||||
actions: {
|
||||
addWidget,
|
||||
addTitleWidget,
|
||||
updateWidget,
|
||||
renameWidget,
|
||||
deleteWidget,
|
||||
duplicateWidget,
|
||||
updateLayout,
|
||||
openAddEditor,
|
||||
openEditEditor,
|
||||
closeEditor,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* A function that you call with a debounce delay, the function will only be called after the delay has passed
|
||||
*
|
||||
* @param fn The function to debounce
|
||||
* @param delay In ms
|
||||
*/
|
||||
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
fn(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that takes in a value, function, and delay.
|
||||
* It will run the function with the debounced value, only if the value has changed.
|
||||
* It should deal with the function being passed in not being a useCallback
|
||||
*/
|
||||
export function useDebounceEffect<T>(value: T, fn: (value: T) => void, delay: number) {
|
||||
const fnRef = useRef(fn);
|
||||
|
||||
// Update the ref whenever the function changes
|
||||
fnRef.current = fn;
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
fnRef.current(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [value, delay]); // Only depend on value and delay, not fn
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UseElementVisibilityOptions = {
|
||||
onVisibilityChange?: (isVisible: boolean) => void;
|
||||
};
|
||||
|
||||
export function useElementVisibility({ onVisibilityChange }: UseElementVisibilityOptions = {}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisibleRef = useRef(false);
|
||||
const callbackRef = useRef(onVisibilityChange);
|
||||
callbackRef.current = onVisibilityChange;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
const nowVisible = entry.isIntersecting;
|
||||
if (isVisibleRef.current !== nowVisible) {
|
||||
isVisibleRef.current = nowVisible;
|
||||
callbackRef.current?.(nowVisible);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, isVisibleRef };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { type UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedEnvironment = UseDataFunctionReturn<typeof orgLoader>["environment"];
|
||||
|
||||
export function useOptionalEnvironment(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.environment;
|
||||
}
|
||||
|
||||
export function useEnvironment(matches?: UIMatch[]) {
|
||||
const environment = useOptionalEnvironment(matches);
|
||||
invariant(environment, "Environment must be defined");
|
||||
return environment;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type Path, useMatches } from "@remix-run/react";
|
||||
import { type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
|
||||
/**
|
||||
* It gives the URLs for the current page for other environments
|
||||
* @returns
|
||||
*/
|
||||
export function useEnvironmentSwitcher() {
|
||||
const matches = useMatches();
|
||||
const location = useOptimisticLocation();
|
||||
|
||||
const urlForEnvironment = (newEnvironment: Pick<RuntimeEnvironment, "id" | "slug">) => {
|
||||
return routeForEnvironmentSwitch({
|
||||
location,
|
||||
matchId: matches[matches.length - 1].id,
|
||||
environmentSlug: newEnvironment.slug,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
urlForEnvironment,
|
||||
};
|
||||
}
|
||||
|
||||
/** Function that takes in a UIMatch id, the current URL, the new environment slug, and returns a new URL */
|
||||
export function routeForEnvironmentSwitch({
|
||||
location,
|
||||
matchId,
|
||||
environmentSlug,
|
||||
}: {
|
||||
location: Path;
|
||||
matchId: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
switch (matchId) {
|
||||
// Run page
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/runs\/.*/,
|
||||
"/runs"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/deployments\/.*/,
|
||||
"/deployments"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam":
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/schedules\/.*/,
|
||||
"/schedules"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
default: {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug),
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the /env/<slug>/ in the path so it's /env/<environmentSlug>
|
||||
*/
|
||||
function replaceEnvInPath(path: string, environmentSlug: string) {
|
||||
//allow anything except /
|
||||
return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`);
|
||||
}
|
||||
|
||||
function fullPath(location: Path) {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIMatch } from "@remix-run/react";
|
||||
import type { MatchedProject } from "./useProject";
|
||||
import { useOptionalProject } from "./useProject";
|
||||
|
||||
export type ProjectJobEnvironment = MatchedProject["environments"][number];
|
||||
|
||||
export function useEnvironments(matches?: UIMatch[]) {
|
||||
const project = useOptionalProject(matches);
|
||||
if (!project) return;
|
||||
|
||||
return project.environments;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type EventSourceOptions = {
|
||||
init?: EventSourceInit;
|
||||
event?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to an event source and return the latest event.
|
||||
* @param url The URL of the event source to connect to
|
||||
* @param options The options to pass to the EventSource constructor
|
||||
* @returns The last event received from the server
|
||||
*/
|
||||
export function useEventSource(
|
||||
url: string | URL,
|
||||
{ event = "message", init, disabled }: EventSourceOptions = {}
|
||||
) {
|
||||
const [data, setData] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset data if dependencies change
|
||||
setData(null);
|
||||
|
||||
const eventSource = new EventSource(url, init);
|
||||
eventSource.addEventListener(event ?? "message", handler);
|
||||
|
||||
function handler(event: MessageEvent) {
|
||||
setData(event.data || "UNKNOWN_EVENT_DATA");
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener(event ?? "message", handler);
|
||||
eventSource.close();
|
||||
};
|
||||
}, [url, event, init, disabled]);
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { extractDomain, faviconUrl } from "~/utils/favicon";
|
||||
|
||||
function resolve(input: string, size: number): string | null {
|
||||
const domain = extractDomain(input);
|
||||
return domain && domain.includes(".") ? faviconUrl(domain, size) : null;
|
||||
}
|
||||
|
||||
export function useFaviconUrl(urlInput: string, size: number = 64) {
|
||||
const [url, setUrl] = useState<string | null>(() => resolve(urlInput, size));
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setUrl(resolve(urlInput, size));
|
||||
}, 400);
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [urlInput, size]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { useOptionalOrganization } from "./useOrganizations";
|
||||
|
||||
/**
|
||||
* Hook to access organization-level feature flags.
|
||||
* Returns the feature flags from the current organization, or an empty object if no organization is found.
|
||||
*/
|
||||
export function useFeatureFlags(matches?: UIMatch[]) {
|
||||
const org = useOptionalOrganization(matches);
|
||||
return org?.featureFlags ?? {};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { type loader } from "../root";
|
||||
import type { TriggerFeatures } from "~/features.server";
|
||||
|
||||
export function useFeatures(): TriggerFeatures {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch?.features ?? { isManagedCloud: false, hasPrivateConnections: false };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
|
||||
/**
|
||||
* A hook that provides fuzzy filtering functionality for a list of objects.
|
||||
* Uses match-sorter to perform the filtering across multiple object properties and
|
||||
* consistently order the results by score.
|
||||
*
|
||||
* @param params - The parameters object
|
||||
* @param params.items - Array of objects to filter
|
||||
* @param params.keys - Array of object keys to perform the fuzzy search on (supports dot-notation for nested properties)
|
||||
* @returns An object containing:
|
||||
* - filterText: The current filter text (the controlled value if provided, otherwise the internal state)
|
||||
* - setFilterText: Updates the internal filter text. No-op when `filterText` is provided
|
||||
* (controlled mode) — the parent owns the value in that case.
|
||||
* - filteredItems: The filtered array of items based on the current filter text
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const users = [{ name: "John", email: "john@example.com" }];
|
||||
* const { filterText, setFilterText, filteredItems } = useFuzzyFilter({
|
||||
* items: users,
|
||||
* keys: ["name", "email"]
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useFuzzyFilter<T extends object>({
|
||||
items,
|
||||
keys,
|
||||
filterText: controlledFilterText,
|
||||
}: {
|
||||
items: T[];
|
||||
keys: (Extract<keyof T, string> | (string & {}))[];
|
||||
/** Optional controlled filter text. If provided, internal state is ignored. */
|
||||
filterText?: string;
|
||||
}) {
|
||||
const [internalFilterText, setInternalFilterText] = useState("");
|
||||
const filterText = controlledFilterText ?? internalFilterText;
|
||||
|
||||
const filteredItems = useMemo<T[]>(() => {
|
||||
const filterTerms = filterText
|
||||
.trim()
|
||||
.split(" ")
|
||||
.map((term) => term.trim())
|
||||
.filter((term) => term !== "");
|
||||
|
||||
if (filterTerms.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return filterTerms.reduceRight(
|
||||
(results, term) =>
|
||||
matchSorter(results, term, {
|
||||
keys,
|
||||
}),
|
||||
items
|
||||
);
|
||||
}, [items, filterText]);
|
||||
|
||||
return {
|
||||
filterText,
|
||||
setFilterText: setInternalFilterText,
|
||||
filteredItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useInitialDimensions(ref: React.RefObject<HTMLElement>) {
|
||||
const [dimensions, setDimensions] = useState<DOMRectReadOnly | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
setDimensions(ref.current.getBoundingClientRect());
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type UseIntervalOptions = {
|
||||
/** If passed, will refresh every interval MS */
|
||||
interval?: number;
|
||||
onLoad?: boolean;
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Skip interval ticks while the document tab is hidden */
|
||||
pauseWhenHidden?: boolean;
|
||||
callback: () => void;
|
||||
};
|
||||
|
||||
export function useInterval({
|
||||
interval,
|
||||
onLoad = true,
|
||||
onFocus = true,
|
||||
disabled = false,
|
||||
pauseWhenHidden = false,
|
||||
callback,
|
||||
}: UseIntervalOptions) {
|
||||
// Always keep the latest callback in a ref so the effects below
|
||||
// never close over a stale version.
|
||||
const latestCallback = useRef(callback);
|
||||
useEffect(() => {
|
||||
latestCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// On interval
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (pauseWhenHidden && document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
latestCallback.current();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled, pauseWhenHidden]);
|
||||
|
||||
// On focus
|
||||
useEffect(() => {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
latestCallback.current();
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate when the page becomes visible
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
// Revalidate when the window gains focus
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [onFocus, disabled]);
|
||||
|
||||
// On load
|
||||
useEffect(() => {
|
||||
if (disabled || !onLoad) return;
|
||||
latestCallback.current();
|
||||
}, [disabled, onLoad]);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
const useLazyRef = <T>(initialValFunc: () => T) => {
|
||||
const ref: MutableRefObject<T | null> = useRef(null);
|
||||
if (ref.current === null) {
|
||||
ref.current = initialValFunc();
|
||||
}
|
||||
return ref;
|
||||
};
|
||||
|
||||
export default useLazyRef;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useLocation, useNavigation, useResolvedPath } from "@remix-run/react";
|
||||
import type { RelativeRoutingType } from "@remix-run/router";
|
||||
|
||||
//A lot of this logic is lifted from <NavLink> in react-router-dom, thanks again Remix team ❤️.
|
||||
//https://github.com/remix-run/react-router/blob/a04ae6b90127ae583be08432c52b951e53f6a3c7/packages/react-router-dom/index.tsx#L1010
|
||||
|
||||
type Options = {
|
||||
/** Defines the relative path behavior for the link.
|
||||
*
|
||||
* route - default, relative to the route hierarchy so .. will remove all URL segments of the current route pattern
|
||||
*
|
||||
* path - relative to the path so .. will remove one URL segment
|
||||
*/
|
||||
relative?: RelativeRoutingType;
|
||||
/** The end prop changes the matching logic for the active and pending states to only match to the "end" of the NavLinks's to path. If the URL is longer than to, it will no longer be considered active. */
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
type Result = {
|
||||
isActive: boolean;
|
||||
isPending: boolean;
|
||||
isTransitioning: boolean;
|
||||
};
|
||||
|
||||
/** Pass a relative link and you will get back whether it's the current page, about to be and whether the route is currently changing */
|
||||
export function useLinkStatus(to: string, options?: Options): Result {
|
||||
const { relative, end = false } = options || {};
|
||||
|
||||
const path = useResolvedPath(to, { relative: relative });
|
||||
const pathName = path.pathname.toLowerCase();
|
||||
|
||||
//current location and pending location (if there is one)
|
||||
const location = useLocation();
|
||||
const locationPathname = location.pathname.toLowerCase();
|
||||
const navigation = useNavigation();
|
||||
const nextLocationPathname = navigation.location
|
||||
? navigation.location.pathname.toLowerCase()
|
||||
: null;
|
||||
|
||||
// If the `to` has a trailing slash, look at that exact spot. Otherwise,
|
||||
// we're looking for a slash _after_ what's in `to`. For example:
|
||||
//
|
||||
// <NavLink to="/users"> and <NavLink to="/users/">
|
||||
// both want to look for a / at index 6 to match URL `/users/matt`
|
||||
const endSlashPosition =
|
||||
pathName !== "/" && pathName.endsWith("/") ? pathName.length - 1 : pathName.length;
|
||||
|
||||
const isActive =
|
||||
locationPathname === pathName ||
|
||||
(!end &&
|
||||
locationPathname.startsWith(pathName) &&
|
||||
locationPathname.charAt(endSlashPosition) === "/");
|
||||
|
||||
const isPending =
|
||||
nextLocationPathname != null &&
|
||||
(nextLocationPathname === pathName ||
|
||||
(!end &&
|
||||
nextLocationPathname.startsWith(pathName) &&
|
||||
nextLocationPathname.charAt(pathName.length) === "/"));
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isPending,
|
||||
isTransitioning: navigation.state === "loading",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Reducer } from "react";
|
||||
import { useReducer } from "react";
|
||||
|
||||
export type ListState<T> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type AppendAction<T> = {
|
||||
type: "append";
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type UpdateAction<T> = {
|
||||
type: "update";
|
||||
index: number;
|
||||
item: T;
|
||||
};
|
||||
|
||||
type DeleteAction<_T> = {
|
||||
type: "delete";
|
||||
index: number;
|
||||
};
|
||||
|
||||
type InsertAfter<T> = {
|
||||
type: "insertAfter";
|
||||
index: number;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type Action<T> = AppendAction<T> | UpdateAction<T> | DeleteAction<T> | InsertAfter<T>;
|
||||
|
||||
function reducer<T>(state: ListState<T>, action: Action<T>): ListState<T> {
|
||||
switch (action.type) {
|
||||
case "append":
|
||||
return { items: [...state.items, ...action.items] };
|
||||
case "update":
|
||||
return {
|
||||
items: state.items.map((v, i) => (i === action.index ? action.item : v)),
|
||||
};
|
||||
case "delete":
|
||||
return { items: state.items.filter((_, i) => i !== action.index) };
|
||||
case "insertAfter":
|
||||
return {
|
||||
items: [
|
||||
...state.items.slice(0, action.index + 1),
|
||||
...action.items,
|
||||
...state.items.slice(action.index + 1),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type HookReturn<T> = {
|
||||
items: T[];
|
||||
append: (items: T[]) => void;
|
||||
update: (index: number, item: T) => void;
|
||||
delete: (index: number) => void;
|
||||
insertAfter: (index: number, items: T[]) => void;
|
||||
};
|
||||
|
||||
export function useList<T>(initialItems: T[]): HookReturn<T> {
|
||||
const [state, dispatch] = useReducer<Reducer<ListState<T>, Action<T>>>(reducer, {
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
return {
|
||||
items: state.items,
|
||||
append: (items: T[]) => dispatch({ type: "append", items }),
|
||||
update: (index: number, item: T) => dispatch({ type: "update", index, item }),
|
||||
delete: (index: number) => dispatch({ type: "delete", index }),
|
||||
insertAfter: (index: number, items: T[]) => dispatch({ type: "insertAfter", index, items }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
|
||||
export function useOptimisticLocation() {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
|
||||
if (navigation.state === "idle" || !navigation.location) {
|
||||
return location;
|
||||
}
|
||||
|
||||
return navigation.location;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { UIMatch } from "@remix-run/react";
|
||||
import type { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
|
||||
export const organizationMatchId = "routes/_app.orgs.$organizationSlug";
|
||||
|
||||
export function useOptionalOrganizations(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.organizations;
|
||||
}
|
||||
|
||||
export function useOrganizations(matches?: UIMatch[]) {
|
||||
const orgs = useOptionalOrganizations(matches);
|
||||
invariant(orgs, "No organizations found in loader.");
|
||||
return orgs;
|
||||
}
|
||||
|
||||
export function useOptionalOrganization(matches?: UIMatch[]) {
|
||||
const orgs = useOptionalOrganizations(matches);
|
||||
const org = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
|
||||
if (!orgs || !org || !org.organization) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return orgs.find((o) => o.id === org.organization.id);
|
||||
}
|
||||
|
||||
export function useOrganization(matches?: UIMatch[]) {
|
||||
const org = useOptionalOrganization(matches);
|
||||
invariant(org, "No organization found in loader.");
|
||||
return org;
|
||||
}
|
||||
|
||||
export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean {
|
||||
const data = useTypedMatchesData<any>({
|
||||
id: "routes/_app.orgs.new",
|
||||
matches,
|
||||
});
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export const useOrganizationChanged = (action: (org: MatchedOrganization | undefined) => void) => {
|
||||
useChanged(useOptionalOrganization, action);
|
||||
};
|
||||
|
||||
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.isImpersonating === true;
|
||||
}
|
||||
|
||||
export type CustomDashboard = UseDataFunctionReturn<typeof orgLoader>["customDashboards"][number];
|
||||
|
||||
export function useCustomDashboards(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.customDashboards ?? [];
|
||||
}
|
||||
|
||||
export function useDashboardLimits(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.dashboardLimits ?? { used: 0, limit: 3 };
|
||||
}
|
||||
|
||||
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.widgetLimitPerDashboard ?? 16;
|
||||
}
|
||||
|
||||
export function useBillingLimit(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.billingLimit;
|
||||
}
|
||||
|
||||
export function useCanManageBillingLimits(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.canManageBillingLimits === true;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
|
||||
export function usePathName(preemptive = true) {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
|
||||
if (!preemptive || navigation.state === "idle" || !navigation.location) {
|
||||
return location.pathname;
|
||||
}
|
||||
|
||||
return navigation.location.pathname;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useOrganizationChanged } from "./useOrganizations";
|
||||
import { useOptionalUser, useUserChanged } from "./useUser";
|
||||
import { useProjectChanged } from "./useProject";
|
||||
|
||||
export const usePostHog = (
|
||||
apiKey?: string,
|
||||
uiHost?: string,
|
||||
logging = false,
|
||||
debug = false
|
||||
): void => {
|
||||
const postHogInitialized = useRef(false);
|
||||
const location = useLocation();
|
||||
const user = useOptionalUser();
|
||||
|
||||
//start PostHog once
|
||||
useEffect(() => {
|
||||
if (apiKey === undefined || apiKey === "") return;
|
||||
if (postHogInitialized.current === true) return;
|
||||
if (logging) console.log("Initializing PostHog");
|
||||
posthog.init(apiKey, {
|
||||
// Same-origin first-party proxy (see app/routes/ph.$.ts) that forwards to
|
||||
// PostHog Cloud EU server-side.
|
||||
api_host: "/ph",
|
||||
// Point the toolbar at the real PostHog UI; without it, it falls back to /ph.
|
||||
ui_host: uiHost,
|
||||
cross_subdomain_cookie: true,
|
||||
opt_in_site_apps: true,
|
||||
debug,
|
||||
loaded: function (posthog) {
|
||||
if (logging) console.log("PostHog loaded");
|
||||
if (user !== undefined) {
|
||||
if (logging) console.log("Loaded: Identifying user", user);
|
||||
posthog.identify(user.id, { email: user.email });
|
||||
}
|
||||
},
|
||||
});
|
||||
postHogInitialized.current = true;
|
||||
}, [apiKey, uiHost, logging, user]);
|
||||
|
||||
useUserChanged((user) => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
if (logging) console.log("User changed");
|
||||
if (user) {
|
||||
if (logging) console.log("Identifying user", user);
|
||||
posthog.identify(user.id, { email: user.email });
|
||||
} else {
|
||||
if (logging) console.log("Resetting user");
|
||||
posthog.reset();
|
||||
}
|
||||
});
|
||||
|
||||
useOrganizationChanged((org) => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
if (org) {
|
||||
if (logging) console.log(`Grouping by organization`, org);
|
||||
posthog.group("organization", org.id);
|
||||
} else {
|
||||
//reset the groups when you go to one of the top-level pages
|
||||
if (logging) console.log("Resetting groups");
|
||||
posthog.resetGroups();
|
||||
}
|
||||
});
|
||||
|
||||
useProjectChanged((project) => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
if (project) {
|
||||
if (logging) console.log(`Grouping by project`, project);
|
||||
posthog.group("project", project.id);
|
||||
}
|
||||
});
|
||||
|
||||
//page view
|
||||
useEffect(() => {
|
||||
if (postHogInitialized.current === false) return;
|
||||
posthog.capture("$pageview");
|
||||
}, [location, logging]);
|
||||
};
|
||||
|
||||
export function usePostHogTracking() {
|
||||
const capture = useCallback((eventName: string, properties?: Record<string, unknown>) => {
|
||||
posthog.capture(eventName, properties);
|
||||
}, []);
|
||||
|
||||
const startSessionRecording = useCallback(() => {
|
||||
posthog.startSessionRecording();
|
||||
}, []);
|
||||
|
||||
return { capture, startSessionRecording };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { type UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
|
||||
export type MatchedProject = UseDataFunctionReturn<typeof orgLoader>["project"];
|
||||
|
||||
export function useOptionalProject(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.project;
|
||||
}
|
||||
|
||||
export function useProject(matches?: UIMatch[]) {
|
||||
const project = useOptionalProject(matches);
|
||||
invariant(project, "Project must be defined");
|
||||
return project;
|
||||
}
|
||||
|
||||
export const useProjectChanged = (action: (org: MatchedProject | undefined) => void) => {
|
||||
useChanged(useOptionalProject, action);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { type UseDataFunctionReturn } from "remix-typedjson";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedRegion = UseDataFunctionReturn<typeof orgLoader>["regions"][number];
|
||||
|
||||
export function useRegions(matches?: UIMatch[]): MatchedRegion[] {
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.regions ?? [];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type NavigateOptions = {
|
||||
replace?: boolean;
|
||||
preventScrollReset?: boolean;
|
||||
};
|
||||
|
||||
export function useReplaceSearchParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const replaceSearchParam = useCallback(
|
||||
(key: string, value?: string, navigateOpts?: NavigateOptions) => {
|
||||
setSearchParams((s) => {
|
||||
if (value) {
|
||||
s.set(key, value);
|
||||
} else {
|
||||
s.delete(key);
|
||||
}
|
||||
return s;
|
||||
}, navigateOpts);
|
||||
},
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
return { searchParams, setSearchParams, replaceSearchParam };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRevalidator, useSearchParams } from "@remix-run/react";
|
||||
|
||||
type UseRevalidateOnParamOptions = {
|
||||
/** The query param(s) that trigger revalidation */
|
||||
param: string | string[];
|
||||
/** Callback fired when revalidation is triggered */
|
||||
onRevalidate?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that triggers revalidation when specific query params are present,
|
||||
* then removes those params from the URL.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* // Revalidate when ?_revalidate is present
|
||||
* useRevalidateOnParam({ param: "_revalidate" });
|
||||
*
|
||||
* // With callback to close a modal
|
||||
* useRevalidateOnParam({
|
||||
* param: "_revalidate",
|
||||
* onRevalidate: () => setEditorMode(null),
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* The redirect should include the param:
|
||||
* ```ts
|
||||
* return redirect(`${dashboardPath}?_revalidate=${Date.now()}`);
|
||||
* ```
|
||||
*/
|
||||
export function useRevalidateOnParam({ param, onRevalidate }: UseRevalidateOnParamOptions) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const paramArray = Array.isArray(param) ? param : [param];
|
||||
|
||||
useEffect(() => {
|
||||
// Check if any of the trigger params are present
|
||||
const hasParam = paramArray.some((p) => searchParams.has(p));
|
||||
|
||||
if (hasParam) {
|
||||
// Trigger revalidation
|
||||
revalidator.revalidate();
|
||||
|
||||
// Call the callback if provided
|
||||
onRevalidate?.();
|
||||
|
||||
// Remove the trigger params from the URL
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
paramArray.forEach((p) => newParams.delete(p));
|
||||
|
||||
// Update URL without the params (replace to avoid adding to history)
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}, [searchParams, setSearchParams, revalidator, paramArray, onRevalidate]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Scroll a page body container back to the top when navigating to a route. */
|
||||
export function useScrollContainerToTop<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.scrollTo(0, 0);
|
||||
}, [location.key]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type Values = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function useSearchParams() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
|
||||
const replace = useCallback(
|
||||
(values: Values) => {
|
||||
const s = set(new URLSearchParams(location.search), values);
|
||||
navigate(`${location.pathname}?${s.toString()}`, { replace: true });
|
||||
},
|
||||
[location, navigate]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
(keys: string | string[]) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
for (const key of keys) {
|
||||
search.delete(key);
|
||||
}
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
},
|
||||
[location, navigate]
|
||||
);
|
||||
|
||||
const value = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
return search.get(param) ?? undefined;
|
||||
},
|
||||
[location]
|
||||
);
|
||||
|
||||
const values = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
return search.getAll(param);
|
||||
},
|
||||
[location]
|
||||
);
|
||||
|
||||
const has = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
return search.has(param);
|
||||
},
|
||||
[location]
|
||||
);
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
replace,
|
||||
del,
|
||||
has,
|
||||
};
|
||||
}
|
||||
|
||||
function set(searchParams: URLSearchParams, values: Values) {
|
||||
const search = new URLSearchParams(searchParams);
|
||||
for (const [param, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
search.delete(param);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
search.set(param, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
search.delete(param);
|
||||
for (const v of value) {
|
||||
search.append(param, v);
|
||||
}
|
||||
}
|
||||
|
||||
return search;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider";
|
||||
import { useShortcuts } from "~/components/primitives/ShortcutsProvider";
|
||||
|
||||
export type Modifier = "alt" | "ctrl" | "meta" | "shift" | "mod";
|
||||
|
||||
export type Shortcut = {
|
||||
key: string;
|
||||
modifiers?: Modifier[];
|
||||
enabledOnInputElements?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShortcutDefinition =
|
||||
| {
|
||||
windows: Shortcut;
|
||||
mac: Shortcut;
|
||||
}
|
||||
| Shortcut;
|
||||
|
||||
type useShortcutKeysProps = {
|
||||
shortcut: ShortcutDefinition | undefined;
|
||||
action: (event: KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
enabledOnInputElements?: boolean;
|
||||
};
|
||||
|
||||
export function useShortcutKeys({
|
||||
shortcut,
|
||||
action,
|
||||
disabled = false,
|
||||
enabledOnInputElements,
|
||||
}: useShortcutKeysProps) {
|
||||
const { platform } = useOperatingSystem();
|
||||
const { areShortcutsEnabled } = useShortcuts();
|
||||
const isMac = platform === "mac";
|
||||
const relevantShortcut =
|
||||
shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
|
||||
const keys = createKeysFromShortcut(relevantShortcut);
|
||||
|
||||
const isEnabled = !disabled && areShortcutsEnabled && relevantShortcut?.enabled !== false;
|
||||
|
||||
useHotkeys(
|
||||
keys,
|
||||
(event) => {
|
||||
if (!event.repeat) {
|
||||
action(event);
|
||||
}
|
||||
},
|
||||
{
|
||||
enabled: isEnabled,
|
||||
enableOnFormTags:
|
||||
isEnabled && (enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements),
|
||||
enableOnContentEditable:
|
||||
isEnabled && (enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createKeysFromShortcut(shortcut: Shortcut | undefined) {
|
||||
if (!shortcut) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modifiers = shortcut.modifiers;
|
||||
const character = shortcut.key;
|
||||
|
||||
return modifiers ? modifiers.map((k) => k).join("+") + "+" + character : character;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
|
||||
/** Whether the org should see self-serve billing UI (plan picker, Stripe checkout, upgrades). */
|
||||
export function useShowSelfServe(): boolean {
|
||||
const plan = useCurrentPlan();
|
||||
return plan?.v3Subscription?.showSelfServe ?? true;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type TextFilterProps<T> = {
|
||||
defaultValue?: string;
|
||||
items: T[];
|
||||
filter: (item: T, filterText: string) => boolean;
|
||||
};
|
||||
|
||||
export function useTextFilter<T>({ defaultValue = "", items, filter }: TextFilterProps<T>) {
|
||||
const [filterText, setFilterText] = useState(defaultValue);
|
||||
|
||||
const filteredItems = useMemo<T[]>(() => {
|
||||
if (filterText === "") {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => {
|
||||
return filter(item, filterText);
|
||||
});
|
||||
}, [items, filterText]);
|
||||
|
||||
return {
|
||||
filterText,
|
||||
setFilterText,
|
||||
filteredItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Normalize any CSS color (hex, oklch, hsl, ...) to rgb()/rgba() by rendering
|
||||
* it to a 1x1 canvas. framer-motion can only interpolate hex/rgb/hsl, while
|
||||
* Tailwind v4's default palette is oklch.
|
||||
*/
|
||||
function toRgb(color: string): string {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a / 255})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a theme CSS variable to a concrete, animatable color once on mount.
|
||||
* framer-motion can't interpolate `var()` strings or oklch values, so animated
|
||||
* colors must be resolved and normalized first. The fallback is used during
|
||||
* SSR and should match the default dark theme (see tailwind.css).
|
||||
*/
|
||||
export function useThemeColor(variable: `--${string}`, fallback: string): string {
|
||||
const [color] = useState(() => {
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
||||
return value ? toRgb(value) : fallback;
|
||||
});
|
||||
return color;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useThrottle(fn: (...args: any[]) => void, duration: number) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Clean up when the component is unmounted
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) clearTimeout(timeout.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (...args: Parameters<typeof fn>) => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
|
||||
timeout.current = setTimeout(() => {
|
||||
fn(...args);
|
||||
timeout.current = undefined;
|
||||
}, duration);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type ToggleFilterProps<T> = {
|
||||
items: T[];
|
||||
filter: (item: T, isToggleActive: boolean) => boolean;
|
||||
defaultValue?: boolean;
|
||||
};
|
||||
|
||||
export function useToggleFilter<T>({ items, filter, defaultValue = false }: ToggleFilterProps<T>) {
|
||||
const [isToggleActive, setToggleActive] = useState(defaultValue);
|
||||
|
||||
const filteredItems = useMemo<T[]>(() => {
|
||||
return items.filter((item) => filter(item, isToggleActive));
|
||||
}, [items, isToggleActive]);
|
||||
|
||||
return {
|
||||
isToggleActive,
|
||||
setToggleActive,
|
||||
filteredItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { type loader } from "~/root";
|
||||
|
||||
export function useTriggerCliTag() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch!.triggerCliTag;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { UIMatch } from "@remix-run/react";
|
||||
import { useMatches } from "@remix-run/react";
|
||||
import type { RemixSerializedType, UseDataFunctionReturn } from "remix-typedjson";
|
||||
import { deserializeRemix } from "remix-typedjson";
|
||||
|
||||
type AppData = any;
|
||||
|
||||
function useTypedDataFromMatches<T = AppData>({
|
||||
id,
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
const match = matches.find((m) => m.id === id);
|
||||
return useTypedMatchData<T>(match);
|
||||
}
|
||||
|
||||
export function useTypedMatchesData<T = AppData>({
|
||||
id,
|
||||
matches,
|
||||
}: {
|
||||
id: string;
|
||||
matches?: UIMatch[];
|
||||
}): UseDataFunctionReturn<T> | undefined {
|
||||
if (!matches) {
|
||||
matches = useMatches();
|
||||
}
|
||||
|
||||
return useTypedDataFromMatches<T>({ id, matches });
|
||||
}
|
||||
|
||||
export function useTypedMatchData<T = AppData>(
|
||||
match: UIMatch | undefined
|
||||
): UseDataFunctionReturn<T> | undefined {
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return deserializeRemix<T>(match.data as RemixSerializedType<T>) as
|
||||
| UseDataFunctionReturn<T>
|
||||
| undefined;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import type { UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { type loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { useIsImpersonating } from "./useOrganizations";
|
||||
|
||||
export type User = UserWithDashboardPreferences;
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.user ?? undefined;
|
||||
}
|
||||
|
||||
export function useUser(matches?: UIMatch[]): User {
|
||||
const maybeUser = useOptionalUser(matches);
|
||||
if (!maybeUser) {
|
||||
throw new Error(
|
||||
"No user found in root loader, but user is required by useUser. If user is optional, try useOptionalUser instead."
|
||||
);
|
||||
}
|
||||
return maybeUser;
|
||||
}
|
||||
|
||||
export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||
useChanged(useOptionalUser, callback);
|
||||
}
|
||||
|
||||
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||
const user = useOptionalUser(matches);
|
||||
const isImpersonating = useIsImpersonating(matches);
|
||||
|
||||
return Boolean(user?.admin) || isImpersonating;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useCallback } from "react";
|
||||
import { type ChartZoomRange } from "~/components/primitives/charts/ChartSyncContext";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
/**
|
||||
* `onZoom` handler that sets the Time/Date filter to the dragged range, the same
|
||||
* way TimeFilter applies a custom range: epoch-ms `from`/`to`, clearing `period`
|
||||
* and pagination so the page reloads scoped to that window.
|
||||
*/
|
||||
export function useZoomToTimeFilter() {
|
||||
const { replace } = useSearchParams();
|
||||
|
||||
return useCallback(
|
||||
(range: ChartZoomRange) => {
|
||||
replace({
|
||||
period: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
from: Math.round(range.start).toString(),
|
||||
to: Math.round(range.end).toString(),
|
||||
});
|
||||
},
|
||||
[replace]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user