add shared-state-plugin

This commit is contained in:
j3d1 2024-06-23 01:04:32 +02:00
parent e91b64ca97
commit 67375bd281
7 changed files with 455 additions and 24 deletions

View file

@ -28,7 +28,7 @@ export default {
}), }),
methods: { methods: {
...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']), ...mapMutations(['removeToast', 'createToast', 'closeAddBoxModal', 'openAddBoxModal']),
...mapActions(['loadEvents']), ...mapActions(['loadEvents', 'scheduleAfterInit']),
openAddItemModal() { openAddItemModal() {
this.addItemModalOpen = true; this.addItemModalOpen = true;
}, },
@ -44,6 +44,7 @@ export default {
}, },
created: function () { created: function () {
document.title = document.location.hostname; document.title = document.location.hostname;
this.scheduleAfterInit(() => [this.loadEvents()]);
} }
}; };
</script> </script>

View file

@ -27,16 +27,19 @@ export default {
computed: { computed: {
...mapState(['lastUsed']) ...mapState(['lastUsed'])
}, },
created() {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
},
methods: { methods: {
...mapActions(['postItem']), ...mapActions(['postItem', 'loadBoxes', 'scheduleAfterInit']),
saveNewItem() { saveNewItem() {
this.postItem(this.item).then(() => { this.postItem(this.item).then(() => {
this.$emit('close'); this.$emit('close');
}); });
} }
},
created() {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
},
mounted() {
this.scheduleAfterInit(() => [this.loadBoxes()]);
} }
}; };
</script> </script>

View file

