Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ Create so called "rules for AI" written in Markdown, used by tools such as GitHu

1. Install Supabase CLI

Check [Supabase docs](https://supabase.com/docs/guides/local-development?queryGroups=package-manager&package-manager=brew#quickstart) for local setup reference.

```bash
brew install supabase
brew install supabase/tap/supabase
```

2. Start Supabase (requires local Docker)
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"clsx": "^2.1.1",
"fflate": "^0.8.2",
"lucide-react": "0.479.0",
"lz-string": "^1.5.0",
"react": "18.3.0",
"react-dom": "18.3.0",
"react-hook-form": "7.55.0",
Expand Down
26 changes: 24 additions & 2 deletions src/components/TwoPane.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { RuleBuilder } from './rule-builder';
import { RulePreview } from './rule-preview';
import CollectionsSidebar from './rule-collections/CollectionsSidebar';
import { MobileNavigation } from './MobileNavigation';
import { useNavigationStore } from '../store/navigationStore';
import { isFeatureEnabled } from '../features/featureFlags';
import { useTechStackStore } from '../store/techStackStore';

export default function TwoPane() {
function RulesPane() {
const { activePanel, isSidebarOpen, toggleSidebar, setSidebarOpen } = useNavigationStore();
const isCollectionsEnabled = isFeatureEnabled('authOnUI');

Expand Down Expand Up @@ -51,3 +52,24 @@ export default function TwoPane() {
</div>
);
}

type TwoPaneProps = {
initialUrl: URL;
};

export default function TwoPane({ initialUrl }: TwoPaneProps) {
const { anyLibrariesToLoad } = useTechStackStore();
const [isHydrated, setIsHydrated] = useState(false);

const shouldWaitForHydration = anyLibrariesToLoad(initialUrl) && !isHydrated;

useEffect(() => {
setIsHydrated(true);
}, []);

if (shouldWaitForHydration) {
return <div>Loading...</div>;
}

return <RulesPane />;
}
4 changes: 3 additions & 1 deletion src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import Topbar from '../components/Topbar';
import TwoPane from '../components/TwoPane';
import Footer from '../components/Footer';
const user = Astro.locals.user;

const initialUrl = Astro.url;
---

<Layout>
<div class="flex flex-col h-screen max-h-screen bg-gray-950 overflow-hidden">
<Topbar client:load initialUser={user} />
<main class="flex-grow overflow-auto">
<TwoPane client:load />
<TwoPane client:load initialUrl={initialUrl} />
</main>
<Footer client:load />
</div>
Expand Down
119 changes: 119 additions & 0 deletions src/store/storage/urlStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import { createUrlStorage, doesUrlContainState } from './urlStorage';
import type { StorageValue } from 'zustand/middleware';

// make the compressing/decompressing methods more readable for testing
vi.mock('lz-string', () => ({
compressToEncodedURIComponent: vi.fn((str) => `compressed:${str}`),
decompressFromEncodedURIComponent: vi.fn((str) => str.replace('compressed:', '')),
}));

type TestState = { foo: string };
const TEST_KEY = 'testKey';
const TEST_STATE: TestState = { foo: 'bar' };
const TEST_VALUE: StorageValue<TestState> = { state: TEST_STATE };
const COMPRESSED_VALUE = 'compressed:' + JSON.stringify(TEST_VALUE);

describe('UrlStorage', () => {
afterEach(() => {
vi.clearAllMocks();
});

describe('client-side, storage', () => {
const spies = {
location: vi.spyOn(window, 'location', 'get'),
pushState: vi.fn(),
};

function setLocationSearch(search: string) {
spies.location.mockReturnValue({
search,
pathname: '/test',
hash: '',
} as unknown as Location);
}

beforeEach(() => {
spies.location = vi.spyOn(window, 'location', 'get');
setLocationSearch('');
spies.pushState = vi.fn();
global.window.history.pushState = spies.pushState;
});

afterEach(() => {
spies.location.mockRestore();
});

it('contains null if key is not present', () => {
setLocationSearch('');
const storage = createUrlStorage<TestState>();
expect(storage.getItem(TEST_KEY)).toBeNull();
});

it('contains parsed value if key is present', () => {
setLocationSearch(`?${TEST_KEY}=${COMPRESSED_VALUE}`);
const storage = createUrlStorage<TestState>();
expect(storage.getItem(TEST_KEY)).toEqual(TEST_VALUE);
});

it('contains compressed value and updates URL', () => {
const storage = createUrlStorage<TestState>();
storage.setItem(TEST_KEY, TEST_VALUE);

// Construct expected search string using URLSearchParams for consistent encoding
const expectedParams = new URLSearchParams();
expectedParams.set(TEST_KEY, COMPRESSED_VALUE);
const expectedSearchString = expectedParams.toString();
const expectedUrl = window.location.pathname + '?' + expectedSearchString;

expect(window.history.pushState).toHaveBeenCalledWith({}, '', expectedUrl);
});

it('removes key and updates URL', () => {
setLocationSearch(`?${TEST_KEY}=${COMPRESSED_VALUE}`);
const storage = createUrlStorage<TestState>();
storage.removeItem(TEST_KEY);

// After removal, the param should not be present, queryParams.toString() will be empty
const expectedUrl = window.location.pathname + '?';
expect(window.history.pushState).toHaveBeenCalledWith({}, '', expectedUrl);
});
});

describe('server-side', () => {
let originalWindow: Window & typeof globalThis;

beforeAll(() => {
originalWindow = global.window;
// @ts-expect-error: Simulate server-side by deleting window
delete global.window;
});

afterAll(() => {
global.window = originalWindow!;
});

it('does not throw and does not update URL', () => {
const storage = createUrlStorage<TestState>();
expect(() => storage.setItem(TEST_KEY, TEST_VALUE)).not.toThrow();
expect(() => storage.removeItem(TEST_KEY)).not.toThrow();
});
});
});

describe('doesUrlContainState', () => {
it('should return true if param exists', () => {
const url = new URL('http://localhost/?rulesFromUrl=bar');
expect(doesUrlContainState(url, 'rulesFromUrl')).toBe(true);
});

it('should return false if param does not exist', () => {
const url = new URL('http://localhost/?someOtherParam=bar');
expect(doesUrlContainState(url, 'rulesFromUrl')).toBe(false);
});

it('should return false if param is not present', () => {
const url = new URL('http://localhost/');
expect(doesUrlContainState(url, 'rulesFromUrl')).toBe(false);
});
});
49 changes: 49 additions & 0 deletions src/store/storage/urlStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PersistStorage, StorageValue } from 'zustand/middleware';
import * as LZString from 'lz-string';

const { compressToEncodedURIComponent, decompressFromEncodedURIComponent } = LZString;

class UrlStorage<TState> implements PersistStorage<TState> {
private queryParams: URLSearchParams;

constructor() {
if (this.isServerSide()) {
this.queryParams = new URLSearchParams('');
} else {
this.queryParams = new URLSearchParams(window.location.search);
}
}

getItem(name: string): StorageValue<TState> | Promise<StorageValue<TState> | null> | null {
const value = this.queryParams.get(name);
if (!value) return null;
return JSON.parse(decompressFromEncodedURIComponent(value));
}

setItem(name: string, value: StorageValue<TState>): unknown | Promise<unknown> {
this.queryParams.set(name, compressToEncodedURIComponent(JSON.stringify(value)));
this.updateUrl();

return Promise.resolve();
}

removeItem(name: string): unknown | Promise<unknown> {
this.queryParams.delete(name);
this.updateUrl();

return Promise.resolve();
}

private updateUrl() {
if (this.isServerSide()) return;
window.history.pushState({}, '', window.location.pathname + '?' + this.queryParams.toString());
}

private isServerSide() {
return typeof window === 'undefined';
}
}

export const createUrlStorage = <TState>() => new UrlStorage<TState>();
export const doesUrlContainState = (url: URL, name: string): boolean =>
url.searchParams.get(name) !== null;
27 changes: 24 additions & 3 deletions src/store/techStackStore.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { create } from 'zustand';
import { create, type StateCreator } from 'zustand';
import {
Layer,
Stack,
Library,
getLayersByLibrary,
getStacksByLibrary,
} from '../data/dictionaries';
import { devtools, persist, type PersistOptions } from 'zustand/middleware';
import { createUrlStorage, doesUrlContainState } from './storage/urlStorage';

interface TechStackState {
// Selected items
Expand All @@ -23,6 +25,7 @@ interface TechStackState {
unselectStack: (stack: Stack) => void;
selectLibrary: (library: Library) => void;
unselectLibrary: (library: Library) => void;
anyLibrariesToLoad: (url: URL) => boolean;

// Reset actions
resetLayers: () => void;
Expand All @@ -42,7 +45,17 @@ interface TechStackState {
getSelectedStacksByLibrary: (library: Library) => Stack[];
}

export const useTechStackStore = create<TechStackState>((set, get) => ({
type PersistedState = Pick<TechStackState, 'selectedLibraries'>;

const persistOptions: PersistOptions<TechStackState, PersistedState> = {
name: 'rules',
storage: createUrlStorage<PersistedState>(),
partialize: (state: TechStackState) => ({
selectedLibraries: state.selectedLibraries,
}),
};

const techStackState: StateCreator<TechStackState> = (set, get) => ({
// Initial state
selectedLayers: [],
selectedStacks: [],
Expand Down Expand Up @@ -88,6 +101,8 @@ export const useTechStackStore = create<TechStackState>((set, get) => ({
selectedLibraries: state.selectedLibraries.filter((l) => l !== library),
})),

anyLibrariesToLoad: (url: URL) => doesUrlContainState(url, persistOptions.name),

// Reset actions
resetLayers: () => set({ selectedLayers: [] }),
resetStacks: () => set({ selectedStacks: [] }),
Expand Down Expand Up @@ -135,4 +150,10 @@ export const useTechStackStore = create<TechStackState>((set, get) => ({
const allStacksForLibrary = getStacksByLibrary(library);
return allStacksForLibrary.filter((stack) => get().selectedStacks.includes(stack));
},
}));
});

export const useTechStackStore = create<TechStackState>()(
devtools(persist(techStackState, persistOptions), {
enabled: process.env.NODE_ENV !== 'production',
}),
);
Loading