Skip to content

Commit 7fd4628

Browse files
authoredMar 18, 2025··
Merge pull request #29 from CrowdStrike/collection-hook
feat: useCollectionObject
2 parents d7f27ec + a70e2d6 commit 7fd4628

File tree

11 files changed

+3741
-241
lines changed

11 files changed

+3741
-241
lines changed
 

‎.github/workflows/build.yml

+28-25
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ name: Build
22

33
on:
44
push:
5-
branches: [ main ]
5+
branches: [main]
66
pull_request:
7-
branches: [ main ]
7+
branches: [main]
88

99
jobs:
1010
build:
@@ -16,26 +16,29 @@ jobs:
1616
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
1717

1818
steps:
19-
- uses: actions/checkout@v4
20-
21-
- name: Use Node.js ${{ matrix.node-version }}
22-
uses: actions/setup-node@v4
23-
with:
24-
node-version: ${{ matrix.node-version }}
25-
cache: 'npm'
26-
27-
- name: Install dependencies
28-
run: npm ci
29-
30-
- name: Build
31-
run: npm run build
32-
33-
- name: Cache build artifacts
34-
uses: actions/cache@v4
35-
with:
36-
path: |
37-
dist
38-
node_modules
39-
key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}
40-
restore-keys: |
41-
${{ runner.os }}-build-
19+
- uses: actions/checkout@v4
20+
21+
- name: Use Node.js ${{ matrix.node-version }}
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: ${{ matrix.node-version }}
25+
cache: "npm"
26+
27+
- name: Install dependencies
28+
run: npm ci
29+
30+
- name: Build
31+
run: npm run build
32+
33+
- name: Test
34+
run: npm test
35+
36+
- name: Cache build artifacts
37+
uses: actions/cache@v4
38+
with:
39+
path: |
40+
dist
41+
node_modules
42+
key: ${{ runner.os }}-build-${{ hashFiles('**/package-lock.json') }}
43+
restore-keys: |
44+
${{ runner.os }}-build-

‎README.md

+22
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,25 @@ return (
135135
136136
> [!IMPORTANT]
137137
> Note that the `data` object returned by `useFoundry()` is a React state object, which the Foundry provider correctly updates via a `falcon.events.on('data')` event handler. Do not use the `falcon.data` object, since it will not correctly re-render your UI when a data event occurs.
138+
139+
### `useCollectionObject`
140+
141+
Query a collection object. This hook is useful to look up a well known value when a component renders, for example a configuration value.
142+
143+
```javascript
144+
const [config, configReady, configError] = useCollectionObject(
145+
"config",
146+
"default"
147+
);
148+
useEffect(() => {
149+
if (!configReady) return;
150+
if (configError) return; // TODO: handle error
151+
// TODO: use config value
152+
}, [configReady]);
153+
```
154+
155+
### Types
156+
157+
Use these types to perform type assertions on responses from foundry-js and safely interact with those responses (rather than asserting them as `any`). See the documentation for each type for more details.
158+
159+
- `CollectionReadResponse` - Returned from `falcon.collection().read()`.

‎__mocks__/@crowdstrike/foundry-js.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { jest } from "@jest/globals";
2+
3+
/**
4+
* Stub that enables `jest.mock("@crowdstrike/foundry-js")` since we won't be able to interact with
5+
* Foundry from a local test. Mock specific function calls in individual tests, for example:
6+
*
7+
* ```
8+
* const mockReadResponse = { special_key: "special_value" };
9+
* const mockCollection = {
10+
* read: jest
11+
* .fn<() => Promise<CollectionReadResponse>>()
12+
* .mockResolvedValue(mockReadResponse),
13+
* };
14+
* const mockFalcon = {
15+
* collection: jest.fn().mockReturnValue(mockCollection),
16+
* };
17+
* ```
18+
*/
19+
export const FalconApi = jest.fn();

‎__mocks__/foundry-context.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { jest } from "@jest/globals";
2+
3+
/**
4+
* Stub that enables `jest.mock("./foundry-context")` since we won't have an actual `FoundryProvider`
5+
* in a local test. Return a mock `FalconApi` (see `FalconApi` mock docs):
6+
7+
*
8+
* ```javascript
9+
* (useFoundry as jest.Mock).mockReturnValue({
10+
* isInitialized: true,
11+
* falcon: mockFalcon,
12+
* });
13+
* ```
14+
*/
15+
export const useFoundry = jest.fn();

‎jest.config.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} **/
2+
export default {
3+
preset: "ts-jest",
4+
testEnvironment: "jsdom",
5+
transform: {
6+
"^.+.tsx?$": ["ts-jest", {}],
7+
},
8+
};