@ -0,0 +1,345 @@
import {isProxy, toRaw} from 'vue';
export default (config) => {
if (!('isLoadedKey' in config)) {
throw new Error("isLoadedKey not defined in config");
}
if (('asyncFetch' in config) && !('lastfetched' in config)) {
throw new Error("asyncFetch defined but lastfetched not defined in config");
}
if (config.debug) console.log('plugin created');
const clone = (obj) => {
if (isProxy(obj)) {
obj = toRaw(obj);
}
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj.__proto__ === ({}).__proto__) {
return Object.assign({}, obj);
}
if (obj.__proto__ === [].__proto__) {
return obj.slice();
}
return obj;
}
const deepEqual = (a, b) => {
if (a === b) {
return true;
}
if (a === null || b === null) {
return false;
}
if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) {
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
}
for (let key in b) {
if (!(key in a)) {
return false;
}
}
for (let key in a) {
if (!(key in b)) {
return false;
}
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
}
return false;
}
const toRawRecursive = (obj) => {
if (isProxy(obj)) {
obj = toRaw(obj);
}
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj.__proto__ === ({}).__proto__) {
const new_obj = {};
for (let key in obj) {
new_obj[key] = toRawRecursive(obj[key]);
}
return new_obj;
}
if (obj.__proto__ === [].__proto__) {
return obj.map((item) => toRawRecursive(item));
}
return obj;
}
/** may only be called from worker */
const worker_fun = function (self, ctx) {
/* globals WebSocket, SharedWorker, onconnect, onmessage, postMessage, close, location */
let intialized = false;
let state = {};
let ports = [];
let notify_socket;
const tryConnect = () => {
if (self.WebSocket === undefined) {
if (ctx.debug) console.log("no websocket support");
return;
}
if (!notify_socket || notify_socket.readyState !== WebSocket.OPEN) {
// global location is not useful in worker loaded from data url
const scheme = ctx.location.protocol === "https:" ? "wss" : "ws";
if (ctx.debug) console.log("connecting to", scheme + '://' + ctx.location.host + '/ws/2/notify/');
notify_socket = new WebSocket(scheme + '://' + ctx.location.host + '/ws/2/notify/');
notify_socket.onopen = (e) => {
if (ctx.debug) console.log("open", JSON.stringify(e));
};
notify_socket.onclose = (e) => {
if (ctx.debug) console.log("close", JSON.stringify(e));
setTimeout(() => {
tryConnect();
}, 1000);
};
notify_socket.onerror = (e) => {
if (ctx.debug) console.log("error", JSON.stringify(e));
setTimeout(() => {
tryConnect();
}, 1000);
};
notify_socket.onmessage = (e) => {
let data = JSON.parse(e.data);
if (ctx.debug) console.log("message", data);
//this.loadEventItems()
//this.loadTickets()
}
}
}
const deepEqual = (a, b) => {
if (a === b) {
return true;
}
if (a === null || b === null) {
return false;
}
if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) {
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
}
for (let key in b) {
if (!(key in a)) {
return false;
}
}
for (let key in a) {
if (!(key in b)) {
return false;
}
if (!deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) {
return false;
}
}
return true;
}
return false;
}
const handle_message = (message_data, reply, others, all) => {
switch (message_data.type) {
case 'state_init':
if (!intialized) {
intialized = true;
state = message_data.state;
reply({type: 'state_init', first: true});
} else {
reply({type: 'state_init', first: false, state: state});
}
break;
case 'state_diff':
if (message_data.key in state) {
if (!deepEqual(state[message_data.key], message_data.old_value)) {
if (ctx.debug) console.log("state diff old value mismatch | state:", state[message_data.key], " old:", message_data.old_value);
}
if (!deepEqual(state[message_data.key], message_data.new_value)) {
if (ctx.debug) console.log("state diff changed | state:", state[message_data.key], " new:", message_data.new_value);
state[message_data.key] = message_data.new_value;
others(message_data);
} else {
if (ctx.debug) console.log("state diff no change | state:", state[message_data.key], " new:", message_data.new_value);
}
} else {
if (ctx.debug) console.log("state diff key not found", message_data.key);
}
break;
default:
if (ctx.debug) console.log("unknown message", message_data);
}
}
onconnect = (connect_event) => {
const port = connect_event.ports[0];
ports.push(port);
port.onmessage = (message_event) => {
const reply = (message_data) => {
port.postMessage(message_data);
}
const others = (message_data) => {
for (let i = 0; i < ports.length; i++) {
if (ports[i] !== port) {
ports[i].postMessage(message_data);
}
}
}
const all = (message_data) => {
for (let i = 0; i < ports.length; i++) {
ports[i].postMessage(message_data);
}
}
handle_message(message_event.data, reply, others, all);
}
port.start();
if (ctx.debug) console.log("worker connected", JSON.stringify(connect_event));
tryConnect();
}
if (ctx.debug) console.log("worker loaded");
}
const worker_context = {
location: {
protocol: location.protocol, host: location.host
}, bug: config.debug
}
const worker_code = '(' + worker_fun.toString() + ')(self,' + JSON.stringify(worker_context) + ')';
const worker_url = 'data:application/javascript;base64,' + btoa(worker_code);
const worker = new SharedWorker(worker_url, 'vuex-shared-state-plugin');
worker.port.start();
if (config.debug) console.log('worker started');
const updateWorkerState = (key, new_value, old_value = null) => {
if (new_value === old_value) {
if (config.debug) console.log('updateWorkerState: no change', key, new_value);
return;
}
if (new_value === undefined) {
if (config.debug) console.log('updateWorkerState: undefined', key, new_value);
return;
}
worker.port.postMessage({
type: 'state_diff',
key: key,
new_value: isProxy(new_value) ? toRawRecursive(new_value) : new_value,
old_value: isProxy(old_value) ? toRawRecursive(old_value) : old_value
});
}
const registerInitialState = (keys, local_state) => {
const value = keys.reduce((obj, key) => {
obj[key] = isProxy(local_state[key]) ? toRawRecursive(local_state[key]) : local_state[key];
return obj;
}, {});
if (config.debug) console.log('registerInitilState', value);
worker.port.postMessage({
type: 'state_init', state: value
});
}
return (store) => {
worker.port.onmessage = function (e) {
switch (e.data.type) {
case 'state_init':
if (config.debug) console.log('state_init', e.data);
if (e.data.first) {
if (config.debug) console.log('worker state initialized');
} else {
for (let key in e.data.state) {
if (key in store.state) {
if (config.debug) console.log('worker state init received', key, clone(e.data.state[key]));
if (!deepEqual(store.state[key], e.data.state[key])) {
store.state[key] = e.data.state[key];
}
} else {
if (config.debug) console.log("state init key not found", key);
}
}
}
store.state[config.isLoadedKey] = true;
if ('afterInit' in config) {
setTimeout(() => {
store.dispatch(config.afterInit);
}, 0);
}
break;
case 'state_diff':
if (config.debug) console.log('state_diff', e.data);
if (e.data.key in store.state) {
if (config.debug) console.log('worker state update', e.data.key, clone(e.data.new_value));
//TODO this triggers the watcher again, but we don't want that
store.state[e.data.key] = e.data.new_value;
} else {
if (config.debug) console.log("state diff key not found", e.data.key);
}
break;
default:
if (config.debug) console.log("unknown message", e.data);
}
};
registerInitialState(config.state, store.state);
if ('mutations' in config) {
store.subscribe((mutation, state) => {
if (mutation.type in config.mutations) {
console.log(mutation.type, mutation.payload);
console.log(state);
}
});
}
/*if ('actions' in config) {
store.subscribeAction((action, state) => {
if (action.type in config.actions) {
console.log(action.type, action.payload);
console.log(state);
}
});
}*/
if ('state' in config) {
config.watch.forEach((member) => {
store.watch((state, getters) => state[member], (newValue, oldValue) => {
if (config.debug) console.log('watch', member, clone(newValue), clone(oldValue));
updateWorkerState(member, newValue, oldValue);
});
});
}
};
}

