Repository: Unleash/unleash
Pull request #9850: feat: recently used constraints hook
Author: login: sjaanus
Created: 2025-04-28T12:06:29Z | Merged: 2025-04-28T12:51:52Z

Add hook to store last 3 constraints in local storage. Also add tests.


Diff:
diff --git a/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.test.tsx b/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.test.tsx
new file mode 100644
index 0000000000..f357d9ddbf
--- /dev/null
+++ b/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.test.tsx
@@ -0,0 +1,228 @@
+import {
+    useRecentlyUsedConstraints,
+    areConstraintsEqual,
+} from './useRecentlyUsedConstraints';
+import { renderHook, act } from '@testing-library/react';
+import type { IConstraint } from 'interfaces/strategy';
+import { IN, STR_CONTAINS } from 'constants/operators';
+import type { Operator } from 'constants/operators';
+
+const createTestConstraint = (
+    contextName: string,
+    operator: Operator = IN,
+    values = [contextName],
+): IConstraint => ({
+    contextName,
+    operator,
+    values,
+});
+
+describe('areConstraintsEqual', () => {
+    it('should return true for constraints with identical content', () => {
+        const constraint1: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user2'],
+            inverted: false,
+        };
+
+        const constraint2: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user2'],
+            inverted: false,
+        };
+
+        expect(areConstraintsEqual(constraint1, constraint2)).toBe(true);
+    });
+
+    it('should return true for constraints with same values in different order', () => {
+        const constraint1: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user2', 'user3'],
+        };
+
+        const constraint2: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user2', 'user3', 'user1'],
+        };
+
+        expect(areConstraintsEqual(constraint1, constraint2)).toBe(true);
+    });
+
+    it('should return false for constraints with different content', () => {
+        const constraint1: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user2'],
+        };
+
+        const constraint2: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user3'],
+        };
+
+        expect(areConstraintsEqual(constraint1, constraint2)).toBe(false);
+    });
+
+    it('should return false for constraints with different operators', () => {
+        const constraint1: IConstraint = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1'],
+        };
+
+        const constraint2: IConstraint = {
+            contextName: 'userId',
+            operator: STR_CONTAINS,
+            values: ['user1'],
+        };
+
+        expect(areConstraintsEqual(constraint1, constraint2)).toBe(false);
+    });
+});
+
+describe('useRecentlyUsedConstraints', () => {
+    beforeEach(() => {
+        window.localStorage.clear();
+    });
+
+    it('should initialize with empty array when no items in localStorage', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        expect(result.current.items).toEqual([]);
+    });
+
+    it('should initialize with initial items if provided', () => {
+        const initialItems = [createTestConstraint('userId')];
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key', initialItems),
+        );
+
+        expect(result.current.items).toEqual(initialItems);
+    });
+
+    it('should add new items to the beginning of the list', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        act(() => {
+            result.current.addItem(createTestConstraint('userId'));
+        });
+        expect(result.current.items[0].contextName).toBe('userId');
+
+        act(() => {
+            result.current.addItem(createTestConstraint('email'));
+        });
+        expect(result.current.items[0].contextName).toBe('email');
+        expect(result.current.items[1].contextName).toBe('userId');
+    });
+
+    it('should limit stored items to maximum of 3', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        act(() => {
+            result.current.addItem(createTestConstraint('userId'));
+            result.current.addItem(createTestConstraint('email'));
+            result.current.addItem(createTestConstraint('countryId'));
+            result.current.addItem(createTestConstraint('appName'));
+        });
+
+        expect(result.current.items.length).toBe(3);
+        expect(result.current.items[0].contextName).toBe('appName');
+        expect(result.current.items[1].contextName).toBe('countryId');
+        expect(result.current.items[2].contextName).toBe('email');
+    });
+
+    it('should not add duplicate constraints', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        const constraint1 = createTestConstraint('userId', IN, [
+            'user1',
+            'user2',
+        ]);
+        const constraint2 = createTestConstraint('email');
+
+        act(() => {
+            result.current.addItem(constraint1);
+            result.current.addItem(constraint2);
+        });
+        expect(result.current.items.length).toBe(2);
+
+        const duplicateConstraint = createTestConstraint('userId', IN, [
+            'user1',
+            'user2',
+        ]);
+        act(() => {
+            result.current.addItem(duplicateConstraint);
+        });
+
+        expect(result.current.items.length).toBe(2);
+        expect(result.current.items[0].contextName).toBe('userId');
+        expect(result.current.items[1].contextName).toBe('email');
+    });
+
+    it('should not add duplicate constraints with values in different order', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        const constraint1 = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user1', 'user2', 'user3'],
+        } as IConstraint;
+
+        act(() => {
+            result.current.addItem(constraint1);
+        });
+        expect(result.current.items.length).toBe(1);
+
+        // Same constraint but with values in different order
+        const sameConstraintDifferentOrder = {
+            contextName: 'userId',
+            operator: IN,
+            values: ['user3', 'user1', 'user2'],
+        } as IConstraint;
+
+        act(() => {
+            result.current.addItem(sameConstraintDifferentOrder);
+        });
+
+        // Should not add a duplicate, just move it to the top
+        expect(result.current.items.length).toBe(1);
+        expect(result.current.items[0].contextName).toBe('userId');
+        // The values array in the stored item should be the one from the most recently added item
+        expect(result.current.items[0].values).toEqual([
+            'user3',
+            'user1',
+            'user2',
+        ]);
+    });
+
+    it('should persist items to localStorage', () => {
+        const { result } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        act(() => {
+            result.current.addItem(createTestConstraint('userId'));
+        });
+
+        const { result: newResult } = renderHook(() =>
+            useRecentlyUsedConstraints('test-key'),
+        );
+
+        expect(newResult.current.items[0].contextName).toBe('userId');
+    });
+});
diff --git a/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts b/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts
new file mode 100644
index 0000000000..6d43b17f2c
--- /dev/null
+++ b/frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts
@@ -0,0 +1,57 @@
+import { useLocalStorageState } from 'hooks/useLocalStorageState';
+import type { IConstraint } from 'interfaces/strategy';
+
+export const areConstraintsEqual = (
+    a: IConstraint,
+    b: IConstraint,
+): boolean => {
+    // Sort the values arrays if they exist
+    const sortedValues = (values?: string[]) =>
+        values ? [...values].sort() : undefined;
+
+    const aJson = JSON.stringify({
+        contextName: a.contextName,
+        operator: a.operator,
+        values: sortedValues(a.values),
+        value: a.value,
+        inverted: a.inverted,
+        caseInsensitive: a.caseInsensitive,
+    });
+
+    const bJson = JSON.stringify({
+        contextName: b.contextName,
+        operator: b.operator,
+        values: sortedValues(b.values),
+        value: b.value,
+        inverted: b.inverted,
+        caseInsensitive: b.caseInsensitive,
+    });
+
+    return aJson === bJson;
+};
+
+export const useRecentlyUsedConstraints = (
+    key: string,
+    initialItems: IConstraint[] = [],
+) => {
+    const [items, setItems] = useLocalStorageState<IConstraint[]>(
+        `recently-used-constraints-${key}`,
+        initialItems,
+    );
+
+    const addItem = (newItem: IConstraint) => {
+        setItems((prevItems) => {
+            const filteredItems = prevItems.filter(
+                (item) => !areConstraintsEqual(item, newItem),
+            );
+
+            const updatedItems = [newItem, ...filteredItems];
+            return updatedItems.slice(0, 3);
+        });
+    };
+
+    return {
+        items,
+        addItem,
+    };
+};


