import ProcessDialog from './ProcessDialog';
import StorageItem from './StorageItem';
import cd from './cd';
import controller from './controller';
import settings from './settings';
import { saveGlobalOption, saveLocalOption } from './utils-api';
import { areObjectsEqual } from './utils-general';
import { createCheckboxField, createNumberField, createRadioField, createTextField, tweakUserOoUiClass } from './utils-oojs';
/**
* Class used to create a settings dialog.
*
* @augments ProcessDialog
*/
class SettingsDialog extends ProcessDialog {
static name = 'settingsDialog';
static title = cd.s('sd-title');
static actions = [
{
action: 'close',
modes: ['settings', 'reload', 'dataRemoved'],
flags: ['safe', 'close'],
disabled: true,
},
{
action: 'save',
modes: ['settings'],
label: cd.s('sd-save'),
flags: ['primary', 'progressive'],
disabled: true,
},
{
action: 'reset',
modes: ['settings'],
label: cd.s('sd-reset'),
flags: ['destructive'],
disabled: true,
},
{
action: 'reload',
modes: ['reload'],
label: cd.s('sd-reload'),
flags: ['primary', 'progressive'],
},
];
static size = 'large';
static cdKey = 'sd';
/**
* Create a settings dialog.
*
* @param {string} [initialPageName]
*/
constructor(initialPageName) {
super({ classes: ['cd-dialog-settings'] });
this.initialPageName = initialPageName;
}
/**
* OOUI native method to get the height of the window body.
*
* @returns {number}
* @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getBodyHeight
* @ignore
*/
getBodyHeight() {
return 600;
}
/**
* OOUI native method that initializes window contents.
*
* @param {...*} [args]
* @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#initialize
* @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
* @ignore
*/
initialize(...args) {
super.initialize(...args);
this.pushPending();
this.initPromise = Promise.all([
settings.load({ omitLocal: true }),
]);
this.loadingPanel = new OO.ui.PanelLayout({
padded: true,
expanded: false,
});
this.loadingPanel.$element.append($('<div>').text(cd.s('loading-ellipsis')));
this.settingsPanel = new OO.ui.PanelLayout({
padded: false,
expanded: true,
});
this.reloadPanel = new OO.ui.PanelLayout({
padded: true,
expanded: false,
});
this.reloadPanel.$element.append($('<p>').text(cd.s('sd-saved')));
this.dataDeletedPanel = new OO.ui.PanelLayout({
padded: true,
expanded: false,
});
this.dataDeletedPanel.$element.append($('<p>').text(cd.s('sd-dataremoved')));
this.stackLayout = new OO.ui.StackLayout({
items: [this.loadingPanel, this.settingsPanel, this.reloadPanel, this.dataDeletedPanel],
});
this.$body.append(this.stackLayout.$element);
}
/**
* OOUI native method that returns a "setup" process which is used to set up a window for use in a
* particular context, based on the `data` argument.
*
* @param {object} [data] Dialog opening data
* @returns {external:OO.ui.Process}
* @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getSetupProcess
* @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
* @ignore
*/
getSetupProcess(data) {
return super.getSetupProcess(data).next(() => {
this.stackLayout.setItem(this.loadingPanel);
this.actions.setMode('settings');
});
}
/**
* OOUI native method that returns a "ready" process which is used to ready a window for use in a
* particular context, based on the `data` argument.
*
* @param {object} data Window opening data
* @returns {external:OO.ui.Process}
* @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getReadyProcess
* @see https://www.mediawiki.org/wiki/OOUI/Windows#Window_lifecycle
* @ignore
*/
getReadyProcess(data) {
return super.getReadyProcess(data).next(async () => {
try {
[this.settings] = await this.initPromise;
} catch (e) {
this.handleError(e, 'error-settings-load', false);
return;
}
// this.settings can be empty after removing the data using the relevant functionality in the
// UI.
if (!Object.keys(this.settings).length) {
this.settings = settings.get();
}
this.renderControls(this.settings);
this.stackLayout.setItem(this.settingsPanel);
this.bookletLayout.setPage(this.initialPageName || settings.scheme.ui[0].name);
this.actions.setAbilities({ close: true });
this.popPending();
controller.addPreventUnloadCondition('dialog', () => this.isUnsaved());
});
}
/**
* OOUI native method that returns a process for taking action.
*
* @param {string} action Symbolic name of the action.
* @returns {external:OO.ui.Process}
* @see https://doc.wikimedia.org/oojs-ui/master/js/OO.ui.ProcessDialog.html#getActionProcess
* @ignore
*/
getActionProcess(action) {
if (action === 'save') {
return new OO.ui.Process(async () => {
this.pushPending();
try {
await settings.save(this.collectSettings());
} catch (e) {
this.handleError(e, 'error-settings-save', true);
return;
}
controller.removePreventUnloadCondition('dialog');
this.stackLayout.setItem(this.reloadPanel);
this.actions.setMode('reload');
this.popPending();
});
} else if (action === 'reload') {
return new OO.ui.Process(() => {
this.close();
location.reload();
});
} else if (action === 'close') {
return new OO.ui.Process(() => {
this.confirmClose();
});
} else if (action === 'reset') {
return new OO.ui.Process(() => {
if (confirm(cd.s('sd-reset-confirm'))) {
const currentPageName = this.bookletLayout.getCurrentPageName();
this.renderControls(settings.scheme.default);
this.bookletLayout.setPage(currentPageName);
}
});
}
return super.getActionProcess(action);
}
/**
* Create widget fields with states of controls set according to setting values.
*
* @param {object} settingValues Values of settings according to which to set the states of
* controls.
* @returns {object}
* @protected
*/
createPages(settingValues) {
const controls = {};
const pages = settings.scheme.ui.map((pageData) => {
const $fields = pageData.controls.map((data) => {
const name = data.name;
switch (data.type) {
case 'checkbox':
controls[name] = createCheckboxField(Object.assign({
value: name,
selected: settingValues[name],
}, data));
controls[name].input.on('change', this.updateAbilities.bind(this));
break;
case 'radio':
controls[name] = createRadioField(Object.assign({
selected: settingValues[name],
}, data));
controls[name].select.on('select', this.updateAbilities.bind(this));
break;
case 'text':
controls[name] = createTextField(Object.assign({
value: settingValues[name],
}, data));
controls[name].input.on('change', this.updateAbilities.bind(this));
break;
case 'number':
controls[name] = createNumberField(Object.assign({
value: settingValues[name],
}, data));
controls[name].input.on('change', this.updateAbilities.bind(this));
break;
case 'multicheckbox':
controls[name] = {};
controls[name].multiselect = new OO.ui.CheckboxMultiselectWidget({
items: data.options.map((option) => (
new OO.ui.CheckboxMultioptionWidget({
data: option.data,
selected: settingValues[name].includes(option.data),
label: option.label,
})
)),
classes: data.classes,
});
controls[name].multiselect.on('select', this.updateAbilities.bind(this));
controls[name].field = new OO.ui.FieldLayout(controls[name].multiselect, {
label: data.label,
align: 'top',
});
break;
case 'multitag':
controls[name] = {};
controls[name].multiselect = new OO.ui.TagMultiselectWidget({
placeholder: data.placeholder,
allowArbitrary: true,
inputPosition: 'outline',
tagLimit: data.tagLimit,
selected: (data.dataToUi || ((val) => val)).call(null, settingValues[name]),
});
controls[name].multiselect.on('change', this.updateAbilities.bind(this));
controls[name].field = new OO.ui.FieldLayout(controls[name].multiselect, {
label: data.label,
align: 'top',
help: data.help,
helpInline: true,
});
break;
case 'button':
controls[name] = {};
controls[name].button = new OO.ui.ButtonWidget({
label: data.label,
flags: data.flags,
});
controls[name].field = new OO.ui.FieldLayout(controls[name].button, {
label: data.fieldLabel,
align: 'top',
help: data.help,
helpInline: true,
});
break;
}
return controls[name].field.$element;
});
// eslint-disable-next-line jsdoc/require-jsdoc
return new (tweakUserOoUiClass(class extends OO.ui.PageLayout {
// eslint-disable-next-line jsdoc/require-jsdoc
constructor() {
super(pageData.name);
this.$element.append($fields);
}
// eslint-disable-next-line jsdoc/require-jsdoc
setupOutlineItem() {
this.outlineItem.setLabel(pageData.label);
}
}))();
});
controls.removeData.button.connect(this, { click: 'removeData' });
controls.desktopNotifications.select.connect(this, {
choose: 'onDesktopNotificationsSelectChange',
});
this.controls = controls;
return pages;
}
/**
* Render control widgets.
*
* @param {object} settingValues Values of settings according to which to set the states of
* controls.
* @protected
*/
renderControls(settingValues) {
settings.initUi();
this.bookletLayout = new OO.ui.BookletLayout({
outlined: true,
});
this.bookletLayout.addPages(this.createPages(settingValues));
this.settingsPanel.$element.empty().append(this.bookletLayout.$element);
this.updateAbilities();
}
/**
* Get an object with settings related to states (see {@link module:settings.scheme}).
*
* @returns {object}
* @protected
*/
getStateSettings() {
return settings.scheme.states.reduce((obj, state) => {
obj[state] = this.settings[state];
return obj;
}, {});
}
/**
* Get setting values from controls.
*
* @returns {object}
* @protected
*/
collectSettings() {
const collectedSettings = {};
const controls = this.controls;
settings.scheme.ui.forEach((pageData) => {
pageData.controls.forEach((data) => {
const name = data.name;
switch (data.type) {
case 'checkbox':
collectedSettings[name] = controls[name].input.isSelected();
break;
case 'radio':
collectedSettings[name] = (
controls[name].select.findSelectedItem()?.getData() ||
settings.scheme.default[name]
);
break;
case 'text':
collectedSettings[name] = controls[name].input.getValue();
break;
case 'number':
collectedSettings[name] = Number(controls[name].input.getValue());
break;
case 'multicheckbox':
collectedSettings[name] = controls[name].multiselect.findSelectedItemsData();
break;
case 'multitag':
collectedSettings[name] = (data.uiToData || ((val) => val)).call(
null,
controls[name].multiselect.getValue()
);
break;
}
});
});
return Object.assign(
{},
settings.scheme.default,
collectedSettings,
this.getStateSettings(),
{
'insertButtons-altered': (
JSON.stringify(collectedSettings.insertButtons) !==
JSON.stringify(settings.scheme.default.insertButtons)
),
},
);
}
/**
* Update the control states.
*
* @protected
*/
async updateAbilities() {
const controls = this.controls;
controls.collapseThreadsLevel.input.setDisabled(!controls.enableThreads.input.isSelected());
controls.hideTimezone.input.setDisabled(
controls.timestampFormat.select.findSelectedItem()?.getData() === 'relative'
);
controls.notifyCollapsedThreads.input.setDisabled(
controls.desktopNotifications.select.findSelectedItem()?.getData() === 'none' &&
controls.notifications.select.findSelectedItem()?.getData() === 'none'
);
controls.showContribsLink.input.setDisabled(!controls.reformatComments.input.isSelected());
controls.useTemplateData.input.setDisabled(
!controls.autocompleteTypes.multiselect.findItemFromData('templates').isSelected()
);
let areInputsValid = true;
await Promise.all(
[]
.concat(...settings.scheme.ui.map((pageData) => (
pageData.controls
.filter((data) => data.type === 'number')
.map((data) => data.name)
)))
.map((name) => controls[name].input.getValidity())
)
.catch(() => {
areInputsValid = false;
});
const collectedSettings = this.collectSettings();
this.actions.setAbilities({
save: !areObjectsEqual(collectedSettings, this.settings) && areInputsValid,
reset: !areObjectsEqual(
Object.assign({}, collectedSettings),
Object.assign(
{},
settings.scheme.default,
settings.scheme.resetsTo,
this.getStateSettings(),
)
),
});
}
/**
* Handler of the event of change of the desktop notifications radio select.
*
* @param {external:OO.ui.RadioOptionWidget} option
* @protected
*/
onDesktopNotificationsSelectChange(option) {
if (typeof Notification === 'undefined') return;
if (option.data !== 'none' && Notification.permission !== 'granted') {
OO.ui.alert(cd.s('dn-grantpermission'));
Notification.requestPermission((permission) => {
if (permission !== 'granted') {
this.controls.desktopNotifications.select.selectItemByData('none');
}
});
}
}
/**
* Remove script data as requested by the user after confirmation.
*
* @protected
*/
async removeData() {
if (confirm(cd.s('sd-removedata-confirm'))) {
this.pushPending();
try {
await Promise.all([
saveLocalOption(cd.g.localSettingsOptionName, null),
saveLocalOption(cd.g.visitsOptionName, null),
saveLocalOption(cd.g.subscriptionsOptionName, null),
saveGlobalOption(cd.g.settingsOptionName, null),
]);
} catch (e) {
this.handleError(e, 'sd-error-removedata', false);
return;
}
(new StorageItem('commentForms')).removeItem();
(new StorageItem('thanks')).removeItem();
(new StorageItem('seenRenderedChanges')).removeItem();
(new StorageItem('collapsedThreads')).removeItem();
(new StorageItem('mutedUsers')).removeItem();
this.stackLayout.setItem(this.dataDeletedPanel);
this.actions.setMode('dataRemoved');
this.popPending();
}
}
}
tweakUserOoUiClass(SettingsDialog);
export default SettingsDialog;