From 985f206e02ba76249aa2d2018b3b7566caeca575 Mon Sep 17 00:00:00 2001 From: Eric Kwoka <43540491+ekwoka@users.noreply.github.com> Date: Thu, 23 Nov 2023 13:33:57 +0400 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Improves=20functionality?= =?UTF-8?q?=20and=20API=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Refactors and fixes some bugs * :test_tube: Tests --- packages/params/src/index.ts | 414 ++++++++++++++++------------ packages/params/src/pathresolve.ts | 76 +++++ packages/params/src/querystring.ts | 85 ++++++ packages/params/testSite/index.html | 16 +- size.json | 8 +- 5 files changed, 414 insertions(+), 185 deletions(-) create mode 100644 packages/params/src/pathresolve.ts create mode 100644 packages/params/src/querystring.ts diff --git a/packages/params/src/index.ts b/packages/params/src/index.ts index 919b95a..e2f0dba 100644 --- a/packages/params/src/index.ts +++ b/packages/params/src/index.ts @@ -1,69 +1,102 @@ -import type { PluginCallback, InterceptorObject } from 'alpinejs'; +import type { PluginCallback, InterceptorObject, Alpine } from 'alpinejs'; +import { fromQueryString, toQueryString } from './querystring'; +import { + retrieveDotNotatedValueFromData, + objectAtPath, + deleteDotNotatedValueFromData, + insertDotNotatedValueIntoData, +} from './pathresolve'; + +class QueryInterceptor implements InterceptorObject { + _x_interceptor = true as const; + private alias: string | undefined = undefined; + private transformer: Transformer | null = null; + private method: 'replaceState' | 'pushState' = 'replaceState'; + private show: boolean = false; + constructor( + public initialValue: T, + private Alpine: Alpine, + private reactiveParams: Record, + ) {} + initialize(data: Record, path: string) { + const { + alias = path, + initialValue, + reactiveParams, + transformer, + show, + } = this; + const initial = + (retrieveDotNotatedValueFromData(alias, reactiveParams) as T) ?? + initialValue; + + const keys = path.split('.'); + const final = keys.pop()!; + const obj = objectAtPath(keys, data, final); + + Object.defineProperty(obj, final, { + set: (value: T) => { + !show && value === initialValue + ? deleteDotNotatedValueFromData(alias, reactiveParams) + : insertDotNotatedValueIntoData(alias, value, reactiveParams); + this.setParams(); + }, + get: () => { + const value = (retrieveDotNotatedValueFromData(alias, reactiveParams) ?? + initialValue) as T; + return value; + }, + enumerable: true, + }); + + return transformer?.(initial) ?? initial; + } + private setParams() { + const { reactiveParams, method, Alpine } = this; + history[method]( + intoState(Alpine.raw(reactiveParams)), + '', + `?${toQueryString(Alpine.raw(reactiveParams))}`, + ); + } + as(name: string) { + this.alias = name; + return this; + } + into(fn: Transformer) { + this.transformer = fn; + return this; + } + alwaysShow() { + this.show = true; + return this; + } + usePush() { + this.method = 'pushState'; + return this; + } +} export const query: PluginCallback = (Alpine) => { const reactiveParams: Record = Alpine.reactive( fromQueryString(location.search), ); - const intoState = () => - Object.assign({}, history.state ?? {}, { - query: JSON.parse(JSON.stringify(Alpine.raw(reactiveParams))), - }); - window.addEventListener('popstate', (event) => { - if (event.state?.query) Object.assign(reactiveParams, event.state.query); + 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]; }); - Alpine.effect(() => { - if (JSON.stringify(reactiveParams) === JSON.stringify(history.state?.query)) - return; - history.pushState(intoState(), '', `?${toQueryString(reactiveParams)}`); - }); - - const bindQuery = (): ((initial: T) => QueryInterceptor) => { - let alias: string; - const obj: QueryInterceptor = { - initialValue: undefined as T, - _x_interceptor: true, - initialize(data, path) { - const lookup = alias || path; - const initial = - retrieveDotNotatedValueFromData(lookup, reactiveParams) ?? - this.initialValue; - - const keys = path.split('.'); - const final = keys[keys.length - 1]; - data = objectAtPath(keys, data); - Object.defineProperty(data, final, { - set(value: T) { - insertDotNotatedValueIntoData(lookup, value, reactiveParams); - }, - get() { - return retrieveDotNotatedValueFromData(lookup, reactiveParams) as T; - }, - }); - - return initial as T; - }, - as(name: string) { - alias = name; - return this; - }, - }; + const bindQuery = (initial: T) => + new QueryInterceptor(initial, Alpine, reactiveParams); - return (initial) => { - obj.initialValue = initial; - return obj; - }; - }; - - Alpine.query = (val: T) => bindQuery()(val) as QueryInterceptor; - Alpine.magic('query', () => bindQuery()); + Alpine.query = bindQuery; + Alpine.magic('query', () => bindQuery); }; -type QueryInterceptor = InterceptorObject & { - as: (name: string) => QueryInterceptor; -}; +type Transformer = (val: string | T) => T; export default query; @@ -73,129 +106,168 @@ declare module 'alpinejs' { } } -/** - * Converts Objects to bracketed query strings - * { items: [['foo']] } -> "items[0][0]=foo" - * @param params Object to convert to query string - */ -const toQueryString = (data: object) => { - const entries = buildQueryStringEntries(data); - - return entries.map((entry) => entry.join('=')).join('&'); -}; - -const isObjectLike = (subject: unknown): subject is object => - typeof subject === 'object' && subject !== null; - -const buildQueryStringEntries = ( - data: object, - entries: [string, string][] = [], - baseKey = '', -) => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey ? `${baseKey}[${iKey}]` : iKey; - if (iValue === undefined) return; - if (!isObjectLike(iValue)) - entries.push([ - key, - encodeURIComponent(iValue) - .replaceAll('%20', '+') // Conform to RFC1738 - .replaceAll('%2C', ','), - ]); - else buildQueryStringEntries(iValue, entries, key); +const intoState = (data: Record) => + Object.assign({}, history.state ?? {}, { + query: JSON.parse(JSON.stringify(data)), }); - return entries; -}; - -const fromQueryString = (queryString: string) => { - const data: Record = {}; - if (queryString === '') return data; - - const entries = new URLSearchParams(queryString).entries(); - - for (const [key, value] of entries) { - // Query string params don't always have values... (`?foo=`) - if (!value) continue; - - const decoded = value; - - if (!key.includes('[')) data[key] = decoded; - else { - // Convert to dot notation because it's easier... - const dotNotatedKey = key.replaceAll(/\[([^\]]+)\]/g, '.$1'); - insertDotNotatedValueIntoData(dotNotatedKey, decoded, data); - } - } - - return data; -}; - -const objectAtPath = (keys: string[], data: Record) => { - const final = keys.pop()!; - while (keys.length) { - const key = keys.shift()!; - - // This is where we fill in empty arrays/objects allong the way to the assigment... - if (data[key] === undefined) - data[key] = isNaN(Number(keys[0] ?? final)) ? {} : []; - data = data[key] as Record; - // Keep deferring assignment until the full key is built up... - } - return data; -}; - -const insertDotNotatedValueIntoData = ( - key: string, - value: unknown, - data: Record, -) => { - const keys = key.split('.'); - const final = keys[keys.length - 1]; - data = objectAtPath(keys, data); - data[final] = value; -}; - -const retrieveDotNotatedValueFromData = ( - key: string, - data: Record, -) => { - const keys = key.split('.'); - const final = keys[keys.length - 1]; - data = objectAtPath(keys, data); - return data[final]; -}; - if (import.meta.vitest) { - describe('QueryString', () => { - it('builds query string from objects', () => { - expect(toQueryString({ foo: 'bar' })).toBe('foo=bar'); - expect(toQueryString({ foo: ['bar'] })).toBe('foo[0]=bar'); - expect(toQueryString({ foo: [['bar']] })).toBe('foo[0][0]=bar'); - expect(toQueryString({ foo: { bar: 'baz' } })).toBe('foo[bar]=baz'); - expect(toQueryString({ foo: { bar: ['baz'] } })).toBe('foo[bar][0]=baz'); - expect(toQueryString({ foo: 'bar', fizz: [['buzz']] })).toBe( - 'foo=bar&fizz[0][0]=buzz', + describe('QueryInterceptor', () => { + const Alpine = { + raw(val: T): T { + return val; + }, + reactive(val: T): T { + return val; + }, + } as unknown as Alpine; + afterEach(() => { + vi.restoreAllMocks(); + }); + it('defines value on the data', () => { + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor('hello', Alpine, paramObject).initialize( + data, + 'foo', + ); + expect(data).toEqual({ foo: 'hello' }); + }); + it('stores value in the params', () => { + const paramObject = {}; + const interceptor = new QueryInterceptor('hello', Alpine, paramObject); + const data = { foo: 'bar' }; + interceptor.initialize(data, 'foo'); + expect(data).toEqual({ foo: 'hello' }); + data.foo = 'world'; + expect(paramObject).toEqual({ foo: 'world' }); + expect(data).toEqual({ foo: 'world' }); + }); + it('returns initial value from initialize', () => { + expect( + new QueryInterceptor('hello', Alpine, {}).initialize({}, 'foo'), + ).toBe('hello'); + }); + it('initializes with value from params', () => { + const paramObject = { foo: 'hello' }; + const data = { foo: 'bar' }; + expect( + new QueryInterceptor('hello', Alpine, paramObject).initialize( + data, + 'foo', + ), + ).toBe('hello'); + expect(data).toEqual({ foo: 'hello' }); + }); + it('updates history state', () => { + vi.spyOn(history, 'replaceState'); + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor('hello', Alpine, paramObject).initialize( + data, + 'foo', + ); + expect(data).toEqual({ foo: 'hello' }); + data.foo = 'world'; + expect(paramObject).toEqual({ foo: 'world' }); + expect(data).toEqual({ foo: 'world' }); + expect(history.replaceState).toHaveBeenCalledWith( + { query: { foo: 'world' } }, + '', + '?foo=world', + ); + data.foo = 'fizzbuzz'; + expect(paramObject).toEqual({ foo: 'fizzbuzz' }); + expect(data).toEqual({ foo: 'fizzbuzz' }); + expect(history.replaceState).toHaveBeenCalledWith( + { query: { foo: 'fizzbuzz' } }, + '', + '?foo=fizzbuzz', ); - expect(toQueryString({ foo: 'fizz buzz, foo bar' })).toBe( - 'foo=fizz+buzz,+foo+bar', + }); + it('can alias the key', () => { + vi.spyOn(history, 'replaceState'); + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor('hello', Alpine, paramObject) + .as('bar') + .initialize(data, 'foo'); + expect(data).toEqual({ foo: 'hello' }); + data.foo = 'world'; + expect(paramObject).toEqual({ bar: 'world' }); + expect(data).toEqual({ foo: 'world' }); + expect(history.replaceState).toHaveBeenCalledWith( + { query: { bar: 'world' } }, + '', + '?bar=world', + ); + }); + it('can transform the initial query value', () => { + const paramObject = { count: '1' }; + const data = { count: 0 }; + data.count = new QueryInterceptor(0, Alpine, paramObject) + .into(Number) + .initialize(data, 'count'); + expect(data).toEqual({ count: 1 }); + expect(paramObject).toEqual({ count: 1 }); + }); + it('does not display inital value', () => { + vi.spyOn(history, 'replaceState'); + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor(data.foo, Alpine, paramObject).initialize( + data, + 'foo', ); + data.foo = 'hello'; + expect(data).toEqual({ foo: 'hello' }); + expect(paramObject).toEqual({ foo: 'hello' }); + data.foo = 'bar'; + expect(data).toEqual({ foo: 'bar' }); + expect(paramObject).toEqual({}); + expect(history.replaceState).toHaveBeenCalledWith({ query: {} }, '', '?'); }); - it('parses query string to objects', () => { - expect(fromQueryString('foo=bar')).toEqual({ foo: 'bar' }); - expect(fromQueryString('foo[0]=bar')).toEqual({ foo: ['bar'] }); - expect(fromQueryString('foo[0][0]=bar')).toEqual({ foo: [['bar']] }); - expect(fromQueryString('foo[bar]=baz')).toEqual({ foo: { bar: 'baz' } }); - expect(fromQueryString('foo[bar][0]=baz')).toEqual({ - foo: { bar: ['baz'] }, - }); - expect(fromQueryString('foo=bar&fizz[0][0]=buzz')).toEqual({ - foo: 'bar', - fizz: [['buzz']], - }); - expect(fromQueryString('foo=fizz+buzz,+foo+bar')).toEqual({ - foo: 'fizz buzz, foo bar', - }); + it('can always show the initial value', () => { + vi.spyOn(history, 'replaceState'); + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor(data.foo, Alpine, paramObject) + .alwaysShow() + .initialize(data, 'foo'); + data.foo = 'hello'; + expect(data).toEqual({ foo: 'hello' }); + expect(paramObject).toEqual({ foo: 'hello' }); + expect(history.replaceState).toHaveBeenCalledWith( + { query: { foo: 'hello' } }, + '', + '?foo=hello', + ); + data.foo = 'bar'; + expect(data).toEqual({ foo: 'bar' }); + expect(paramObject).toEqual({ foo: 'bar' }); + expect(history.replaceState).toHaveBeenCalledWith( + { query: { foo: 'bar' } }, + '', + '?foo=bar', + ); + }); + it('can use pushState', () => { + vi.spyOn(history, 'replaceState'); + vi.spyOn(history, 'pushState'); + const paramObject = {}; + const data = { foo: 'bar' }; + new QueryInterceptor(data.foo, Alpine, paramObject) + .usePush() + .initialize(data, 'foo'); + data.foo = 'hello'; + expect(data).toEqual({ foo: 'hello' }); + expect(paramObject).toEqual({ foo: 'hello' }); + expect(history.pushState).toHaveBeenCalledWith( + { query: { foo: 'hello' } }, + '', + '?foo=hello', + ); + expect(history.replaceState).not.toHaveBeenCalled(); }); }); } diff --git a/packages/params/src/pathresolve.ts b/packages/params/src/pathresolve.ts new file mode 100644 index 0000000..cbb31cf --- /dev/null +++ b/packages/params/src/pathresolve.ts @@ -0,0 +1,76 @@ +export const objectAtPath = ( + keys: string[], + data: Record, + final?: string, +) => { + while (keys.length) { + const key = keys.shift()!; + + // This is where we fill in empty arrays/objects allong the way to the assigment... + if (data[key] === undefined) + data[key] = isNaN(Number(keys[0] ?? final)) ? {} : []; + data = data[key] as Record; + // Keep deferring assignment until the full key is built up... + } + return data; +}; + +export const insertDotNotatedValueIntoData = ( + path: string, + value: unknown, + data: Record, +) => { + const keys = path.split('.'); + const final = keys.pop()!; + data = objectAtPath(keys, data, final); + data[final] = value; +}; + +export const retrieveDotNotatedValueFromData = ( + path: string, + data: Record, +) => { + const keys = path.split('.'); + const final = keys.pop()!; + data = objectAtPath(keys, data, final); + return data[final]; +}; + +export const deleteDotNotatedValueFromData = ( + path: string, + data: Record, +) => { + const keys = path.split('.'); + const final = keys.pop()!; + data = objectAtPath(keys, data, final); + delete data[final]; +}; + +if (import.meta.vitest) { + describe('Object Path Resolution', () => { + const data = { foo: { fizz: 'buzz', bar: { qwux: 'quuz' } } }; + it.each([ + [['foo'], data.foo], + [['foo', 'bar'], data.foo.bar], + [['foo', 'fizz'], data.foo.fizz], + ])('object at path %s is %s', (path, expected) => { + expect(objectAtPath(path, data)).toBe(expected); + }); + it.each([ + ['foo', data.foo], + ['foo.bar', data.foo.bar], + ['foo.fizz', data.foo.fizz], + ])('value at path %s is %o', (path, expected) => { + expect(retrieveDotNotatedValueFromData(path, data)).toBe(expected); + }); + it.each([ + ['foo', 'hello'], + ['foo.bar', [1, 2]], + ['foo.fizz', { hello: 'world' }], + ])('value at path %s is %o', (path, toInsert) => { + const innerdata = structuredClone(data); + insertDotNotatedValueIntoData(path, toInsert, innerdata); + expect(retrieveDotNotatedValueFromData(path, innerdata)).toBe(toInsert); + }); + }); +} diff --git a/packages/params/src/querystring.ts b/packages/params/src/querystring.ts new file mode 100644 index 0000000..b43fcbb --- /dev/null +++ b/packages/params/src/querystring.ts @@ -0,0 +1,85 @@ +import { insertDotNotatedValueIntoData } from './pathresolve'; + +/** + * Converts Objects to bracketed query strings + * { items: [['foo']] } -> "items[0][0]=foo" + * @param params Object to convert to query string + */ +export const toQueryString = (data: object) => { + const entries = buildQueryStringEntries(data); + + return entries.map((entry) => entry.join('=')).join('&'); +}; + +export const isObjectLike = (subject: unknown): subject is object => + typeof subject === 'object' && subject !== null; + +export const buildQueryStringEntries = ( + data: object, + entries: [string, string][] = [], + baseKey = '', +) => { + Object.entries(data).forEach(([iKey, iValue]) => { + const key = baseKey ? `${baseKey}[${iKey}]` : iKey; + if (iValue === undefined) return; + if (!isObjectLike(iValue)) + entries.push([ + key, + encodeURIComponent(iValue) + .replaceAll('%20', '+') // Conform to RFC1738 + .replaceAll('%2C', ','), + ]); + else buildQueryStringEntries(iValue, entries, key); + }); + + return entries; +}; + +export const fromQueryString = (queryString: string) => { + const data: Record = {}; + if (queryString === '') return data; + + const entries = new URLSearchParams(queryString).entries(); + + for (const [key, value] of entries) { + // Query string params don't always have values... (`?foo=`) + if (!value) continue; + + const decoded = value; + + if (!key.includes('[')) data[key] = decoded; + else { + // Convert to dot notation because it's easier... + const dotNotatedKey = key.replaceAll(/\[([^\]]+)\]/g, '.$1'); + insertDotNotatedValueIntoData(dotNotatedKey, decoded, data); + } + } + + return data; +}; + +if (import.meta.vitest) { + describe('QueryString', () => { + const cases: [object, string][] = [ + [{ foo: 'bar' }, 'foo=bar'], + [{ foo: ['bar'] }, 'foo[0]=bar'], + [{ foo: [['bar']] }, 'foo[0][0]=bar'], + [{ foo: { bar: 'baz' } }, 'foo[bar]=baz'], + [{ foo: { bar: ['baz'] } }, 'foo[bar][0]=baz'], + [{ foo: 'bar', fizz: [['buzz']] }, 'foo=bar&fizz[0][0]=buzz'], + [{ foo: 'fizz buzz, foo bar' }, 'foo=fizz+buzz,+foo+bar'], + ]; + it.each(cases)( + 'builds %o into query string %s', + (obj: object, str: string) => { + expect(toQueryString(obj)).toBe(str); + }, + ); + it.each(cases)( + 'parses %o from query string %s', + (obj: object, str: string) => { + expect(fromQueryString(str)).toEqual(obj); + }, + ); + }); +} diff --git a/packages/params/testSite/index.html b/packages/params/testSite/index.html index 3de039f..01b4fa2 100644 --- a/packages/params/testSite/index.html +++ b/packages/params/testSite/index.html @@ -9,18 +9,14 @@ import query from '../src/index.ts'; Alpine.plugin(query); Alpine.data('test', () => ({ - tab: Alpine.query({ - tab: 'company, [one]', - other: 'this \\$!@#%^&*()["thing"]', - }), - tabs: [undefined, 'company three', 'account', 'user'], - index: 0, + count: Alpine.query(0).into(Number), + date: Alpine.query(new Date().toString()).as('now').alwaysShow(), })); Alpine.start(); - + +
+
+ diff --git a/size.json b/size.json index 62d0a27..84cfc95 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "params": { "minified": { - "pretty": "1.43 kB", - "raw": 1425 + "pretty": "1.84 kB", + "raw": 1842 }, "brotli": { - "pretty": "712 B", - "raw": 712 + "pretty": "875 B", + "raw": 875 } }, "xajax": {