@checkstack/command-frontend 0.0.5 → 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,144 @@
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
+
65
+ ## 0.1.0
66
+
67
+ ### Minor Changes
68
+
69
+ - 9faec1f: # Unified AccessRule Terminology Refactoring
70
+
71
+ This release completes a comprehensive terminology refactoring from "permission" to "accessRule" across the entire codebase, establishing a consistent and modern access control vocabulary.
72
+
73
+ ## Changes
74
+
75
+ ### Core Infrastructure (`@checkstack/common`)
76
+
77
+ - Introduced `AccessRule` interface as the primary access control type
78
+ - Added `accessPair()` helper for creating read/manage access rule pairs
79
+ - Added `access()` builder for individual access rules
80
+ - Replaced `Permission` type with `AccessRule` throughout
81
+
82
+ ### API Changes
83
+
84
+ - `env.registerPermissions()` → `env.registerAccessRules()`
85
+ - `meta.permissions` → `meta.access` in RPC contracts
86
+ - `usePermission()` → `useAccess()` in frontend hooks
87
+ - Route `permission:` field → `accessRule:` field
88
+
89
+ ### UI Changes
90
+
91
+ - "Roles & Permissions" tab → "Roles & Access Rules"
92
+ - "You don't have permission..." → "You don't have access..."
93
+ - All permission-related UI text updated
94
+
95
+ ### Documentation & Templates
96
+
97
+ - Updated 18 documentation files with AccessRule terminology
98
+ - Updated 7 scaffolding templates with `accessPair()` pattern
99
+ - All code examples use new AccessRule API
100
+
101
+ ## Migration Guide
102
+
103
+ ### Backend Plugins
104
+
105
+ ```diff
106
+ - import { permissionList } from "./permissions";
107
+ - env.registerPermissions(permissionList);
108
+ + import { accessRules } from "./access";
109
+ + env.registerAccessRules(accessRules);
110
+ ```
111
+
112
+ ### RPC Contracts
113
+
114
+ ```diff
115
+ - .meta({ userType: "user", permissions: [permissions.read.id] })
116
+ + .meta({ userType: "user", access: [access.read] })
117
+ ```
118
+
119
+ ### Frontend Hooks
120
+
121
+ ```diff
122
+ - const canRead = accessApi.usePermission(permissions.read.id);
123
+ + const canRead = accessApi.useAccess(access.read);
124
+ ```
125
+
126
+ ### Routes
127
+
128
+ ```diff
129
+ - permission: permissions.entityRead.id,
130
+ + accessRule: access.read,
131
+ ```
132
+
133
+ ### Patch Changes
134
+
135
+ - Updated dependencies [9faec1f]
136
+ - Updated dependencies [f533141]
137
+ - @checkstack/command-common@0.1.0
138
+ - @checkstack/common@0.2.0
139
+ - @checkstack/frontend-api@0.1.0
140
+ - @checkstack/ui@0.2.0
141
+
3
142
  ## 0.0.5
4
143
 
5
144
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/command-frontend",
3
- "version": "0.0.5",
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
  // =============================================================================
@@ -118,16 +127,16 @@ export function formatShortcut(shortcut: string, isMac: boolean): string {
118
127
  *
119
128
  * @param commands - Array of commands with shortcuts
120
129
  * @param navigate - Navigation function to call when a command is triggered
121
- * @param userPermissions - Array of permission IDs the user has
130
+ * @param userAccessRules - Array of access rule IDs the user has
122
131
  */
123
132
  export function useGlobalShortcuts(
124
133
  commands: SearchResult[],
125
134
  navigate: (route: string) => void,
126
- userPermissions: string[]
135
+ userAccessRules: string[]
127
136
  ): void {
128
137
  useEffect(() => {
129
- // Check if user has wildcard permission (admin)
130
- const hasWildcard = userPermissions.includes("*");
138
+ // Check if user has wildcard access rule (admin)
139
+ const hasWildcard = userAccessRules.includes("*");
131
140
 
132
141
  const handleKeyDown = (event: KeyboardEvent) => {
133
142
  // Don't trigger in input fields
@@ -144,12 +153,12 @@ export function useGlobalShortcuts(
144
153
  for (const command of commands) {
145
154
  if (!command.shortcuts || !command.route) continue;
146
155
 
147
- // Check permissions (skip if user has wildcard)
148
- if (!hasWildcard && command.requiredPermissions?.length) {
149
- const hasPermission = command.requiredPermissions.every((perm) =>
150
- userPermissions.includes(perm)
156
+ // Check access rules (skip if user has wildcard)
157
+ if (!hasWildcard && command.requiredAccessRules?.length) {
158
+ const hasAccess = command.requiredAccessRules.every((rule) =>
159
+ userAccessRules.includes(rule)
151
160
  );
152
- if (!hasPermission) continue;
161
+ if (!hasAccess) continue;
153
162
  }
154
163
 
155
164
  for (const shortcut of command.shortcuts) {
@@ -165,7 +174,7 @@ export function useGlobalShortcuts(
165
174
 
166
175
  globalThis.addEventListener("keydown", handleKeyDown);
167
176
  return () => globalThis.removeEventListener("keydown", handleKeyDown);
168
- }, [commands, navigate, userPermissions]);
177
+ }, [commands, navigate, userAccessRules]);
169
178
  }
170
179
 
171
180
  /**
@@ -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
  /**
@@ -359,7 +279,7 @@ export function GlobalShortcuts(): React.ReactNode {
359
279
  globalThis.location.href = route;
360
280
  }, []);
361
281
 
362
- // For now, pass "*" as permission since the backend already filters by permission
282
+ // For now, pass "*" as access rule since the backend already filters
363
283
  // The commands returned from getCommands are already filtered
364
284
  useGlobalShortcuts(commands, navigate, ["*"]);
365
285