From 9b503d6c81f45864eba299a20cfd2ec957c6884b Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Sun, 30 May 2021 19:39:27 +0200 Subject: [PATCH] feat(devtools): load/save state --- package.json | 2 + src/createPinia.ts | 6 +-- src/devtools/plugin.ts | 101 ++++++++++++++++++++++++++++++++++++++--- src/global.d.ts | 2 +- yarn.lock | 10 ++++ 5 files changed, 111 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index abfb3dfd..33ddaa1f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-replace": "^2.4.2", "@sucrase/jest-plugin": "^2.1.0", + "@types/file-saver": "^2.0.2", "@types/jest": "^26.0.23", "@types/node": "^15.6.1", "@vue/server-renderer": "^3.0.11", @@ -76,6 +77,7 @@ "brotli": "^1.3.2", "codecov": "^3.8.2", "conventional-changelog-cli": "^2.1.1", + "file-saver": "^2.0.5", "jest": "^26.6.3", "jest-mock-warn": "^1.1.0", "lint-staged": "^11.0.0", diff --git a/src/createPinia.ts b/src/createPinia.ts index 11b4af5c..587928aa 100644 --- a/src/createPinia.ts +++ b/src/createPinia.ts @@ -4,7 +4,7 @@ import { setActivePinia, piniaSymbol, } from './rootStore' -import { ref, App } from 'vue' +import { ref, App, markRaw } from 'vue' import { devtoolsPlugin } from './devtools' import { IS_CLIENT } from './env' @@ -21,7 +21,7 @@ export function createPinia(): Pinia { // plugins added before calling app.use(pinia) const toBeInstalled: PiniaStorePlugin[] = [] - const pinia: Pinia = { + const pinia: Pinia = markRaw({ install(app: App) { pinia._a = localApp = app app.provide(piniaSymbol, pinia) @@ -48,7 +48,7 @@ export function createPinia(): Pinia { _a: localApp!, state, - } + }) // pinia devtools rely on dev only features so they cannot be forced unless // the dev build of Vue is used diff --git a/src/devtools/plugin.ts b/src/devtools/plugin.ts index 827a4beb..5f686013 100644 --- a/src/devtools/plugin.ts +++ b/src/devtools/plugin.ts @@ -15,6 +15,7 @@ import { formatStoreForInspectorState, formatStoreForInspectorTree, } from './formatting' +import { saveAs } from 'file-saver' /** * Registered stores used for devtools. @@ -36,12 +37,23 @@ function checkClipboardAccess() { } } +function checkNotFocusedError(error: Error) { + if (error.message.toLowerCase().includes('document is not focused')) { + toastMessage( + 'You need to activate the "Emulate a focused page" setting in the "Rendering" panel of devtools.', + 'warn' + ) + return true + } +} + async function actionGlobalCopyState(pinia: Pinia) { if (checkClipboardAccess()) return try { await navigator.clipboard.writeText(JSON.stringify(pinia.state.value)) toastMessage('Global state copied to clipboard.') } catch (error) { + if (checkNotFocusedError(error)) return toastMessage( `Failed to serialize the state. Check the console for more details.`, 'error' @@ -56,6 +68,7 @@ async function actionGlobalPasteState(pinia: Pinia) { pinia.state.value = JSON.parse(await navigator.clipboard.readText()) toastMessage('Global state pasted from clipboard.') } catch (error) { + if (checkNotFocusedError(error)) return toastMessage( `Failed to deserialize the state from clipboard. Check the console for more details.`, 'error' @@ -64,6 +77,64 @@ async function actionGlobalPasteState(pinia: Pinia) { } } +async function actionGlobalSaveState(pinia: Pinia) { + try { + saveAs( + new Blob([JSON.stringify(pinia.state.value)], { + type: 'text/plain;charset=utf-8', + }), + 'pinia-state.json' + ) + } catch (error) { + toastMessage( + `Failed to export the state as JSON. Check the console for more details.`, + 'error' + ) + console.error(error) + } +} + +let fileInput: HTMLInputElement | undefined +function getFileOpener() { + if (!fileInput) { + fileInput = document.createElement('input') + fileInput.type = 'file' + fileInput.accept = '.json' + } + + function openFile(): Promise { + return new Promise((resolve, reject) => { + fileInput!.onchange = async () => { + const files = fileInput!.files + if (!files) return resolve(null) + const file = files.item(0) + if (!file) return resolve(null) + return resolve({ text: await file.text(), file }) + } + fileInput!.oncancel = () => resolve(null) + fileInput!.click() + }) + } + return openFile +} + +async function actionGlobalOpenStateFile(pinia: Pinia) { + try { + const open = await getFileOpener() + const result = await open() + if (!result) return + const { text, file } = result + pinia.state.value = JSON.parse(text) + toastMessage(`Global state imported from "${file.name}".`) + } catch (error) { + toastMessage( + `Failed to export the state as JSON. Check the console for more details.`, + 'error' + ) + console.error(error) + } +} + export function addDevtools(app: App, store: Store) { // TODO: we probably need to ensure the latest version of the store is kept: // without effectScope, multiple stores will be created and will have a @@ -100,19 +171,37 @@ export function addDevtools(app: App, store: Store) { treeFilterPlaceholder: 'Search stores', actions: [ { - icon: 'content-copy', + icon: 'content_copy', action: () => { actionGlobalCopyState(store._p) }, tooltip: 'Serialize and copy the state', }, { - icon: 'content-paste', - action: () => { - actionGlobalPasteState(store._p) + icon: 'content_paste', + action: async () => { + await actionGlobalPasteState(store._p) + api.sendInspectorTree(INSPECTOR_ID) + api.sendInspectorState(INSPECTOR_ID) }, tooltip: 'Replace the state with the content of your clipboard', }, + { + icon: 'save', + action: () => { + actionGlobalSaveState(store._p) + }, + tooltip: 'Save the state as a JSON file', + }, + { + icon: 'folder_open', + action: async () => { + await actionGlobalOpenStateFile(store._p) + api.sendInspectorTree(INSPECTOR_ID) + api.sendInspectorState(INSPECTOR_ID) + }, + tooltip: 'Import the state from a JSON file', + }, ], }) @@ -413,7 +502,7 @@ export function devtoolsPlugin< */ function toastMessage( message: string, - type?: 'normal' | 'error' | 'warning' | undefined + type?: 'normal' | 'error' | 'warn' | undefined ) { const piniaMessage = '🍍 ' + message @@ -421,7 +510,7 @@ function toastMessage( __VUE_DEVTOOLS_TOAST__(piniaMessage, type) } else if (type === 'error') { console.error(piniaMessage) - } else if (type === 'warning') { + } else if (type === 'warn') { console.warn(piniaMessage) } else { console.log(piniaMessage) diff --git a/src/global.d.ts b/src/global.d.ts index 9481230b..ecff968b 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -6,5 +6,5 @@ declare var __BROWSER__: boolean declare var __CI__: boolean declare var __VUE_DEVTOOLS_TOAST__: ( message: string, - type?: 'normal' | 'error' | 'warning' + type?: 'normal' | 'error' | 'warn' ) => void diff --git a/yarn.lock b/yarn.lock index 57fa1e8b..48dc20b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -896,6 +896,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/file-saver@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.2.tgz#bd593ccfaee42ff94a5c1c83bf69ae9be83493b9" + integrity sha512-xbqnZmGrCEqi/KUzOkeUSe77p7APvLuyellGaAoeww3CHJ1AbjQWjPSCFtKIzZn8L7LpEax4NXnC+gfa6nM7IA== + "@types/graceful-fs@^4.1.2": version "4.1.3" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.3.tgz#039af35fe26bec35003e8d86d2ee9c586354348f" @@ -2482,6 +2487,11 @@ figures@^3.2.0: dependencies: escape-string-regexp "^1.0.5" +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" -- 2.47.2