|
@@ -1,20 +1,35 @@
|
|
|
+
|
|
|
export default function history(Alpine) {
|
|
|
Alpine.magic('queryString', (el, { interceptor }) => {
|
|
|
let alias
|
|
|
+ let alwaysShow = false
|
|
|
+ let usePush = false
|
|
|
|
|
|
- return interceptor((initialValue, getter, setter, path, key) => {
|
|
|
+ return interceptor((initialSeedValue, getter, setter, path, key) => {
|
|
|
let queryKey = alias || path
|
|
|
|
|
|
- let { initial, replace } = track(queryKey, initialValue)
|
|
|
+ let { initial, replace, push, pop } = track(queryKey, initialSeedValue, alwaysShow)
|
|
|
|
|
|
setter(initial)
|
|
|
|
|
|
- Alpine.effect(() => {
|
|
|
- replace(getter())
|
|
|
- })
|
|
|
+ if (! usePush) {
|
|
|
+ Alpine.effect(() => replace(getter()))
|
|
|
+ } else {
|
|
|
+ Alpine.effect(() => push(getter()))
|
|
|
+
|
|
|
+ pop(async newValue => {
|
|
|
+ setter(newValue)
|
|
|
+
|
|
|
+ let tillTheEndOfTheMicrotaskQueue = () => Promise.resolve()
|
|
|
+
|
|
|
+ await tillTheEndOfTheMicrotaskQueue() // ...so that we preserve the internal lock...
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
return initial
|
|
|
}, func => {
|
|
|
+ func.alwaysShow = () => { alwaysShow = true; return func }
|
|
|
+ func.usePush = () => { usePush = true; return func }
|
|
|
func.as = key => { alias = key; return func }
|
|
|
})
|
|
|
})
|
|
@@ -22,57 +37,65 @@ export default function history(Alpine) {
|
|
|
Alpine.history = { track }
|
|
|
}
|
|
|
|
|
|
-export function track(name, initialValue, except = null) {
|
|
|
- let pause = false
|
|
|
- let url = new URL(window.location.href)
|
|
|
- let value = initialValue
|
|
|
+export function track(name, initialSeedValue, alwaysShow = false) {
|
|
|
+ let { has, get, set, remove } = queryStringUtils()
|
|
|
|
|
|
- if (url.searchParams.has(name)) {
|
|
|
- value = url.searchParams.get(name)
|
|
|
- }
|
|
|
-
|
|
|
- // Nothing happens here...
|
|
|
- // let object = { value }
|
|
|
+ let url = new URL(window.location.href)
|
|
|
+ let isInitiallyPresentInUrl = has(url, name)
|
|
|
+ let initialValue = isInitiallyPresentInUrl ? get(url, name) : initialSeedValue
|
|
|
+ let initialValueMemo = JSON.stringify(initialValue)
|
|
|
+ let hasReturnedToInitialValue = (newValue) => JSON.stringify(newValue) === initialValueMemo
|
|
|
|
|
|
- // url.searchParams.set(name, value)
|
|
|
+ if (alwaysShow) url = set(url, name, initialValue)
|
|
|
|
|
|
- // replace(url.toString(), name, object)
|
|
|
+ replace(url, name, { value: initialValue })
|
|
|
|
|
|
- return {
|
|
|
- initial: value, // Initial value...
|
|
|
- replace(newValue) { // Update via replaceState...
|
|
|
- let object = { value: newValue }
|
|
|
+ let lock = false
|
|
|
|
|
|
- let url = new URL(window.location.href)
|
|
|
+ let update = (strategy, newValue) => {
|
|
|
+ if (lock) return
|
|
|
|
|
|
- url.searchParams.set(name, newValue)
|
|
|
+ let url = new URL(window.location.href)
|
|
|
|
|
|
- replace(url.toString(), name, object)
|
|
|
- },
|
|
|
- push(newValue) { // Update via pushState...
|
|
|
- if (pause) return
|
|
|
+ if (! alwaysShow && ! isInitiallyPresentInUrl && hasReturnedToInitialValue(newValue)) {
|
|
|
+ url = remove(url, name)
|
|
|
+ } else {
|
|
|
+ url = set(url, name, newValue)
|
|
|
+ }
|
|
|
|
|
|
- let object = { value: newValue }
|
|
|
+ strategy(url, name, { value: newValue})
|
|
|
+ }
|
|
|
|
|
|
- let url = new URL(window.location.href)
|
|
|
+ return {
|
|
|
+ initial: initialValue,
|
|
|
|
|
|
- url.searchParams.set(name, newValue)
|
|
|
+ replace(newValue) { // Update via replaceState...
|
|
|
+ update(replace, newValue)
|
|
|
+ },
|
|
|
|
|
|
- push(url.toString(), name, object)
|
|
|
+ push(newValue) { // Update via pushState...
|
|
|
+ update(push, newValue)
|
|
|
},
|
|
|
- pop(receiver) { // onPopState...
|
|
|
+
|
|
|
+ pop(receiver) { // "popstate" handler...
|
|
|
window.addEventListener('popstate', (e) => {
|
|
|
- if (! e.state) return
|
|
|
- if (! e.state.alpine) return
|
|
|
+ if (! e.state || ! e.state.alpine) return
|
|
|
|
|
|
- Object.entries(e.state.alpine).forEach(([newName, { value }]) => {
|
|
|
- if (newName !== name) return
|
|
|
+ Object.entries(e.state.alpine).forEach(([iName, { value: newValue }]) => {
|
|
|
+ if (iName !== name) return
|
|
|
|
|
|
- pause = true
|
|
|
+ lock = true
|
|
|
|
|
|
- receiver(value)
|
|
|
+ // Allow the "receiver" to be an async function in case a non-syncronous
|
|
|
+ // operation (like an ajax) requests needs to happen while preserving
|
|
|
+ // the "locking" mechanism ("lock = true" in this case)...
|
|
|
+ let result = receiver(newValue)
|
|
|
|
|
|
- pause = false
|
|
|
+ if (result instanceof Promise) {
|
|
|
+ result.finally(() => lock = false)
|
|
|
+ } else {
|
|
|
+ lock = false
|
|
|
+ }
|
|
|
})
|
|
|
})
|
|
|
}
|
|
@@ -84,72 +107,127 @@ function replace(url, key, object) {
|
|
|
|
|
|
if (! state.alpine) state.alpine = {}
|
|
|
|
|
|
- state.alpine[key] = object
|
|
|
+ state.alpine[key] = unwrap(object)
|
|
|
|
|
|
- window.history.replaceState(state, '', url)
|
|
|
+ window.history.replaceState(state, '', url.toString())
|
|
|
}
|
|
|
|
|
|
function push(url, key, object) {
|
|
|
- let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
|
|
|
+ let state = { alpine: {...window.history.state.alpine, ...{[key]: unwrap(object)}} }
|
|
|
|
|
|
- window.history.pushState(state, '', url)
|
|
|
+ window.history.pushState(state, '', url.toString())
|
|
|
}
|
|
|
|
|
|
-// Alpine.magic('queryString', (el, { interceptor }) => {
|
|
|
-// let alias
|
|
|
+function unwrap(object) {
|
|
|
+ return JSON.parse(JSON.stringify(object))
|
|
|
+}
|
|
|
|
|
|
-// return interceptor((initialValue, getter, setter, path, key) => {
|
|
|
-// let pause = false
|
|
|
-// let queryKey = alias || path
|
|
|
+function queryStringUtils() {
|
|
|
+ return {
|
|
|
+ has(url, key) {
|
|
|
+ let search = url.search
|
|
|
|
|
|
-// let value = initialValue
|
|
|
-// let url = new URL(window.location.href)
|
|
|
+ if (! search) return false
|
|
|
|
|
|
-// if (url.searchParams.has(queryKey)) {
|
|
|
-// value = url.searchParams.get(queryKey)
|
|
|
-// }
|
|
|
+ let data = fromQueryString(search)
|
|
|
|
|
|
-// setter(value)
|
|
|
+ return Object.keys(data).includes(key)
|
|
|
+ },
|
|
|
+ get(url, key) {
|
|
|
+ let search = url.search
|
|
|
|
|
|
-// let object = { value }
|
|
|
+ if (! search) return false
|
|
|
|
|
|
-// url.searchParams.set(queryKey, value)
|
|
|
+ let data = fromQueryString(search)
|
|
|
|
|
|
-// replace(url.toString(), path, object)
|
|
|
+ return data[key]
|
|
|
+ },
|
|
|
+ set(url, key, value) {
|
|
|
+ let data = fromQueryString(url.search)
|
|
|
|
|
|
-// window.addEventListener('popstate', (e) => {
|
|
|
-// if (! e.state) return
|
|
|
-// if (! e.state.alpine) return
|
|
|
+ data[key] = value
|
|
|
|
|
|
-// Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
|
|
|
-// if (newKey !== key) return
|
|
|
+ url.search = toQueryString(data)
|
|
|
|
|
|
-// pause = true
|
|
|
+ return url
|
|
|
+ },
|
|
|
+ remove(url, key) {
|
|
|
+ let data = fromQueryString(url.search)
|
|
|
+
|
|
|
+ delete data[key]
|
|
|
+
|
|
|
+ url.search = toQueryString(data)
|
|
|
+
|
|
|
+ return url
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// This function converts JavaScript data to bracketed query string notation...
|
|
|
+// { items: [['foo']] } -> "items[0][0]=foo"
|
|
|
+function toQueryString(data) {
|
|
|
+ let isObjecty = (subject) => typeof subject === 'object' && subject !== null
|
|
|
+
|
|
|
+ let buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
|
|
|
+ Object.entries(data).forEach(([iKey, iValue]) => {
|
|
|
+ let key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`
|
|
|
+
|
|
|
+ if (! isObjecty(iValue)) {
|
|
|
+ entries[key] = encodeURIComponent(iValue)
|
|
|
+ .replaceAll('%20', '+') // Conform to RFC1738
|
|
|
+ } else {
|
|
|
+ entries = {...entries, ...buildQueryStringEntries(iValue, entries, key)}
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return entries
|
|
|
+ }
|
|
|
+
|
|
|
+ let entries = buildQueryStringEntries(data)
|
|
|
|
|
|
-// Alpine.disableEffectScheduling(() => {
|
|
|
-// setter(value)
|
|
|
-// })
|
|
|
|
|
|
-// pause = false
|
|
|
-// })
|
|
|
-// })
|
|
|
+ return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join('&')
|
|
|
+}
|
|
|
+
|
|
|
+// This function converts bracketed query string notation back to JS data...
|
|
|
+// "items[0][0]=foo" -> { items: [['foo']] }
|
|
|
+function fromQueryString(search) {
|
|
|
+ search = search.replace('?', '')
|
|
|
+
|
|
|
+ if (search === '') return {}
|
|
|
+
|
|
|
+ let insertDotNotatedValueIntoData = (key, value, data) => {
|
|
|
+ let [first, second, ...rest] = key.split('.')
|
|
|
+
|
|
|
+ // We're at a leaf node, let's make the assigment...
|
|
|
+ if (! second) return data[key] = value
|
|
|
+
|
|
|
+ // This is where we fill in empty arrays/objects allong the way to the assigment...
|
|
|
+ if (data[first] === undefined) {
|
|
|
+ data[first] = isNaN(second) ? {} : []
|
|
|
+ }
|
|
|
+
|
|
|
+ // Keep deferring assignment until the full key is built up...
|
|
|
+ insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first])
|
|
|
+ }
|
|
|
|
|
|
-// Alpine.effect(() => {
|
|
|
-// let value = getter()
|
|
|
+ let entries = search.split('&').map(i => i.split('='))
|
|
|
|
|
|
-// if (pause) return
|
|
|
+ let data = {}
|
|
|
|
|
|
-// let object = { value }
|
|
|
+ entries.forEach(([key, value]) => {
|
|
|
+ value = decodeURIComponent(value.replaceAll('+', '%20'))
|
|
|
|
|
|
-// let url = new URL(window.location.href)
|
|
|
+ if (! key.includes('[')) {
|
|
|
+ data[key] = value
|
|
|
+ } else {
|
|
|
+ // Convert to dot notation because it's easier...
|
|
|
+ let dotNotatedKey = key.replaceAll('[', '.').replaceAll(']', '')
|
|
|
|
|
|
-// url.searchParams.set(queryKey, value)
|
|
|
+ insertDotNotatedValueIntoData(dotNotatedKey, value, data)
|
|
|
+ }
|
|
|
+ })
|
|
|
|
|
|
-// push(url.toString(), path, object)
|
|
|
-// })
|
|
|
+ return data
|
|
|
+}
|
|
|
|
|
|
-// return value
|
|
|
-// }, func => {
|
|
|
-// func.as = key => { alias = key; return func }
|
|
|
-// })
|
|
|
-// })
|