‎package-lock.json

+3,474-215
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"scripts": {
2222
"build": "rollup -c",
2323
"clean": "rm -rf dist",
24-
"prepack": "npm run clean && npm run build"
24+
"prepack": "npm run clean && npm run build",
25+
"test": "jest"
2526
},
2627
"dependencies": {
2728
"@crowdstrike/foundry-js": "^0.17.1",
@@ -31,18 +32,23 @@
3132
"devDependencies": {
3233
"@babel/preset-env": "^7.26.9",
3334
"@babel/preset-react": "^7.26.3",
35+
"@jest/globals": "^29.7.0",
3436
"@rollup/plugin-babel": "^6.0.4",
3537
"@rollup/plugin-commonjs": "^28.0.2",
3638
"@rollup/plugin-node-resolve": "^16.0.0",
3739
"@rollup/plugin-terser": "^0.4.4",
3840
"@rollup/plugin-typescript": "^12.1.2",
41+
"@testing-library/react": "^16.2.0",
3942
"@types/react": "^18.3.18",
4043
"@types/react-dom": "^18.3.5",
4144
"html-webpack-plugin": "^5.6.3",
45+
"jest": "^29.7.0",
46+
"jest-environment-jsdom": "^29.7.0",
4247
"postcss": "^8.5.3",
4348
"rollup": "^4.34.8",
4449
"rollup-plugin-peer-deps-external": "^2.2.4",
4550
"rollup-plugin-postcss": "^4.0.2",
51+
"ts-jest": "^29.2.6",
4652
"ts-loader": "^9.4.2",
4753
"typescript": "^5.8.2",
4854
"webpack": "^5.98.0",

‎src/lib/hooks.test.tsx

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { beforeEach, describe, expect, jest, test } from "@jest/globals";
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { useFoundry } from "./foundry-context";
4+
import { useCollectionObject } from "./hooks";
5+
import { CollectionReadResponse } from "./types";
6+
7+
jest.mock("./foundry-context");
8+
jest.mock("@crowdstrike/foundry-js");
9+
10+
describe("useCollectionObject", () => {
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
});
14+
15+
test("returns object when read succeeds", async () => {
16+
// mock falcon api collection response
17+
const mockReadResponse = { special_key: "special_value" };
18+
const mockCollection = {
19+
read: jest
20+
.fn<() => Promise<CollectionReadResponse>>()
21+
.mockResolvedValue(mockReadResponse),
22+
};
23+
const mockFalcon = {
24+
collection: jest.fn().mockReturnValue(mockCollection),
25+
};
26+
27+
// mock useFoundry to use mock falcon api
28+
(useFoundry as jest.Mock).mockReturnValue({
29+
isInitialized: true,
30+
falcon: mockFalcon,
31+
});
32+
33+
const { result } = renderHook(() =>
34+
useCollectionObject("test-collection", "test-key")
35+
);
36+
37+
// wait for ready
38+
await waitFor(() => {
39+
expect(result.current[1]).toBe(true);
40+
});
41+
42+
// ensure falcon api called correctly
43+
expect(mockFalcon.collection).toHaveBeenCalledWith({
44+
collection: "test-collection",
45+
});
46+
expect(mockCollection.read).toHaveBeenCalledWith("test-key");
47+
48+
// ensure response contains value
49+
expect(result.current[0]).toEqual(mockReadResponse); // value
50+
expect(result.current[1]).toBe(true); // complete
51+
expect(result.current[2]).toBe(null); // error
52+
});
53+
54+
test("returns error when read fails", async () => {
55+
// mock falcon api collection response
56+
const errorMessage = "error_message";
57+
const mockReadResponse = { errors: [{ code: 500, message: errorMessage }] };
58+
const mockCollection = {
59+
read: jest
60+
.fn<() => Promise<CollectionReadResponse>>()
61+
.mockResolvedValue(mockReadResponse),
62+
};
63+
const mockFalcon = {
64+
collection: jest.fn().mockReturnValue(mockCollection),
65+
};
66+
67+
// mock useFoundry to use mock falcon api
68+
(useFoundry as jest.Mock).mockReturnValue({
69+
isInitialized: true,
70+
falcon: mockFalcon,
71+
});
72+
73+
const { result } = renderHook(() =>
74+
useCollectionObject("test-collection", "test-key")
75+
);
76+
77+
// wait for ready
78+
await waitFor(() => {
79+
expect(result.current[1]).toBe(true);
80+
});
81+
82+
// ensure response contains value
83+
expect(result.current[0]).toEqual(null); // value
84+
expect(result.current[1]).toBe(true); // complete
85+
expect(result.current[2]).toBe(errorMessage); // error
86+
});
87+
});

