@checkstack/command-frontend 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # @checkstack/command-frontend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7a23261: ## TanStack Query Integration
8
+
9
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
10
+
11
+ ### New Features
12
+
13
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
14
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
15
+ - **Built-in caching**: Configurable stale time and cache duration per query
16
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
17
+ - **Background refetching**: Stale data is automatically refreshed when components mount
18
+
19
+ ### Contract Changes
20
+
21
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
22
+
23
+ ```typescript
24
+ const getItems = proc()
25
+ .meta({ operationType: "query", access: [access.read] })
26
+ .output(z.array(itemSchema))
27
+ .query();
28
+
29
+ const createItem = proc()
30
+ .meta({ operationType: "mutation", access: [access.manage] })
31
+ .input(createItemSchema)
32
+ .output(itemSchema)
33
+ .mutation();
34
+ ```
35
+
36
+ ### Migration
37
+
38
+ ```typescript
39
+ // Before (forPlugin pattern)
40
+ const api = useApi(myPluginApiRef);
41
+ const [items, setItems] = useState<Item[]>([]);
42
+ useEffect(() => {
43
+ api.getItems().then(setItems);
44
+ }, [api]);
45
+
46
+ // After (usePluginClient pattern)
47
+ const client = usePluginClient(MyPluginApi);
48
+ const { data: items, isLoading } = client.getItems.useQuery({});
49
+ ```
50
+
51
+ ### Bug Fixes
52
+
53
+ - Fixed `rpc.test.ts` test setup for middleware type inference
54
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
55
+ - Fixed null→undefined warnings in notification and queue frontends
56
+
57
+ ### Patch Changes
58
+
59
+ - Updated dependencies [7a23261]
60
+ - @checkstack/frontend-api@0.2.0
61
+ - @checkstack/common@0.3.0
62
+ - @checkstack/command-common@0.2.0
63
+ - @checkstack/ui@0.2.1
64
+
3
65
  ## 0.1.0
4
66
 
5
67
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/command-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -24,7 +24,12 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
24
24
  }) => {
25
25
  const navigate = useNavigate();
26
26
  const formatShortcut = useFormatShortcut();
27
- const { results, loading, search, reset } = useDebouncedSearch(300);
27
+ const {
28
+ results,
29
+ loading,
30
+ setQuery: setSearchQuery,
31
+ reset,
32
+ } = useDebouncedSearch(300);
28
33
 
29
34
  const [query, setQuery] = useState("");
30
35
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -34,7 +39,7 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
34
39
  // Trigger search when dialog opens or query changes
35
40
  useEffect(() => {
36
41
  if (open) {
37
- search(query);
42
+ setSearchQuery(query);
38
43
  // Focus input after dialog opens
39
44
  setTimeout(() => inputRef.current?.focus(), 50);
40
45
  } else {
@@ -43,7 +48,7 @@ export const SearchDialog: React.FC<SearchDialogProps> = ({
43
48
  setSelectedIndex(0);
44
49
  reset();
45
50
  }
46
- }, [open, query, search, reset]);
51
+ }, [open, query, setSearchQuery, reset]);
47
52
 
48
53
  // Group results by category
49
54
  const groupedResults: Record<string, SearchResult[]> = {};
package/src/index.tsx CHANGED
@@ -1,17 +1,14 @@
1
- import { useEffect, useCallback, useMemo, useState, useRef } from "react";
1
+ import { useEffect, useCallback, useMemo, useState } from "react";
2
2
  import {
3
- useApi,
4
- rpcApiRef,
5
- createApiRef,
6
3
  createFrontendPlugin,
7
4
  NavbarCenterSlot,
5
+ usePluginClient,
8
6
  } from "@checkstack/frontend-api";
9
7
  import {
10
8
  CommandApi,
11
9
  pluginMetadata,
12
10
  type SearchResult,
13
11
  } from "@checkstack/command-common";
14
- import type { InferClient } from "@checkstack/common";
15
12
  import { NavbarSearch } from "./components/NavbarSearch";
16
13
 
17
14
  // =============================================================================
@@ -30,15 +27,6 @@ export const commandPlugin = createFrontendPlugin({
30
27
  ],
31
28
  });
32
29
 
33
- // =============================================================================
34
- // API REF
35
- // =============================================================================
36
-
37
- export type CommandApiClient = InferClient<typeof CommandApi>;
38
-
39
- export const commandApiRef =
40
- createApiRef<CommandApiClient>("plugin.command.api");
41
-
42
30
  // =============================================================================
43
31
  // SHORTCUT UTILITIES (Frontend-only - requires DOM types)
44
32
  // =============================================================================
@@ -107,6 +95,27 @@ export function formatShortcut(shortcut: string, isMac: boolean): string {
107
95
  return isMac ? parts.join("") : parts.join("+");
108
96
  }
109
97
 
98
+ // =============================================================================
99
+ // DEBOUNCE HOOK
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Hook to debounce a value by the specified delay.
104
+ */
105
+ function useDebouncedValue<T>(value: T, delayMs: number): T {
106
+ const [debouncedValue, setDebouncedValue] = useState(value);
107
+
108
+ useEffect(() => {
109
+ const timer = setTimeout(() => {
110
+ setDebouncedValue(value);
111
+ }, delayMs);
112
+
113
+ return () => clearTimeout(timer);
114
+ }, [value, delayMs]);
115
+
116
+ return debouncedValue;
117
+ }
118
+
110
119
  // =============================================================================
111
120
  // HOOKS
112
121
  // =============================================================================
@@ -185,105 +194,36 @@ export function useFormatShortcut(): (shortcut: string) => string {
185
194
  );
186
195
  }
187
196
 