View file

@ -4,8 +4,9 @@ import router from './router';
import * as base64 from 'base-64'; import * as base64 from 'base-64';
import * as utf8 from 'utf8'; import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils"; import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin"; import persistentStatePlugin from "@/persistent-state-plugin";
import {triggerRef} from "vue";
const store = createStore({ const store = createStore({
state: { state: {
@ -20,6 +21,7 @@ const store = createStore({
groups: [], groups: [],
state_options: [], state_options: [],
lastEvent: '37C3', lastEvent: '37C3',
lastUsed: {},
remember: false, remember: false,
user: { user: {
username: null, username: null,
@ -30,7 +32,19 @@ const store = createStore({
}, },
thumbnailCache: {}, thumbnailCache: {},
fetchedData: {
events: 0,
items: 0,
boxes: 0,
tickets: 0,
users: 0,
groups: 0,
states: 0,
},
persistent_loaded: false, persistent_loaded: false,
shared_loaded: false,
afterInitHandlers: [],
showAddBoxModal: false, showAddBoxModal: false,
}, },
getters: { getters: {
@ -80,26 +94,33 @@ const store = createStore({
}, },
}, },
mutations: { mutations: {
updateLastUsed(state, diff) {
state.lastUsed = {...state.lastUsed, ...diff};
},
updateLastEvent(state, slug) { updateLastEvent(state, slug) {
state.lastEvent = slug; state.lastEvent = slug;
}, },
replaceEvents(state, events) { replaceEvents(state, events) {
state.events = events; state.events = events;
state.fetchedData = {...state.fetchedData, events: Date.now()};
}, },
replaceTicketStates(state, states) { replaceTicketStates(state, states) {
state.state_options = states; state.state_options = states;
state.fetchedData = {...state.fetchedData, states: Date.now()};
}, },
changeView(state, {view, slug}) { changeView(state, {view, slug}) {
router.push({path: `/${slug}/${view}`}); router.push({path: `/${slug}/${view}`});
}, },
replaceLoadedItems(state, newItems) { replaceLoadedItems(state, newItems) {
state.loadedItems = newItems; state.loadedItems = newItems;
state.fetchedData = {...state.fetchedData, items: Date.now()}; // TODO: manage caching items for different events and search results correctly
}, },
setItemCache(state, {slug, items}) { setItemCache(state, {slug, items}) {
state.itemCache[slug] = items; state.itemCache[slug] = items;
}, },
replaceBoxes(state, loadedBoxes) { replaceBoxes(state, loadedBoxes) {
state.loadedBoxes = loadedBoxes; state.loadedBoxes = loadedBoxes;
state.fetchedData = {...state.fetchedData, boxes: Date.now()};
}, },
updateItem(state, updatedItem) { updateItem(state, updatedItem) {
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0]; const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
@ -113,16 +134,21 @@ const store = createStore({
}, },
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
state.tickets = tickets; state.tickets = tickets;
}, state.fetchedData = {...state.fetchedData, tickets: Date.now()};
replaceUsers(state, users) {
state.users = users;
},
replaceGroups(state, groups) {
state.groups = groups;
}, },
updateTicket(state, updatedTicket) { updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0]; const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket); Object.assign(ticket, updatedTicket);
//triggerRef(state.tickets);
state.tickets = [...state.tickets];
},
replaceUsers(state, users) {
state.users = users;
state.fetchedData = {...state.fetchedData, users: Date.now()};
},
replaceGroups(state, groups) {
state.groups = groups;
state.fetchedData = {...state.fetchedData, groups: Date.now()};
}, },
openAddBoxModal(state) { openAddBoxModal(state) {
state.showAddBoxModal = true; state.showAddBoxModal = true;
@ -227,6 +253,19 @@ const store = createStore({
} }
await Promise.all(promises); await Promise.all(promises);
}, },
async afterSharedInit({dispatch, state}) {
const handlers = state.afterInitHandlers;
state.afterInitHandlers = [];
await Promise.all(handlers.map(h => h()).flat());
},
scheduleAfterInit({dispatch, state}, handler) {
if (state.shared_loaded) {
Promise.all(handler()).then(() => {
});
} else {
state.afterInitHandlers.push(handler);
}
},
async fetchImage({state}, url) { async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}}); return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}});
}, },
@ -235,11 +274,15 @@ const store = createStore({
commit('setPermissions', data.permissions); commit('setPermissions', data.permissions);
}, },
async loadEvents({commit, state}) { async loadEvents({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/events/', state.user.token); const {data, success} = await http.get('/2/events/', state.user.token);
if (data && success) if (data && success)
commit('replaceEvents', data); commit('replaceEvents', data);
}, },
async fetchTicketStates({commit, state}) { async fetchTicketStates({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/tickets/states/', state.user.token); const {data, success} = await http.get('/2/tickets/states/', state.user.token);
if (data && success) if (data && success)
commit('replaceTicketStates', data); commit('replaceTicketStates', data);
@ -255,6 +298,8 @@ const store = createStore({
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
}, },
async loadEventItems({commit, getters, state}) { async loadEventItems({commit, getters, state}) {
if (!state.user.token) return;
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
try { try {
commit('replaceLoadedItems', []); commit('replaceLoadedItems', []);
const slug = getters.getEventSlug; const slug = getters.getEventSlug;
@ -279,6 +324,8 @@ const store = createStore({
commit('replaceLoadedItems', data); commit('replaceLoadedItems', data);
}, },
async loadBoxes({commit, state}) { async loadBoxes({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/boxes/', state.user.token); const {data, success} = await http.get('/2/boxes/', state.user.token);
if (data && success) if (data && success)
commit('replaceBoxes', data); commit('replaceBoxes', data);
@ -315,6 +362,8 @@ const store = createStore({
commit('appendItem', data); commit('appendItem', data);
}, },
async loadTickets({commit, state}) { async loadTickets({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/tickets/', state.user.token); const {data, success} = await http.get('/2/tickets/', state.user.token);
if (data && success) if (data && success)
commit('replaceTickets', data); commit('replaceTickets', data);
@ -322,6 +371,7 @@ const store = createStore({
async sendMail({commit, dispatch, state}, {id, message}) { async sendMail({commit, dispatch, state}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token); const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token);
if (data && success) { if (data && success) {
state.fetchedData.tickets = 0;
await dispatch('loadTickets'); await dispatch('loadTickets');
} }
}, },
@ -337,15 +387,20 @@ const store = createStore({
async postComment({commit, dispatch, state}, {id, message}) { async postComment({commit, dispatch, state}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token); const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token);
if (data && success) { if (data && success) {
state.fetchedData.tickets = 0;
await dispatch('loadTickets'); await dispatch('loadTickets');
} }
}, },
async loadUsers({commit, state}) { async loadUsers({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/users/', state.user.token); const {data, success} = await http.get('/2/users/', state.user.token);
if (data && success) if (data && success)
commit('replaceUsers', data); commit('replaceUsers', data);
}, },
async loadGroups({commit, state}) { async loadGroups({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/groups/', state.user.token); const {data, success} = await http.get('/2/groups/', state.user.token);
if (data && success) if (data && success)
commit('replaceGroups', data); commit('replaceGroups', data);
@ -368,8 +423,38 @@ const store = createStore({
"remember", "remember",
"user", "user",
"events", "events",
"lastUsed",
] ]
}), }),
sharedStatePlugin({
debug: true,
isLoadedKey: "shared_loaded",
clearingMutation: "logout",
afterInit: "afterSharedInit",
state: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
],
watch: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
],
mutations: [
//"replaceTickets",
],
}),
], ],
}); });

View file

@ -93,7 +93,7 @@ export default {
...mapGetters(['layout']), ...mapGetters(['layout']),
}, },
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem']), ...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
openLightboxModalWith(item) { openLightboxModalWith(item) {
this.lightboxHash = item.file; this.lightboxHash = item.file;
}, },
@ -115,7 +115,7 @@ export default {
} }
}, },
mounted() { mounted() {
this.loadEventItems(); this.scheduleAfterInit(() => [this.loadEventItems()]);
} }
}; };
</script> </script>