‎src/lib/hooks.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useEffect, useState } from "react";
2+
import { useFoundry } from "./foundry-context";
3+
import { CollectionReadResponse } from "./types";
4+
5+
/** Query a collection object. This hook is useful to look up a well known value when a component
6+
* renders, for example a configuration value.
7+
*
8+
* Example:
9+
*
10+
* ```
11+
* const [config, configReady, configError] = useCollectionObject("config", "default");
12+
* useEffect(() => {
13+
* if (!configReady) return;
14+
* if (configError) return; // TODO: handle error
15+
* // TODO: use config value
16+
* }, [configReady])
17+
* ```
18+
*/
19+
export function useCollectionObject(
20+
/** Collection name to query */
21+
collection: string,
22+
/** Object key to query */
23+
key: string
24+
): [
25+
/** The object value, if exists, otherwise `null` */
26+
any,
27+
/** True when the query is complete (regardless of the result of the query, including error) */
28+
boolean,
29+
/** Error response message, if any, otherwise `null` */
30+
string | null
31+
] {
32+
const { falcon, isInitialized } = useFoundry();
33+
const [value, setValue] = useState<object | null>(null);
34+
const [complete, setComplete] = useState(false);
35+
const [error, setError] = useState<null | string>(null);
36+
37+
useEffect(() => {
38+
if (!isInitialized) return;
39+
falcon!
40+
.collection({ collection: collection })
41+
.read(key)
42+
.then((r: CollectionReadResponse) => {
43+
if (r.errors && r.errors.length > 0) {
44+
setError(r.errors[0].message);
45+
} else {
46+
setValue(r);
47+
}
48+
setComplete(true);
49+
});
50+
}, [isInitialized]);
51+
52+
return [value, complete, error];
53+
}

‎src/lib/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { FoundryProvider, useFoundry } from "./foundry-context";
2+
export { useCollectionObject } from "./hooks";
3+
export { CollectionReadResponse } from "./types";

‎src/lib/types.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
interface CanError {
2+
/** Errors returned by the API call */
3+
errors?: {
4+
code: number;
5+
message: string;
6+
}[];
7+
}
8+
9+
/**
10+
* Returned by `falcon.collection().read()`. Example:
11+
* ```
12+
* falcon
13+
* .collection({ collection: "foo" })
14+
* .read("bar")
15+
* .then((r: CollectionReadResponse) => {
16+
* if (r.errors && r.errors.length > 0) {
17+
* setError(r.errors[0].message);
18+
* } else {
19+
* setValue(r);
20+
* }});
21+
* ```
22+
*/
23+
export interface CollectionReadResponse extends CanError {
24+
/** Arbitrary data matching the collection's schema */
25+
[key: string]: any;
26+
}

0 commit comments

Comments
 (0)
Please sign in to comment.