188
- /**
189
- * Hook to search across all providers via backend RPC.
190
- * Returns search function and loading state.
191
- */
192
- export function useCommandPaletteSearch(): {
193
- search: (query: string) => Promise<SearchResult[]>;
194
- getCommands: () => Promise<SearchResult[]>;
195
- loading: boolean;
196
- } {
197
- const rpcApi = useApi(rpcApiRef);
198
- const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
199
- const [loading, setLoading] = useState(false);
200
-
201
- const search = useCallback(
202
- async (query: string): Promise<SearchResult[]> => {
203
- setLoading(true);
204
- try {
205
- return await commandApi.search({ query });
206
- } finally {
207
- setLoading(false);
208
- }
209
- },
210
- [commandApi]
211
- );
212
-
213
- const getCommands = useCallback(async (): Promise<SearchResult[]> => {
214
- setLoading(true);
215
- try {
216
- return await commandApi.getCommands();
217
- } finally {
218
- setLoading(false);
219
- }
220
- }, [commandApi]);
221
-
222
- return { search, getCommands, loading };
223
- }
224
-
225
197
  /**
226
198
  * Hook for debounced search in the command palette.
227
- * Automatically debounces the search query by the specified delay.
199
+ * Uses TanStack Query with a debounced query state.
228
200
  */
229
201
  export function useDebouncedSearch(delayMs: number = 300): {
230
202
  results: SearchResult[];
231
203
  loading: boolean;
232
- search: (query: string) => void;
204
+ setQuery: (query: string) => void;
233
205
  reset: () => void;
234
206
  } {
235
- const { search: doSearch, getCommands } = useCommandPaletteSearch();
236
- const [results, setResults] = useState<SearchResult[]>([]);
237
- const [loading, setLoading] = useState(false);
238
- const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
239
-
240
- const search = useCallback(
241
- (query: string) => {
242
- // Clear any pending search
243
- if (timeoutRef.current) {
244
- clearTimeout(timeoutRef.current);
245
- }
246
-
247
- // If empty query, fetch all results immediately (commands + entities)
248
- if (!query.trim()) {
249
- setLoading(true);
250
- doSearch("")
251
- .then(setResults)
252
- .catch(() => setResults([]))
253
- .finally(() => setLoading(false));
254
- return;
255
- }
256
-
257
- // Debounce non-empty queries
258
- setLoading(true);
259
- timeoutRef.current = setTimeout(() => {
260
- doSearch(query)
261
- .then(setResults)
262
- .catch(() => setResults([]))
263
- .finally(() => setLoading(false));
264
- }, delayMs);
265
- },
266
- [doSearch, getCommands, delayMs]
207
+ const commandClient = usePluginClient(CommandApi);
208
+ const [query, setQuery] = useState("");
209
+ const debouncedQuery = useDebouncedValue(query, delayMs);
210
+
211
+ // useQuery automatically refetches when debouncedQuery changes
212
+ const { data, isLoading } = commandClient.search.useQuery(
213
+ { query: debouncedQuery },
214
+ { staleTime: 30_000 } // Cache results for 30 seconds
267
215
  );
268
216
 
269
217
  const reset = useCallback(() => {
270
- if (timeoutRef.current) {
271
- clearTimeout(timeoutRef.current);
272
- }
273
- setResults([]);
274
- setLoading(false);
275
- }, []);
276
-
277
- // Cleanup on unmount
278
- useEffect(() => {
279
- return () => {
280
- if (timeoutRef.current) {
281
- clearTimeout(timeoutRef.current);
282
- }
283
- };
218
+ setQuery("");
284
219
  }, []);
285
220
 
286
- return { results, loading, search, reset };
221
+ return {
222
+ results: data ?? [],
223
+ loading: isLoading,
224
+ setQuery,
225
+ reset,
226
+ };
287
227
  }
288
228
 
289
229
  // =============================================================================
@@ -298,40 +238,20 @@ export function useCommands(): {
298
238
  commands: SearchResult[];
299
239
  loading: boolean;
300
240
  } {
301
- const rpcApi = useApi(rpcApiRef);
302
- const commandApi = useMemo(() => rpcApi.forPlugin(CommandApi), [rpcApi]);
303
- const [commands, setCommands] = useState<SearchResult[]>([]);
304
- const [loading, setLoading] = useState(true);
305
-
306
- useEffect(() => {
307
- let cancelled = false;
308
-
309
- async function fetchCommands() {
310
- try {
311
- const results = await commandApi.getCommands();
312
- if (!cancelled) {
313
- // Filter to only commands with shortcuts
314
- setCommands(
315
- results.filter((r) => r.shortcuts && r.shortcuts.length > 0)
316
- );
317
- }
318
- } catch {
319
- // Ignore errors - commands just won't be available
320
- } finally {
321
- if (!cancelled) {
322
- setLoading(false);
323
- }
324
- }
325
- }
241
+ const commandClient = usePluginClient(CommandApi);
326
242
 
327
- fetchCommands();
243
+ // Fetch all commands (empty query returns all)
244
+ const { data, isLoading } = commandClient.getCommands.useQuery(undefined, {
245
+ staleTime: 60_000, // Cache for 1 minute
246
+ });
328
247
 
329
- return () => {
330
- cancelled = true;
331
- };
332
- }, [commandApi]);
248
+ // Filter to only commands with shortcuts
249
+ const commandsWithShortcuts = useMemo(
250
+ () => (data ?? []).filter((r) => r.shortcuts && r.shortcuts.length > 0),
251
+ [data]
252
+ );
333
253
 
334
- return { commands, loading };
254
+ return { commands: commandsWithShortcuts, loading: isLoading };
335
255
  }
336
256
 
337
257
  /**