Store: Redesign the settings module architecture

Previously, the settings' module was not properly equipped to handle
setting updates that featured certain side effects and no way to
propagate those side effects to other windows.

This redesign is a direct answer to those needs, in preparation to
move these settings and related logic to the aproppriate structures,
which will be done over the course of several commits.

A more in-depth documentation of the current redesign can be found at
the top of the settings module file.
This commit is contained in:
Svallinn 2021-06-11 01:59:59 +01:00
parent 7e94abb3b4
commit bb64efbe4d
No known key found for this signature in database
GPG Key ID: 09FB527F34037CCA
1 changed files with 266 additions and 94 deletions

View File

@ -1,29 +1,162 @@
import { settingsDb } from '../datastores' import { settingsDb } from '../datastores'
/** /*
* NOTE: If someone wants to add a new setting to the app, * Due to the complexity of the settings module in FreeTube, a more
* all that needs to be done in this file is adding * in-depth explanation for adding new settings is required.
* the setting name and its default value to the `state` object
* *
* The respective getter, mutation (setter) and action (updater) will * The explanation will be written with the assumption that
* be automatically generated with the following pattern: * the reader knows how Vuex works.
* *
* Setting: example * And no, there's no need to read the entire wall of text.
* Getter: getExample * We'll direct you where you need to go as we walk you through it.
* Mutation: setExample * Additionally, the text actually looks bigger than it truly is.
* Action: updateExample * Each line has, at most, 72 characters.
* *
* For more details on this, see the expanded exemplification below ****
* Introduction
* *
* If, for whatever reason, the setting needs less or more * You can add a new setting in three different methods.
* functionality than what these auto-generated functions can provide,
* then that setting and its necessary functions must be manually added
* only AFTER the generic ones have been auto-generated
* Example: `usingElectron` (doesn't need an action)
* *
* The same rule applies for standalone getters, mutations and actions * The first two methods benefit from the auto-generation of
* Example: `grabUserSettings` (standalone action) * a getter, a mutation and a few actions related to the setting.
* Those two methods should be preferred whenever possible:
* - `state`
* - `stateWithSideEffects`
*
* The last one DOES NOT feature any kind of auto-generation and should
* only be used in scenarios that don't fall under the other 2 options:
* - `customState`
*
****
* ASIDE:
* The aforementioned "side effects" cover a large area
* of interactions with other modules
* A good example would be a setting that utilizes the Electron API
* when its value changes.
*
****
* First and foremost, you have to understand what type of setting
* you intend to add to the app.
*
* You'll have to select one of these three scenarios:
*
* 1) You just want to add a simple setting that does not actively
* interact with the Electron API, `localStorage` or
* other parts outside of the settings module.
* -> Please consult the `state` section.
*
* 2) You want to add a more complex setting that interacts
* with other parts of the app and tech stack.
* -> Please consult the `state` and `stateWithSideEffects` sections.
*
* 3) You want to add a completely custom state based setting
* that does not work like the usual settings.
* -> Please consult the `state` and `customState` sections.
*
****
* `state`
* This object contains settings that have NO SIDE EFFECTS.
*
* A getter, mutation and an action function is auto-generated
* for every setting present in the `state` object.
* They have the following format (exemplified with setting 'example'):
*
* Getter: `getExample` (gets the value from current state)
* Mutation:
* `setExample`
* (takes a value
* and uses it to update the current state)
* Action:
* `updateExample`
* (takes a value,
* saves it to the database
* and calls `setExample` with it)
*
***
* `stateWithSideEffects`
* This object contains settings that have SIDE EFFECTS.
*
* Each one of these settings must specify an object
* with the following properties:
* - `defaultValue`
* (which is the value you would put down if
* you were to add the setting to the regular `state` object)
*
* - `sideEffectsHandler`
* (which should essentially be a callback of type
* `(store, value) => void`
* that deals with the side effects for that setting)
*
* NOTE: Example implementations of such settings can be found
* in the `stateWithSideEffects` object in case
* the explanation isn't clear enough.
*
* All functions auto-generated for settings in `state`
* (if you haven't read the `state` section, do it now),
* are also auto-generated for settings in `stateWithSideEffects`,
* with a few key differences (exemplified with setting 'example'):
*
* - an additional action is auto-generated:
* - `triggerExampleSideEffects`
* (triggers the `sideEffectsHandler` for that setting;
* you'll most likely never call this directly)
*
* - the behavior of `updateExample` changes a bit:
* - `updateExample`
* (saves value to the database,
* calls `triggerExampleSideEffects` and calls `setExample`)
*
***
* `customState`
* This object contains settings that
* don't linearly fall under the other two options.
*
* No auto-generation of any kind is performed
* when a setting is added to `customState`
*
* You must manually add any getters, mutations and actions to
* `customGetters`, `customMutations` and `customActions` respectively
* that you find appropriate for that setting.
*
* NOTE:
* When adding a setting to the `customState`,
* additional consultation with the FreeTube team is preferred
* to evaluate if it is truly necessary
* and to ensure that the implementation works as intended.
*
* A good example of a setting of this type would be `usingElectron`.
* This setting doesn't need to be persisted in the database
* and it doesn't change over time.
* Therefore, it needs a getter (which we add to `customGetters`), but
* has no need for a mutation or any sort of action.
*
****
* ENDING NOTES
*
* Only two more things that need mentioning.
*
* 1) It's perfectly fine to add extra functionality
* to the `customGetters`, `customMutations` and `customActions`,
* whether it's related to a setting or just serving as
* standalone functionality for the module
* (e.g. `grabUserSettings` (standalone action))
*
* 2) It's also possible to OVERRIDE auto-generated functionality by
* adding functions with the same identifier to
* the respective `custom__` object,
* but you must have an acceptable reason for doing so.
****
*/ */
// HELPERS
const capitalize = str => str.replace(/^\w/, c => c.toUpperCase())
const defaultGetterId = settingId => 'get' + capitalize(settingId)
const defaultMutationId = settingId => 'set' + capitalize(settingId)
const defaultUpdaterId = settingId => 'update' + capitalize(settingId)
const defaultSideEffectsTriggerId = settingId =>
'trigger' + capitalize(settingId) + 'SideEffects'
/*****/
const state = { const state = {
currentTheme: 'lightRed', currentTheme: 'lightRed',
uiScale: 100, uiScale: 100,
@ -80,78 +213,46 @@ const state = {
displayVideoPlayButton: true displayVideoPlayButton: true
} }
const getters = {} const stateWithSideEffects = {
const mutations = {} /*
const actions = {} setting: {
defaultValue: any,
/** sideEffectsHandler: (store, settingValue) => void
* Build getters, mutations and actions for every setting id }
* e.g.:
* Setting id: uiScale
* Getter:
* getUiScale: (state) => state.uiScale
* Mutation:
* setUiScale: (state, uiScaleValue) => state.uiScale = uiScaleValue
* Action:
* updateUiScale: ({ commit }, uiScaleValue) => {
* await settingsDb.update(
* { _id: 'uiScale' },
* { _id: 'uiScale', value: uiScaleValue },
* { upsert: true },
* (err, _) => {
* commit('setUiScale', uiScaleValue)
* }
* )
*/ */
for (const settingId of Object.keys(state)) {
const capitalizedSettingId =
settingId.replace(/^\w/, (c) => c.toUpperCase())
const getterId = 'get' + capitalizedSettingId
const mutationId = 'set' + capitalizedSettingId
const actionId = 'update' + capitalizedSettingId
getters[getterId] = (state) => state[settingId]
mutations[mutationId] = (state, value) => { state[settingId] = value }
actions[actionId] = ({ commit }, value) => {
settingsDb.update(
{ _id: settingId },
{ _id: settingId, value: value },
{ upsert: true },
(err, _) => {
if (!err) {
commit(mutationId, value)
if (getters.getUsingElectron) {
const { ipcRenderer } = require('electron')
// Propagate setting values to all other existing windows
ipcRenderer.send('syncSetting', {
_id: settingId, value: value
})
}
}
}
)
}
} }
// Custom state const customState = {
Object.assign(state, { usingElectron: (window?.process?.type === 'renderer')
// Add `usingElectron` to the state }
usingElectron: window?.process?.type === 'renderer'
})
// Custom getters const customGetters = {
Object.assign(getters, {
// Getter for `usingElectron`
getUsingElectron: (state) => state.usingElectron getUsingElectron: (state) => state.usingElectron
}
const customMutations = {}
/**********/
/*
* DO NOT TOUCH THIS SECTION
* If you wanna add to custom data or logic to the module,
* do so in the aproppriate `custom_` variable
*
* Some of the custom actions below use these properties, so I'll be
* adding them here instead of further down for clarity's sake
*/
Object.assign(customState, {
settingsWithSideEffects: Object.keys(stateWithSideEffects)
}) })
// Custom mutations Object.assign(customGetters, {
// Object.assign(mutations, {}) settingHasSideEffects: (state) => {
return (id) => state.settingsWithSideEffects.includes(id)
}
})
/**********/
// Custom actions const customActions = {
Object.assign(actions, {
// Add `grabUserSettings` to actions
grabUserSettings: ({ commit, getters }) => { grabUserSettings: ({ commit, getters }) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
settingsDb.find( settingsDb.find(
@ -181,16 +282,14 @@ Object.assign(actions, {
]) ])
for (const setting of userSettings) { for (const setting of userSettings) {
if (specialSettings.has(setting._id)) { const { _id, value } = setting
const specialSettingHandler = specialSettings.get(setting._id) if (specialSettings.has(_id)) {
specialSettingHandler(setting.value) const specialSettingHandler = specialSettings.get(_id)
specialSettingHandler(value)
continue continue
} }
const capitalizedSettingId = commit(defaultMutationId(_id), value)
setting._id.replace(/^\w/, (c) => c.toUpperCase())
commit('set' + capitalizedSettingId, setting.value)
} }
resolve() resolve()
@ -203,14 +302,87 @@ Object.assign(actions, {
if (getters.getUsingElectron) { if (getters.getUsingElectron) {
const { ipcRenderer } = require('electron') const { ipcRenderer } = require('electron')
ipcRenderer.on('syncSetting', (_, setting) => { ipcRenderer.on('syncSetting', (_, setting) => {
const capitalizedSettingId = const { _id, value } = setting
setting._id.replace(/^\w/, (c) => c.toUpperCase()) commit(defaultMutationId(_id), value)
})
}
}
}
commit('set' + capitalizedSettingId, setting.value) /**********************/
/*
* DO NOT TOUCH ANYTHING BELOW
* (unless you plan to change the architecture of this module)
*/
const getters = {}
const mutations = {}
const actions = {}
// Add settings that contain side effects to the state
Object.assign(
state,
Object.fromEntries(
Object.keys(stateWithSideEffects).map(
(key) => [
key,
stateWithSideEffects[key].defaultValue
]
)
)
)
// Build default getters, mutations and actions for every setting id
for (const settingId of Object.keys(state)) {
const getterId = defaultGetterId(settingId)
const mutationId = defaultMutationId(settingId)
const updaterId = defaultUpdaterId(settingId)
const triggerId = defaultSideEffectsTriggerId(settingId)
getters[getterId] = (state) => state[settingId]
mutations[mutationId] = (state, value) => { state[settingId] = value }
// If setting has side effects, generate action to handle them
if (Object.keys(stateWithSideEffects).includes(settingId)) {
actions[triggerId] = stateWithSideEffects[settingId].sideEffectsHandler
}
actions[updaterId] = ({ commit, dispatch, getters }, value) => {
settingsDb.update(
{ _id: settingId },
{ _id: settingId, value: value },
{ upsert: true },
(err, _) => {
if (err) return
const {
getUsingElectron: usingElectron,
settingHasSideEffects
} = getters
if (settingHasSideEffects(settingId)) {
dispatch(triggerId, value)
}
commit(mutationId, value)
if (usingElectron) {
const { ipcRenderer } = require('electron')
// Propagate settings to all other existing windows
ipcRenderer.send('syncSetting', {
_id: settingId, value: value
}) })
} }
} }
}) )
}
}
// Add all custom data/logic to their respective objects
Object.assign(state, customState)
Object.assign(getters, customGetters)
Object.assign(mutations, customMutations)
Object.assign(actions, customActions)
export default { export default {
state, state,