Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚧 Improves Support #18

Merged
merged 4 commits into from
Nov 29, 2023
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
49 changes: 20 additions & 29 deletions packages/params/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,45 +173,36 @@ If you have nested primitives like booleans and numbers, you can use `.into` to

You may choose to use separate `$query` interceptors to make this simpler.

## Reactivity
## `observeHistory`

All normal reactive behaviors apply to the `$query` interceptor. You can hook up effects to them, and just have a grand old time.
You might want to use `$query` and have other tools that make changes to the query. By default, when `$query` intercepted values change, it is unaware of any other changes made to the `URL` and those change may be removed.

## What This Doesn't Do

This plugin does not do anything to manage params not associated with an `$query` interceptor. This means that if you have a query string like `?search=hello&sort=asc` and you only have a `$query` interceptor for `search`, the `sort` param will be perpetuated during query string updates.
To handle this, you can import `observeHistory` and call it (with a `History` object, or it will default to `globalThis.history`), and the `pushState` and `replaceState` methods will be wrapped to update the reactive params when they are called.

This does not directly expose anything for triggering events or handlers on query string changes. As the query interceptors are reactive, you can hook directly into the ones you care about and use Alpine Effects to trigger events or other behaviors.

## Use outside of Alpine
```js
import Alpine from 'alpinets/src';
import { query, observeHistory } from '../src/index.ts';
Alpine.plugin(query);
Alpine.data('test', () => ({
count: Alpine.query(0).into(Number),
}));
observeHistory();
Alpine.start();

This Alpine plugin can actually be used outside of Alpine, though it's obviously not ideal for many reason. It's a bit of a hack, but it works!
history.pushState({}, '', '?count=123');
```

```ts
import { QueryInterceptor } from '@ekwoka/alpine-history';
This is not needed to handle `popState` events which are already handled by the plugin.

const params: Record<string, unknown> = {}; // internal object structure to store the params
## Reactivity

const myData = {
search: '',
};
All normal reactive behaviors apply to the `$query` interceptor. You can hook up effects to them, and just have a grand old time.

new QueryInterceptor(
'',
{
raw: <T>(v: T): T => {
v;
},
},
params,
)
.as('q')
.initialize(myData, 'search');
```
## What This Doesn't Do

Not example pretty, but it works!
This plugin does not do anything to manage params not associated with an `$query` interceptor. This means that if you have a query string like `?search=hello&sort=asc` and you only have a `$query` interceptor for `search`, the `sort` param will be perpetuated during query string updates.

This can allow you to use other reactive objects, like Solid Stores. But mostly, this is a hack, but fun!
This does not directly expose anything for triggering events or handlers on query string changes. As the query interceptors are reactive, you can hook directly into the ones you care about and use Alpine Effects to trigger events or other behaviors.

## Author

Expand Down
35 changes: 35 additions & 0 deletions packages/params/src/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
type StateUpdateCallback = (url: URL) => void;

const stateUpdateHandlers: StateUpdateCallback[] = [];

export const onURLChange = (callback: StateUpdateCallback) =>
stateUpdateHandlers.push(callback);

let skip = false;
export const untrack = (cb: () => void) => {
skip = true;
cb();
skip = false;
};

export const observeHistory = (
injectHistory: Pick<History, UpdateMethod> = history,
) => {
[UpdateMethod.replace, UpdateMethod.push].forEach((method) => {
const original = injectHistory[method];
injectHistory[method] = (
data: unknown,
title: string,
url?: string | null,
) => {
original.call(injectHistory, data, title, url);
if (skip) return;
stateUpdateHandlers.forEach((handler) => handler(new URL(location.href)));
};
});
};

export enum UpdateMethod {
replace = 'replaceState',
push = 'pushState',
}
128 changes: 80 additions & 48 deletions packages/params/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deleteDotNotatedValueFromData,
insertDotNotatedValueIntoData,
} from './pathresolve';
import { UpdateMethod, onURLChange, untrack } from './history';