View file

@ -62,7 +62,7 @@ export default {
}, },
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'loadUsers', 'fetchTicketStates']), ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
handleMail(mail) { handleMail(mail) {
this.sendMail({ this.sendMail({
id: this.ticket.id, id: this.ticket.id,
@ -86,12 +86,10 @@ export default {
id: ticket.id, id: ticket.id,
assigned_to: ticket.assigned_to assigned_to: ticket.assigned_to
}) })
}
}, },
created() { },
this.fetchTicketStates() mounted() {
this.loadTickets() this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets(), this.loadUsers()]);
this.loadUsers()
} }
}; };
</script> </script>

View file

@ -65,7 +65,7 @@ export default {
...mapGetters(['stateInfo', 'getEventSlug', 'layout']), ...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
}, },
methods: { methods: {
...mapActions(['loadTickets', 'fetchTicketStates']), ...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) { gotoDetail(ticket) {
this.$router.push({name: 'ticket', params: {id: ticket.id}}); this.$router.push({name: 'ticket', params: {id: ticket.id}});
}, },
@ -80,9 +80,8 @@ export default {
}; };
} }
}, },
created() { mounted() {
this.fetchTicketStates(); this.scheduleAfterInit(() => [this.fetchTicketStates(), this.loadTickets()]);
this.loadTickets();
} }
}; };
</script> </script>