Reviews:
user: login: sjaanus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T12:07:22Z
user: login: thomasheartman
body: Nice one!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T12:21:47Z
user: login: sjaanus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T12:43:34Z

Review comments:
diff_hunk: @@ -0,0 +1,53 @@
+import { useLocalStorageState } from 'hooks/useLocalStorageState';
+import type { IConstraint } from 'interfaces/strategy';
+
+export const areConstraintsEqual = (
path: frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts
user: login: sjaanus
body: This might be temporary or permanent. Wanted to make that if user saves same thing twice in sequence, it does not duplicate, but since there is no id, it must check fields.
created_at: 2025-04-28T12:07:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+import {
+    useRecentlyUsedConstraints,
+    areConstraintsEqual,
+} from './useRecentlyUsedConstraints';
+import { renderHook, act } from '@testing-library/react';
+import type { IConstraint } from 'interfaces/strategy';
+import { IN, STR_CONTAINS } from 'constants/operators';
+import type { Operator } from 'constants/operators';
+
+const createTestConstraint = (
+    contextName: string,
+    operator: Operator = IN,
+    values = [contextName],
+): IConstraint => ({
+    contextName,
+    operator,
+    values,
+});
+
+describe('areConstraintsEqual', () => {
+    it('should return true for constraints with identical content', () => {
path: frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.test.tsx
user: login: thomasheartman
body: Is it worth doing any kind of order-ignoring comparison of values here? E.g. should `values: [A, B]` count as the same as `values: [B, A]` assuming everything else is the same?
created_at: 2025-04-28T12:18:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+import { useLocalStorageState } from 'hooks/useLocalStorageState';
+import type { IConstraint } from 'interfaces/strategy';
+
+export const areConstraintsEqual = (
path: frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts
user: login: thomasheartman
body: I agree. this sounds like a good optimization. 
created_at: 2025-04-28T12:20:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+import { useLocalStorageState } from 'hooks/useLocalStorageState';
+import type { IConstraint } from 'interfaces/strategy';
+
+export const areConstraintsEqual = (
+    a: IConstraint,
+    b: IConstraint,
+): boolean => {
+    const aJson = JSON.stringify({
path: frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.ts
user: login: thomasheartman
body: I'm not sure json converting here is a better solution than just directly comparing the fields. Aside from "values" they're all string fields, so there shouldn't be much weird object comparison going on. And for values, as mentioned above, I think it might make sense to sort the values before comparing, so that you don't end up with the same constraint three times just with different ordering.

But that can definitely be handled later.
created_at: 2025-04-28T12:21:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+import {
+    useRecentlyUsedConstraints,
+    areConstraintsEqual,
+} from './useRecentlyUsedConstraints';
+import { renderHook, act } from '@testing-library/react';
+import type { IConstraint } from 'interfaces/strategy';
+import { IN, STR_CONTAINS } from 'constants/operators';
+import type { Operator } from 'constants/operators';
+
+const createTestConstraint = (
+    contextName: string,
+    operator: Operator = IN,
+    values = [contextName],
+): IConstraint => ({
+    contextName,
+    operator,
+    values,
+});
+
+describe('areConstraintsEqual', () => {
+    it('should return true for constraints with identical content', () => {
path: frontend/src/component/feature/FeatureStrategy/RecentlyUsedConstraints/useRecentlyUsedConstraints.test.tsx
user: login: sjaanus
body: Added sorting before comparison and updated tests.
created_at: 2025-04-28T12:43:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-04-28T12:06:35Z
body: [vc]: #7Z6qQe2bURWbAA5D8Q07oxx41X1XLxfPshBglkpGyQs=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1bmxlYXNoLWRvY3MiLCJyb290RGlyZWN0b3J5Ijoid2Vic2l0ZSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3VubGVhc2gtdGVhbS91bmxlYXNoLWRvY3MvNW5hTEp3TjZZc2NkNHV1dnFld2MzUld2eHkzUyIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InVubGVhc2gtZG9jcy1naXQtcmVjZW50LWhvb2stdW5sZWFzaC10ZWFtLnZlcmNlbC5hcHAifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **unleash-docs** | ⬜️ Ignored ([Inspect](https://vercel.com/unleash-team/unleash-docs/5naLJwN6Yscd4uuvqewc3RWvxy3S)) | [Visit Preview](https://unleash-docs-git-recent-hook-unleash-team.vercel.app) |  | Apr 28, 2025 0:43am |
</details>


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-28T12:06:44Z
body: <h1>Dependency Review</h1>
✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.<h2>Scanned Files</h2>
None

<!-- dependency-review-pr-comment-marker -->
author_association: CONTRIBUTOR