type InnerType<T, S> = T extends PrimitivesToStrings<T>
? T
Expand All @@ -19,20 +20,18 @@ type InnerType<T, S> = T extends PrimitivesToStrings<T>
* This hooks up setter/getter methods to to replace the object itself
* and sync the query string params
*/
export class QueryInterceptor<
T,
S extends Transformer<T> | undefined = undefined,
> implements InterceptorObject<InnerType<T, S>>
class QueryInterceptor<T, S extends Transformer<T> | undefined = undefined>
implements InterceptorObject<InnerType<T, S>>
{
_x_interceptor = true as const;
private alias: string | undefined = undefined;
private transformer?: S;
private method: 'replaceState' | 'pushState' = 'replaceState';
private method: UpdateMethod = UpdateMethod.replace;
private show: boolean = false;
public initialValue: InnerType<T, S>;
constructor(
initialValue: T,
private Alpine: Pick<Alpine, 'raw'>,
private Alpine: Pick<Alpine, 'effect'>,
private reactiveParams: Record<string, unknown>,
) {
this.initialValue = initialValue as InnerType<T, S>;
Expand All @@ -46,10 +45,12 @@ export class QueryInterceptor<
initialize(data: Record<string, unknown>, path: string): InnerType<T, S> {
const {
alias = path,
Alpine,
initialValue,
method,
reactiveParams,
transformer,
show,
transformer,
} = this;
const initial = (retrieveDotNotatedValueFromData(alias, reactiveParams) ??
initialValue) as InnerType<T, S>;
Expand All @@ -63,7 +64,6 @@ export class QueryInterceptor<
!show && value === initialValue
? deleteDotNotatedValueFromData(alias, reactiveParams)
: insertDotNotatedValueIntoData(alias, value, reactiveParams);
this.setParams();
},
get: () => {
const value = (retrieveDotNotatedValueFromData(alias, reactiveParams) ??
Expand All @@ -73,19 +73,10 @@ export class QueryInterceptor<
enumerable: true,
});

Alpine.effect(paramEffect(alias, reactiveParams, method));

return (transformer?.(initial) ?? initial) as InnerType<T, S>;
}
/**
* Sets the query string params to the current reactive params
*/
private setParams() {
const { reactiveParams, method, Alpine } = this;
history[method](
intoState(Alpine.raw(reactiveParams)),
'',
`?${toQueryString(Alpine.raw(reactiveParams))}`,
);
}
/**
* Changes the keyname for using in the query string
* Keyname defaults to path to data
Expand Down Expand Up @@ -115,7 +106,7 @@ export class QueryInterceptor<
* Use pushState instead of replaceState
*/
usePush() {
this.method = 'pushState';
this.method = UpdateMethod.push;
return this;
}
}
Expand All @@ -125,11 +116,20 @@ export const query: PluginCallback = (Alpine) => {
fromQueryString(location.search),
);

const updateParams = (obj: Record<string, unknown>) => {
Object.assign(reactiveParams, obj);
for (const key in Alpine.raw(reactiveParams))
if (!(key in obj)) delete reactiveParams[key];
};

window.addEventListener('popstate', (event) => {
if (!event.state?.query) return;
if (event.state.query) Object.assign(reactiveParams, event.state.query);
for (const key in Alpine.raw(reactiveParams))
if (!(key in event.state.query)) delete reactiveParams[key];
updateParams(event.state.query);
});

onURLChange((url) => {
const query = fromQueryString(url.search);
updateParams(query);
});

const bindQuery = <T>(initial: T) =>
Expand Down Expand Up @@ -166,18 +166,40 @@ const intoState = <T extends Record<string, unknown>>(
query: JSON.parse(JSON.stringify(data)),
});

const paramEffect = (
key: string,
params: Record<string, unknown>,
method: UpdateMethod,
) => {
let previous = JSON.stringify(params[key]);
return () => {
const current = JSON.stringify(params[key]);
if (current === previous) return;
untrack(() => setParams(params, method));
previous = current;
};
};

/**
* Sets the query string params to the current reactive params
*/
const setParams = (params: Record<string, unknown>, method: UpdateMethod) => {
const queryString = toQueryString(params);
history[method](
intoState(params),
'',
queryString ? `?${queryString}` : location.pathname,
);
};

