import { ShowWarningSettingMetadata } from './settings-metadata.js'
import { Dialog } from './dialog.js'
/**
* A class representing the values of the user's settings in an app. Instances
* of this class require metadata so that the settings can be presented to the
* user in a dialog for editing.
*
* The class inherits from `Map`, which allows you to call `keys()`, `has(key)`,
* `get(key)`, `set(key,value)`, `delete(key)`, and `clear()`. We augment this
* built-in functionality with additional methods for loading, saving, and
* allowing the user to edit the settings interactively.
*
* @see {@link SettingsMetadata}
*/
export class Settings extends Map {
/**
* Construct a collection of settings with the given name and metadata.
* Only settings whose metadata appear in the given `metadata` will be used
* for load, save, or editing purposes. (You can `.set()` other things, but
* this class will ignore them.)
*
* @param {String} name - the name of this collection of settings, which
* will be shown to the user in the title of the dialog box presented when
* editing the settings
* @param {SettingsMetadata} metadata - the metadata definitions for all
* settings that will be stored in this object
*/
constructor ( name, metadata ) {
super()
this.name = name
this.metadata = metadata
this.defaults = metadata.defaultSettings()
}
/**
* This should be viewed as the set of allowed keys for this app's settings.
* They are the keys that show up in the metadata, and are thus the only
* keys that this object pays attention to (even if you add settings with
* other names).
*
* @returns {String[]} the keys from the metadata for these settings; see
* {@link SettingsMetadata#keys SettingsMetadata.keys()} for more
* information
* @see {@link Settings#has has()}
*/
keys () { return this.metadata.keys() }
/**
* A settings object has a key if and only if that key appears as part of
* its metadata, which was given at construction time. Even if no value has
* been assigned for the key, the settings object still has it, associated
* with its default value. Even if other keys have been set, we ignore them
* because they do not appear in the schema given at construction time.
*
* @param {string} key - the key whose presence should be checked
* @returns {boolean} whether the key appears in this settings object
* @see {@link Settings#keys keys()}
* @see {@link Settings#get get()}
*/
has ( key ) { return this.keys().includes( key ) }
/**
* If the given key does not appear in the metadata given at construction
* time, then undefined is returned. Otherwise, if the key has had a value
* associated with it via a previous call to `set()`, then we return that
* value. Otherwise, we return the default value given in the metadata for
* the given key.
*
* @param {string} key - the key to look up
* @returns {any} the value stored under the key, or the default value for
* that key if none has yet been set
* @see {@link Settings#has has()}
*/
get ( key ) {
return !this.has( key ) ? undefined :
super.has( key ) ? super.get( key ) : this.defaults[key]
}
/**
* Load from the browser's `localStorage` all settings whose names show up
* in this object's metadata, and convert them to the appropriate types using
* that metadata. For any value not in `localStorage`, fill this object
* with its default value instead (as given by the metadata).
*
* @see {@link Settings#save save()}
* @see {@link Settings#reset reset()}
*/
load () {
const allowedKeys = this.keys()
for ( let i = 0 ; i < localStorage.length ; i++ ) {
const key = localStorage.key( i )
if ( !key.startsWith( 'lurch-' ) ) continue
const subkey = key.substring( 6 )
if ( allowedKeys.includes( subkey ) ) {
const metadata = this.metadata.metadataFor( subkey )
const loaded = localStorage.getItem( key )
this.set( subkey, metadata ? metadata.convert( loaded ) : loaded )
}
}
}
/**
* For every setting that is stored in this object and whose key is allowed,
* according to this object's metadata, save the key-value pair into the
* browser's `localStorage` object.
*
* @see {@link Settings#load load()}
* @see {@link Settings#reset reset()}
*/
save () {
this.keys().forEach( key => {
if ( this.has( key ) )
localStorage.setItem( `lurch-${key}`, this.get( key ) )
} )
}
/**
* This does not clear only this object, but also erases all the user's
* saved settings. It should be used if the user wants to reset the
* application to its default settings by erasing any customization they
* have made so far, so that every setting returns to its default value.
*
* @see {@link Settings#load load()}
* @see {@link Settings#save save()}
*/
reset () {
this.keys().forEach( key => localStorage.removeItem( `lurch-${key}` ) )
this.clear()
this.load()
}
/**
* Show to the user a dialog box for editing this settings object. If the
* user clicks OK, any changes they made will be saved back into this
* object. If the user clicks cancel, no changes they made in the dialog
* will be retained or stored in this object or anywhere else.
*
* Returns a promise as described below. Note that if the user edits the
* settings and closes the dialog, that impacts the contents of this object,
* but not the contents of the browser's `localStorage`. If the client
* wishes the new settings to be saved, they should call this object's
* `save()` function when the promise resolves.
*
* The promise resolves whether the user clicks OK or cancel, but in the
* case of cancel, an empty array is passed (no settings changed). The
* promise rejects only if some unexpected error occurs; that would be
* considered a bug, so the intent is for the promise to always resolve.
*
* @param {tinymce.Editor} editor - the editor in which to show the dialog
* @returns {Promise} a promise that resolves when the user closes the
* dialog, and passes an array of all setting names that the user changed.
* @see {@link Settings#load load()}
* @see {@link Settings#save save()}
*/
userEdit ( editor ) {
const originalSettings = { }
Array.from( this.keys() ).forEach( key =>
originalSettings[key] = this.has( key ) ? this.get( key )
: this.defaults[key] )
return new Promise( ( resolve, _ ) => {
const dialog = editor.windowManager.open( {
title : this.name,
size : 'medium',
body : this.metadata.control(),
buttons : [
{ text : 'OK', type : 'submit' },
{ text : 'Cancel', type : 'cancel' },
{
text : 'Reset all',
type : 'custom',
align : 'start',
name : 'reset-all'
}
],
initialData : originalSettings,
onSubmit : () => {
const results = dialog.getData()
const errors = this.metadata.validate( results )
if ( errors.length > 0 ) {
let message = 'Cannot save preferences due to these errors:<br/>'
errors.forEach( ( text, index ) => {
message += `${index + 1}. ${text}<br/>`
} )
Dialog.failure( editor, message, 'Fix errors before saving' )
return
}
dialog.close()
const changedKeys = Object.keys( results ).filter(
key => results[key] != originalSettings[key] )
changedKeys.forEach( key => this.set( key, results[key] ) )
resolve( changedKeys )
},
onCancel : () => resolve( [ ] ), // nothing changed (cancel)
onAction : ( _, details ) => {
if ( details.name == 'reset-all' ) {
Dialog.areYouSure(
editor,
'Clear all your settings and reset them to the defaults?'
).then( userHitOK => {
if ( userHitOK ) {
dialog.close()
resolve( [ ] )
setTimeout( () => {
this.reset()
this.userEdit( editor )
} )
}
} )
}
}
} )
} )
}
/**
* When a user attempts to take a dangerous action (e.g., delete something
* important) the application may need to pop up a warning asking whether
* the user really meant to take that action, so that the application does
* not take the action if it was an accident on the user's part. This
* function makes that easy.
*
* First, the application settings metadata must contain a setting
* corresponding to the warning we want to display, for the reasons
* described in the {@link ShowWarningSettingMetadata} class. (See that
* link for details.)
*
* Second, the client can call this function and pass the name of the
* warning setting in question and the editor over which to pop up the
* warning dialog if one is needed. The client receives a promise in return
* that resolves if the user wants to proceed and rejects otherwise. The
* user also has the opportunity to say they always want to proceed, which
* will tweak the corresponding warning setting in this object
* appropriately.
*
* Example use:
* ```js
* settings.showWarning( 'warn before delete files', editor ).then( () => {
* // put here the code that deletes the files
* } ).catch( () => { } ) // to ensure no errors if they say no
* ```
*
* @param {string} settingName - the name of the setting for the warning
* @param {tinymce.Editor} editor - the editor instance over which to pop up
* the dialog, if one needs to be shown
* @returns {Promise} a promise that resolves if the user chooses to proceed
* with the action and rejects if the user chooses not to proceed
* @see {@link ShowWarningSettingMetadata}
*/
showWarning ( settingName, editor ) {
// If this is not a warning setting, throw an error.
const metadata = this.metadata.metadataFor( settingName )
if ( !metadata || !( metadata instanceof ShowWarningSettingMetadata ) )
throw new Error( 'No such warning setting: ' + settingName )
const message = metadata.warningText + '<br/>'
+ 'Are you sure you want to proceed?'
// If the user says not to show this warning, return a resolved promise
// so that subsequent client actions will be invoked immediately.
// We resolve to true, meaning the user said it's okay to proceed.
if ( !this.get( settingName ) ) return Promise.resolve( true )
// Otherwise return a promise whose resolution is contintgent upon the
// user's response to the appropriate warning dialog.
return new Promise( ( resolve, _ ) => {
const dialog = editor.windowManager.open( {
title : 'Warning',
body : {
type : 'panel',
items : [
{
type : 'alertbanner',
level : 'warn',
icon : 'warning',
text : message
}
],
},
buttons : [
{
text : 'Yes',
name : 'yes',
buttonType : 'secondary',
type : 'custom'
},
{
text : 'Yes and do not ask again',
name : 'yes-and',
buttonType : 'secondary',
type : 'custom'
},
{
text : 'No',
name : 'no',
buttonType : 'primary',
type : 'custom'
}
],
onAction : ( _, details ) => {
dialog.close()
if ( details.name == 'no' )
return resolve( false )
if ( details.name == 'yes-and' ) {
this.set( settingName, false )
this.save()
}
resolve( true )
}
} )
} )
}
/**
* A "hidden" setting is one that will not show up in the settings dialog,
* but will still be stored in the user's local storage. This can be used
* when some other part of the application wants to store a value that is
* determined by user preference, but its purpose and/or type make it not
* very sensible to add to the settings dialog.
*
* This function can be used to write such hidden settings, and the
* corresponding {@link Settings#loadHiddenSetting loadHiddenSetting()} to
* read them.
*
* Note that this function does police its parameters in one way, that the
* key for the setting must not be the key for a non-hidden setting. Other
* than that, it can be anything the caller desires. That way, it ensures
* that the caller does not accidentally overwrite a non-hidden setting with
* a hidden setting.
*
* @param {string} key - the key under which to store the setting
* @param {string} value - the value to store
* @see {@link Settings#loadHiddenSetting loadHiddenSetting()}
*/
saveHiddenSetting ( key, value ) {
if ( this.has( key ) )
throw new Error( 'Non-hidden setting already exists with key: ' + key )
localStorage.setItem( `lurch-${key}`, value )
}
/**
* A "hidden" setting is one that will not show up in the settings dialog,
* but will still be stored in the user's local storage. This can be used
* when some other part of the application wants to store a value that is
* determined by user preference, but its purpose and/or type make it not
* very sensible to add to the settings dialog.
*
* This function can be used to read such hidden settings, and the
* corresponding {@link Settings#setHiddenSetting setHiddenSetting()} to
* write them.
*
* Note that this function does police its parameters in one way, that the
* key for the setting must not be the key for a non-hidden setting. Other
* than that, it can be anything the caller desires. That way, it ensures
* that the caller does not accidentally write code that behaves as if a
* setting is hidden when it is not.
*
* @param {string} key - the key under which the setting was stored
* @returns {string} the value retrieved
* @see {@link Settings#saveHiddenSetting saveHiddenSetting()}
*/
loadHiddenSetting ( key ) {
if ( this.has( key ) )
throw new Error( 'Non-hidden setting already exists with key: ' + key )
return localStorage.getItem( `lurch-${key}` )
}
}
source