if (import.meta.vitest) {
describe('QueryInterceptor', () => {
const Alpine = {
raw<T>(val: T): T {
return val;
},
};
describe('QueryInterceptor', async () => {
const Alpine = await import('alpinejs').then((m) => m.default);
afterEach(() => {
vi.restoreAllMocks();
});
it('defines value on the data', () => {
const paramObject = {};
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor('hello', Alpine, paramObject).initialize(
data,
Expand All @@ -186,7 +208,7 @@ if (import.meta.vitest) {
expect(data).toEqual({ foo: 'hello' });
});
it('stores value in the params', () => {
const paramObject = {};
const paramObject = Alpine.reactive({});
const interceptor = new QueryInterceptor('hello', Alpine, paramObject);
const data = { foo: 'bar' };
interceptor.initialize(data, 'foo');
Expand All @@ -211,16 +233,17 @@ if (import.meta.vitest) {
).toBe('hello');
expect(data).toEqual({ foo: 'hello' });
});
it('updates history state', () => {
vi.spyOn(history, 'replaceState');
const paramObject = {};
it('updates history state', async () => {
vi.spyOn(history, UpdateMethod.replace);
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor('hello', Alpine, paramObject).initialize(
data,
'foo',
);
expect(data).toEqual({ foo: 'hello' });
data.foo = 'world';
await Alpine.nextTick();
expect(paramObject).toEqual({ foo: 'world' });
expect(data).toEqual({ foo: 'world' });
expect(history.replaceState).toHaveBeenCalledWith(
Expand All @@ -229,6 +252,7 @@ if (import.meta.vitest) {
'?foo=world',
);
data.foo = 'fizzbuzz';
await Alpine.nextTick();
expect(paramObject).toEqual({ foo: 'fizzbuzz' });
expect(data).toEqual({ foo: 'fizzbuzz' });
expect(history.replaceState).toHaveBeenCalledWith(
Expand All @@ -237,15 +261,16 @@ if (import.meta.vitest) {
'?foo=fizzbuzz',
);
});
it('can alias the key', () => {
vi.spyOn(history, 'replaceState');
const paramObject = {};
it('can alias the key', async () => {
vi.spyOn(history, UpdateMethod.replace);
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor('hello', Alpine, paramObject)
.as('bar')
.initialize(data, 'foo');
expect(data).toEqual({ foo: 'hello' });
data.foo = 'world';
await Alpine.nextTick();
expect(paramObject).toEqual({ bar: 'world' });
expect(data).toEqual({ foo: 'world' });
expect(history.replaceState).toHaveBeenCalledWith(
Expand All @@ -263,30 +288,33 @@ if (import.meta.vitest) {
expect(data).toEqual({ count: 1 });
expect(paramObject).toEqual({ count: 1 });
});
it('does not display inital value', () => {
vi.spyOn(history, 'replaceState');
const paramObject = {};
it('does not display inital value', async () => {
vi.spyOn(history, UpdateMethod.replace);
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor(data.foo, Alpine, paramObject).initialize(
data,
'foo',
);
data.foo = 'hello';
await Alpine.nextTick();
expect(data).toEqual({ foo: 'hello' });
expect(paramObject).toEqual({ foo: 'hello' });
data.foo = 'bar';
await Alpine.nextTick();
expect(data).toEqual({ foo: 'bar' });
expect(paramObject).toEqual({});
expect(history.replaceState).toHaveBeenCalledWith({ query: {} }, '', '?');
expect(history.replaceState).toHaveBeenCalledWith({ query: {} }, '', '/');
});
it('can always show the initial value', () => {
vi.spyOn(history, 'replaceState');
const paramObject = {};
it('can always show the initial value', async () => {
vi.spyOn(history, UpdateMethod.replace);
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor(data.foo, Alpine, paramObject)
.alwaysShow()
.initialize(data, 'foo');
data.foo = 'hello';
await Alpine.nextTick();
expect(data).toEqual({ foo: 'hello' });
expect(paramObject).toEqual({ foo: 'hello' });
expect(history.replaceState).toHaveBeenCalledWith(
Expand All @@ -295,6 +323,7 @@ if (import.meta.vitest) {
'?foo=hello',
);
data.foo = 'bar';
await Alpine.nextTick();
expect(data).toEqual({ foo: 'bar' });
expect(paramObject).toEqual({ foo: 'bar' });
expect(history.replaceState).toHaveBeenCalledWith(
Expand All @@ -303,15 +332,16 @@ if (import.meta.vitest) {
'?foo=bar',
);
});
it('can use pushState', () => {
vi.spyOn(history, 'replaceState');
vi.spyOn(history, 'pushState');
const paramObject = {};
it('can use pushState', async () => {
vi.spyOn(history, UpdateMethod.replace);
vi.spyOn(history, UpdateMethod.push);
const paramObject = Alpine.reactive({});
const data = { foo: 'bar' };
new QueryInterceptor(data.foo, Alpine, paramObject)
.usePush()
.initialize(data, 'foo');
data.foo = 'hello';
await Alpine.nextTick();
expect(data).toEqual({ foo: 'hello' });
expect(paramObject).toEqual({ foo: 'hello' });
expect(history.pushState).toHaveBeenCalledWith(
Expand All @@ -333,3 +363,5 @@ type PrimitivesToStrings<T> = T extends string | number | boolean | null
[K in keyof T]: PrimitivesToStrings<T[K]>;
}
: T;

export { observeHistory } from './history';
Loading