MediaWiki:Gadget-WishlistIntake.js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
// <nowiki>
mw.loader.using( ["vue","@wikimedia/codex","mediawiki.util","mediawiki.api","user.options","mediawiki.action.view.postEdit","mediawiki.confirmCloseWindow","mediawiki.jqueryMsg"] ).then( ( require ) => {
/**
* WishlistIntake: A gadget for the intake and editing of Community Wishlist proposals.
* From [[Community Tech]]
* Compiled from source at https://gitlab.wikimedia.org/repos/commtech/wishlist-intake
* Please submit code changes as a merge request to the source repository.
*/
'use strict';
function __$styleInject(css) {
if (!css) return;
if (typeof window == 'undefined') return;
var style = document.createElement('style');
style.setAttribute('media', 'screen');
style.innerHTML = css;
document.head.appendChild(style);
return css;
}
var vue = require('vue');
var codex = require('@wikimedia/codex');
/**
* The wikitext template parameters (and optional page ID) of a wish
*/
class Wish {
/**
* Normalize an array of values by trimming whitespace,
* removing empty values, and removing duplicates.
*
* @param {Array<string>} givenArray
* @return {Array<string>}
* @private
*/
static normalizeArray( givenArray ) {
// Trim whitespace.
return givenArray.map( ( value ) => value.trim() )
// Remove empty values and duplicates.
.filter( ( value, index, array ) => value !== '' && array.indexOf( value ) === index );
}
/**
* Get the storage value for a parameter from an array of values.
*
* @param {Array<string>} values
* @return {string}
*/
static getValueFromArray( values ) {
return this.normalizeArray( values ).join( Wish.DELIMITER );
}
/**
* Get an array of values from a storage value for a parameter.
*
* @param {string} value
* @return {Array<string>}
*/
static getArrayFromValue( value ) {
return this.normalizeArray( value.split( Wish.DELIMITER ) );
}
/**
* Props values here should be identical to storage, be it wikitext or MariaDB.
*
* @param {Object} props
*/
constructor( props ) {
// Non-template (metadata) properties
this.pageId = props.pageId || null;
this.page = props.page || '';
this.name = props.name || '';
this.lang = props.lang || '';
this.updated = props.updated || '';
// Template parameters
this.baselang = props.baselang || '';
this.type = props.type || '';
this.status = props.status || '';
this.title = props.title || '';
this.description = props.description || '';
this.audience = props.audience || '';
this.tasks = props.tasks || '';
this.proposer = props.proposer || '';
this.created = props.created || '';
this.projects = props.projects || '';
this.otherproject = props.otherproject || '';
this.area = props.area || '';
}
}
// Delimiter for array types
Wish.DELIMITER = ',';
Wish.TYPE_FEATURE = 'feature';
Wish.TYPE_BUG = 'bug';
Wish.TYPE_CHANGE = 'change';
Wish.TYPE_UNKNOWN = '';
Wish.STATUS_DRAFT = 'draft';
Wish.STATUS_SUBMITTED = 'submitted';
Wish.STATUS_OPEN = 'open';
Wish.STATUS_IN_PROGRESS = 'started';
Wish.STATUS_DELIVERED = 'delivered';
Wish.STATUS_BLOCKED = 'blocked';
Wish.STATUS_ARCHIVED = 'archived';
var script$7 = vue.defineComponent( {
name: 'StatusSection',
components: {
CdxField: codex.CdxField,
CdxSelect: codex.CdxSelect
},
props: {
status: { type: String, default: Wish.STATUS_SUBMITTED },
disabled: { type: Boolean, default: false }
},
emits: [
'update:status'
],
data( props ) {
return {
statusValue: props.status,
statusOptions: [
{ label: mw.msg( 'communityrequests-status-draft' ), value: Wish.STATUS_DRAFT },
{ label: mw.msg( 'communityrequests-status-submitted' ), value: Wish.STATUS_SUBMITTED },
{ label: mw.msg( 'communityrequests-status-open' ), value: Wish.STATUS_OPEN },
{ label: mw.msg( 'communityrequests-status-in-progress' ), value: Wish.STATUS_IN_PROGRESS },
{ label: mw.msg( 'communityrequests-status-delivered' ), value: Wish.STATUS_DELIVERED },
{ label: mw.msg( 'communityrequests-status-blocked' ), value: Wish.STATUS_BLOCKED },
{ label: mw.msg( 'communityrequests-status-archived' ), value: Wish.STATUS_ARCHIVED }
]
};
}
} );
const _hoisted_1$7 = { class: "wishlist-intake-status" };
function render$7(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_select = vue.resolveComponent("cdx-select");
const _component_cdx_field = vue.resolveComponent("cdx-field");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$7, [
vue.createVNode(_component_cdx_field, { disabled: _ctx.disabled }, {
label: vue.withCtx(() => [
vue.createTextVNode(" Status ")
]),
description: vue.withCtx(() => [
vue.createTextVNode(" Only staff can change the status of a wish. ")
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_select, {
selected: _ctx.statusValue,
"onUpdate:selected": [
_cache[0] || (_cache[0] = $event => ((_ctx.statusValue) = $event)),
_cache[1] || (_cache[1] = $event => (_ctx.$emit( 'update:status', $event )))
],
"menu-items": _ctx.statusOptions
}, null, 8 /* PROPS */, ["selected", "menu-items"])
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["disabled"])
]))
}
script$7.render = render$7;
var script$6 = vue.defineComponent( {
name: 'TypeSection',
components: {
CdxField: codex.CdxField,
CdxRadio: codex.CdxRadio
},
props: {
type: { type: String, default: null },
status: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
},
emits: [
'update:type'
],
setup() {
const radios = [
{
label: mw.msg( 'communitywishlist-wishtype-feature-label' ),
description: mw.msg( 'communitywishlist-wishtype-feature-description' ),
value: Wish.TYPE_FEATURE
},
{
label: mw.msg( 'communitywishlist-wishtype-bug-label' ),
description: mw.msg( 'communitywishlist-wishtype-bug-description' ),
value: Wish.TYPE_BUG
},
{
label: mw.msg( 'communitywishlist-wishtype-change-label' ),
description: mw.msg( 'communitywishlist-wishtype-change-description' ),
value: Wish.TYPE_CHANGE
},
{
label: mw.msg( 'communitywishlist-wishtype-unknown-label' ),
description: mw.msg( 'communitywishlist-wishtype-unknown-description' ),
value: Wish.TYPE_UNKNOWN
}
];
return {
radios
};
},
data() {
return {
messages: {}
};
},
watch: {
status: {
handler( newStatus ) {
if ( newStatus === 'error' ) {
this.messages = { error: mw.msg( 'communitywishlist-wishtype-error' ) };
} else {
this.messages = {};
}
}
}
}
} );
const _hoisted_1$6 = { class: "wishlist-intake-type" };
function render$6(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_radio = vue.resolveComponent("cdx-radio");
const _component_cdx_field = vue.resolveComponent("cdx-field");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$6, [
vue.createVNode(_component_cdx_field, {
"is-fieldset": true,
status: _ctx.status,
messages: _ctx.messages,
disabled: _ctx.disabled
}, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-wishtype-label' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-wishtype-description' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
(vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(_ctx.radios, (radio) => {
return (vue.openBlock(), vue.createBlock(_component_cdx_radio, {
key: 'radio-' + radio.value,
"model-value": _ctx.type,
name: "radio-group-descriptions",
"input-value": radio.value,
onInput: _cache[0] || (_cache[0] = $event => (_ctx.$emit( 'update:type', $event.target.value )))
}, {
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(radio.description), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(radio.label) + " ", 1 /* TEXT */)
]),
_: 2 /* DYNAMIC */
}, 1032 /* PROPS, DYNAMIC_SLOTS */, ["model-value", "input-value"]))
}), 128 /* KEYED_FRAGMENT */))
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["status", "messages", "disabled"])
]))
}
script$6.render = render$6;
var script$5 = vue.defineComponent( {
name: 'ProjectSection',
components: {
CdxButton: codex.CdxButton,
CdxCard: codex.CdxCard,
CdxCheckbox: codex.CdxCheckbox,
CdxField: codex.CdxField,
CdxIcon: codex.CdxIcon,
CdxLabel: codex.CdxLabel,
CdxTextInput: codex.CdxTextInput
},
props: {
projects: { type: Array, default: () => [] },
otherproject: { type: String, default: '' },
disabled: { type: Boolean, default: false },
status: { type: String, default: 'default' },
statustype: { type: String, default: 'default' }
},
emits: [
'update:projects',
'update:otherproject'
],
setup() {
// A hacky way to use codex icons in user scripts is to just copy the svg for the icon
// See: https://doc.wikimedia.org/codex/latest/icons/all-icons.html
// TODO: find out why we can't use the JS icons (no @wikimedia/codex-icons module on-wiki) T311099
const cdxIconCollapse = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><!----><g><path d="m2.5 15.25 7.5-7.5 7.5 7.5 1.5-1.5-9-9-9 9z"></path></g></svg>';
const cdxIconExpand = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><!----><g><path d="m17.5 4.75-7.5 7.5-7.5-7.5L1 6.25l9 9 9-9z"></path></g></svg>';
return {
cdxIconExpand,
cdxIconCollapse
};
},
data() {
return {
/**
* Whether the extended project list is shown.
* @type {boolean}
*/
expanded: this.shouldBeExpanded(),
/**
* Error messages to display.
* @see https://doc.wikimedia.org/codex/latest/components/demos/field.html#with-validation-messages
* @type {Object}
*/
messages: {}
};
},
computed: {
/**
* Whether the "All projects" checkbox is ticked.
* @return {boolean}
*/
allProjects() {
return this.projects.length === 1 && this.projects[ 0 ] === 'all';
}
},
methods: {
/**
* Check if a project is selected.
* @param {string} project
* @return {boolean}
*/
isSelectedProject( project ) {
return this.allProjects || this.projects.includes( project );
},
/**
* Handler for (de-)selecting individual projects.
* @param {boolean} selected
* @param {string} project
*/
onUpdateProject( selected, project ) {
const projectList = this.getProjectList();
// Get the full list of projects IDs.
let currentProjects = this.allProjects ?
projectList.map( ( p ) => p.value ) :
this.projects;
// If we're adding a project, and it isn't already in the list, add it.
if ( selected && !currentProjects.includes( project ) ) {
currentProjects.push( project );
} else {
// Otherwise, remove it.
currentProjects = currentProjects.filter( ( p ) => p !== project );
}
// Auto-check "All projects" if all projects are selected.
const intersection = projectList.filter( ( p ) => currentProjects.includes( p.value ) );
const willBeAllProjects = intersection.length === projectList.length;
if ( willBeAllProjects ) {
currentProjects = [ 'all' ];
} else {
// Remove any unknown values (T362275#9912455)
currentProjects = currentProjects.filter( ( p ) => {
return projectList.some( ( p2 ) => p2.value === p );
} );
}
// Bubble up the selected projects to WishlistIntake.
this.$emit( 'update:projects', currentProjects );
},
/**
* Handler for clicking the project checkbox.
* @param {MouseEvent} event
* @param {string} project
*/
onClickProjectCheckbox( event, project ) {
// Prevent the card from being clicked when the checkbox is clicked.
event.stopPropagation();
this.onUpdateProject( !this.isSelectedProject( project ), project );
},
/**
* Handler for (de-)selecting all projects.
* @param {boolean} selectAll
*/
onUpdateAllProjects( selectAll ) {
// Auto-expand when selecting all projects, otherwise keep the current state.
this.expanded = selectAll ? true : this.expanded;
this.$emit( 'update:projects', selectAll ? [ 'all' ] : [] );
},
/**
* Card data for the top projects.
*
* @see https://doc.wikimedia.org/codex/latest/components/demos/card.html
* @return {Array<Object>}
*/
getTopProjects() {
return [
{
// NOTE: values are mapped to localized strings in Module:Community_Wishlist.
value: 'wikipedia',
url: 'www.wikipedia.org',
label: mw.msg( 'project-localized-name-group-wikipedia' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/Wikipedia-logo-v2.svg/263px-Wikipedia-logo-v2.svg.png'
}
},
{
value: 'wikidata',
url: 'www.wikidata.org',
label: mw.msg( 'project-localized-name-wikidatawiki' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Wikidata-logo.svg/200px-Wikidata-logo.svg.png'
}
},
{
value: 'commons',
url: 'commons.wikimedia.org',
label: mw.msg( 'project-localized-name-commonswiki' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Commons-logo.svg/200px-Commons-logo.svg.png'
}
},
{
value: 'wikisource',
url: 'www.wikisource.org',
label: mw.msg( 'project-localized-name-group-wikisource' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4c/Wikisource-logo.svg/200px-Wikisource-logo.svg.png'
}
}
];
},
/**
* Card data for the extended projects.
*
* @return {Array<Object>}
*/
getExtendedProjects() {
return [
{
value: 'wiktionary',
url: 'www.wiktionary.org',
label: mw.msg( 'project-localized-name-group-wiktionary' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/ec/Wiktionary-logo.svg/200px-Wiktionary-logo.svg.png'
}
},
{
value: 'wikivoyage',
url: 'www.wikivoyage.org',
label: mw.msg( 'project-localized-name-group-wikivoyage' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Wikivoyage-Logo-v3-icon.svg/200px-Wikivoyage-Logo-v3-icon.svg.png'
}
},
{
value: 'wikiquote',
url: 'www.wikiquote.org',
label: mw.msg( 'project-localized-name-group-wikiquote' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Wikiquote-logo.svg/200px-Wikiquote-logo.svg.png'
}
},
{
value: 'wikiversity',
url: 'www.wikiversity.org',
label: mw.msg( 'project-localized-name-group-wikiversity' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/Wikiversity_logo_2017.svg/200px-Wikiversity_logo_2017.svg.png'
}
},
{
value: 'wikifunctions',
url: 'www.wikifunctions.org',
label: mw.msg( 'project-localized-name-wikifunctionswiki' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Wikifunctions-logo.svg/200px-Wikifunctions-logo.svg.png'
}
},
{
value: 'wikispecies',
url: 'www.wikispecies.org',
label: mw.msg( 'project-localized-name-specieswiki' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/Wikispecies-logo.svg/200px-Wikispecies-logo.svg.png'
}
},
{
value: 'wikinews',
url: 'www.wikinews.org',
label: mw.msg( 'project-localized-name-group-wikinews' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/24/Wikinews-logo.svg/200px-Wikinews-logo.svg.png'
}
},
{
value: 'metawiki',
url: 'meta.wikimedia.org',
label: mw.msg( 'project-localized-name-metawiki' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Wikimedia_Community_Logo.svg/200px-Wikimedia_Community_Logo.svg.png'
}
},
{
value: 'wmcs',
url: 'wikitech.wikimedia.org',
label: mw.msg( 'wikimedia-otherprojects-cloudservices' ),
thumbnail: {
width: 200,
height: 150,
url: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/Wikimedia_Cloud_Services_logo.svg/200px-Wikimedia_Cloud_Services_logo.svg.png'
}
}
];
},
/**
* Get a list of projects to display.
* @param {boolean} [expanded=true] Whether to show all projects.
* @return {Array<Object>}
*/
getProjectList( expanded = true ) {
if ( expanded ) {
return this.getTopProjects().concat( this.getExtendedProjects() );
}
return this.getTopProjects();
},
/**
* Whether the projects list should be expanded.
* Note this intentionally is false when this.projects is `['all']`,
* unless 'otherproject' is not empty. The idea being we only auto-expand
* the projects list on initial load if there are projects selected that
* are not in the top projects list.
*
* @return {boolean}
*/
shouldBeExpanded() {
return this.projects.some( ( project ) => {
return this.getExtendedProjects()
.some( ( p ) => p.value === project );
} ) || this.otherproject.trim() !== '';
}
},
watch: {
statustype: {
handler( newStatus ) {
if ( newStatus === 'noSelection' ) {
const otherLabel = mw.msg( 'communitywishlist-project-other-label' );
this.messages = {
error: mw.msg( 'communitywishlist-project-no-selection', 1, otherLabel )
};
} else if ( newStatus === 'invalidOther' ) {
this.messages = {
error: mw.msg( 'communitywishlist-project-other-error', 3 )
};
} else {
this.messages = {};
}
}
}
}
} );
const _hoisted_1$5 = { class: "wishlist-intake-project" };
const _hoisted_2$2 = {
role: "group",
class: "cdx-docs-card-group-with-thumbnails"
};
const _hoisted_3$2 = ["aria-label"];
const _hoisted_4$1 = {
key: 0,
class: "wishlist-intake-project-other"
};
function render$5(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_label = vue.resolveComponent("cdx-label");
const _component_cdx_checkbox = vue.resolveComponent("cdx-checkbox");
const _component_cdx_card = vue.resolveComponent("cdx-card");
const _component_cdx_text_input = vue.resolveComponent("cdx-text-input");
const _component_cdx_field = vue.resolveComponent("cdx-field");
const _component_cdx_icon = vue.resolveComponent("cdx-icon");
const _component_cdx_button = vue.resolveComponent("cdx-button");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$5, [
vue.createVNode(_component_cdx_field, {
disabled: _ctx.disabled,
status: _ctx.status,
messages: _ctx.messages
}, {
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_label, null, {
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-project-help' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-project-intro' ).text()) + " ", 1 /* TEXT */)
]),
_: 1 /* STABLE */
}),
vue.createVNode(_component_cdx_checkbox, {
indeterminate: _ctx.projects.length > 0 && !_ctx.allProjects,
"model-value": _ctx.allProjects,
"onUpdate:modelValue": _ctx.onUpdateAllProjects
}, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-project-all-projects' ).text()), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["indeterminate", "model-value", "onUpdate:modelValue"]),
vue.createElementVNode("div", _hoisted_2$2, [
(vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(_ctx.getProjectList( _ctx.expanded ), (project) => {
return (vue.openBlock(), vue.createBlock(_component_cdx_card, {
key: project.value,
class: "cdx-docs-card-group-with-thumbnails__card",
thumbnail: project.thumbnail,
onClick: $event => (_ctx.onUpdateProject( !_ctx.isSelectedProject( project.value ), project.value ))
}, {
title: vue.withCtx(() => [
vue.createElementVNode("span", {
"aria-label": `project-${ project.value }`
}, vue.toDisplayString(project.label), 9 /* TEXT, PROPS */, _hoisted_3$2),
(vue.openBlock(), vue.createBlock(_component_cdx_checkbox, {
key: 'project-' + project.value,
"model-value": _ctx.isSelectedProject( project.value ),
"input-value": project.value,
"aria-labelledby": 'project-' + project.value,
onClick: $event => (_ctx.onClickProjectCheckbox( $event, project.value ))
}, null, 8 /* PROPS */, ["model-value", "input-value", "aria-labelledby", "onClick"]))
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(project.url), 1 /* TEXT */)
]),
_: 2 /* DYNAMIC */
}, 1032 /* PROPS, DYNAMIC_SLOTS */, ["thumbnail", "onClick"]))
}), 128 /* KEYED_FRAGMENT */))
]),
(_ctx.expanded)
? (vue.openBlock(), vue.createElementBlock("div", _hoisted_4$1, [
vue.createVNode(_component_cdx_field, null, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-project-other-label' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-project-other-description' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_text_input, {
"model-value": _ctx.otherproject,
"aria-label": _ctx.$i18n( 'communitywishlist-project-other-label' ).text(),
onInput: _cache[0] || (_cache[0] = $event => (_ctx.$emit( 'update:otherproject', $event.target.value )))
}, null, 8 /* PROPS */, ["model-value", "aria-label"])
]),
_: 1 /* STABLE */
})
]))
: vue.createCommentVNode("v-if", true),
vue.createVNode(_component_cdx_button, {
weight: "quiet",
class: "wishlist-intake-project-toggle",
action: "progressive",
type: "button",
onClick: _cache[1] || (_cache[1] = $event => (_ctx.expanded = !_ctx.expanded))
}, {
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_icon, {
icon: _ctx.expanded ? _ctx.cdxIconCollapse : _ctx.cdxIconExpand
}, null, 8 /* PROPS */, ["icon"]),
vue.createTextVNode(" " + vue.toDisplayString(_ctx.expanded ?
_ctx.$i18n( 'communitywishlist-project-show-less' ).text() :
_ctx.$i18n( 'communitywishlist-project-show-all' ).text()), 1 /* TEXT */)
]),
_: 1 /* STABLE */
})
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["disabled", "status", "messages"])
]))
}
__$styleInject(".wishlist-intake-project .wishlist-intake-project-other .cdx-field {\n margin-top: 12px;\n}\n.wishlist-intake-project .cdx-card .cdx-checkbox {\n position: absolute;\n top: 12px;\n right: 12px;\n}\n[dir='rtl'] .wishlist-intake-project .cdx-card .cdx-checkbox {\n left: 12px;\n right: auto;\n}\n.wishlist-intake-project-toggle {\n margin-top: 16px;\n}\n.wishlist-intake-project-other {\n margin-top: 16px;\n}\n.cdx-docs-card-group-with-thumbnails {\n display: grid;\n grid-template-columns: auto auto;\n gap: 16px;\n}\n.cdx-docs-card-group-with-thumbnails p {\n margin-top: 100px;\n font-weight: 700;\n}\n.cdx-docs-card-group-with-thumbnails__card {\n cursor: pointer;\n padding: 16px;\n}\n.cdx-docs-card-group-with-thumbnails__card .cdx-card__thumbnail.cdx-thumbnail .cdx-thumbnail__image {\n background-size: contain;\n border: 0;\n}\n@media (max-width: calc(640px - 1px)) {\n .cdx-docs-card-group-with-thumbnails {\n grid-template-columns: none;\n }\n}\n");
script$5.render = render$5;
var wishHomePage = "Community Wishlist";
var wishIntakePage = "Community Wishlist/Intake";
var wishEditParam = "editwish";
var voteRecordedStorageName = "wishlist-intake-vote-added";
var wishIndexTemplate = "Community Wishlist/Wishes";
var wishIndexTemplateAll = "Community Wishlist/Wishes/All";
var wishIndexTemplateRecent = "Community Wishlist/Wishes/Recent";
var wishIndexTemplateArchive = "Community Wishlist/Wishes/Archive";
var wishCategory = "Community Wishlist/Wishes";
var wishPagePrefix = "Community Wishlist/Wishes/";
var wishTemplate = "Community Wishlist/Wish";
var focusAreaPagePrefix = "Community Wishlist/Focus areas/";
var focusAreaVoteCountSuffix = "/Vote count";
var focusAreaTemplate = "Community Wishlist/Focus area";
var focusAreasTemplate = "Community Wishlist/Focus areas";
var focusAreaTemplateAll = "Community Wishlist/Focus areas/All";
var focusAreaTemplateTop = "Community Wishlist/Focus areas/Top";
var supportTemplate = "Community Wishlist/Support";
var messagesPage = "MediaWiki:Gadget-WishlistIntake/messages";
var interfaceMessageGroupId = "agg-Community_Wishlist_interface";
var wishesMessageGroupId = "agg-Community_Wishlist_wishes";
var maxRecentWishes = 5;
var gadgets = {
WishlistIntake: {
description: "A gadget for the intake and editing of Community Wishlist proposals.",
ResourceLoader: true,
"default": true,
hidden: true,
"package": true,
files: [
"WishlistIntake.js"
],
rights: [
"editmyusercss"
],
categories: [
"Community Wishlist/Intake"
],
dependencies: [
"vue",
"@wikimedia/codex",
"mediawiki.util",
"mediawiki.api",
"user.options",
"mediawiki.action.view.postEdit",
"mediawiki.confirmCloseWindow",
"mediawiki.jqueryMsg"
],
peers: [
"WishlistIntake-pagestyles"
]
},
"WishlistIntake-pagestyles": {
peer: true,
hidden: true,
files: [
"WishlistIntake-pagestyles.css"
],
filesOnWiki: true
},
WishlistManager: {
description: "A gadget helping with management tasks for the Community Wishlist.",
ResourceLoader: true,
"default": true,
hidden: true,
"package": true,
files: [
"WishlistManager.js"
],
rights: [
"editmyusercss"
],
dependencies: [
"mediawiki.api",
"mediawiki.util"
],
messages: [
"communityrequests-edit-with-form"
]
},
WishlistTranslation: {
description: "A gadget for machine translation of pages within the Community Wishlist.",
ResourceLoader: true,
"package": true,
files: [
"WishlistTranslation.js"
],
categories: [
"Community Wishlist/Intake"
],
dependencies: [
"vue",
"@wikimedia/codex",
"mediawiki.language.names",
"mediawiki.storage"
],
messages: [
"communityrequests-translation-translatable",
"communityrequests-translation-switch",
"communityrequests-translation-progress",
"communityrequests-translation-errors"
]
}
};
var messages = {
"communitywishlist-form-subtitle": "Welcome to the new Community Wishlist. Please fill in the form below to submit your wish.",
"communitywishlist-description": "Describe your problem",
"communitywishlist-description-description": "Explain in detail the wish or problem you are addressing.",
"communitywishlist-title": "Wish title",
"communitywishlist-title-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|character|characters}}).",
"communitywishlist-title-description": "Make sure your title contains a brief description of the wish or problem.",
"communitywishlist-description-error": "Please enter a value for this field ($1 or more {{PLURAL:$1|character|characters}}).",
"communitywishlist-wishtype-label": "Which type best describes your wish?",
"communitywishlist-wishtype-description": "For submitting a policy change request, please consult the applicable project.",
"communitywishlist-wishtype-feature-label": "Feature request",
"communitywishlist-wishtype-feature-description": "You want new features and functions that do not exist yet.",
"communitywishlist-wishtype-bug-label": "Bug report",
"communitywishlist-wishtype-bug-description": "You want a problem or error fixed with existing features.",
"communitywishlist-wishtype-change-label": "System change",
"communitywishlist-wishtype-change-description": "You want a currently working feature or function to be changed.",
"communitywishlist-wishtype-unknown-label": "I'm not sure or I don't know",
"communitywishlist-wishtype-unknown-description": "After receiving your wish, we will assign a relevant type.",
"communitywishlist-wishtype-error": "Please select a wish type.",
"communitywishlist-project-intro": "Which projects is your wish related to?",
"communitywishlist-project-help": "Select all projects your wish will have an impact on.",
"communitywishlist-project-all-projects": "All projects",
"communitywishlist-project-show-less": "Show less",
"communitywishlist-project-show-all": "Show all",
"communitywishlist-project-other-label": "It's something else",
"communitywishlist-project-other-description": "e.g. gadgets, bots and external tools",
"communitywishlist-project-other-error": "Please enter a value for this field (greater than $1 {{PLURAL:$1|character|characters}}), or select a project checkbox.",
"communitywishlist-project-no-selection": "Please select at least $1 {{PLURAL:$1|project checkbox|project checkboxes}}, or enter a value for the \"$2\" field.",
"communitywishlist-audience-label": "Primary affected users",
"communitywishlist-audience-description": "Describe which user group and situation this will affect the most",
"communitywishlist-audience-error": "Please enter a value for this field (between $1 and $2 {{PLURAL:$2|characters}}).",
"communitywishlist-phabricator-label": "Phabricator tasks (optional)",
"communitywishlist-phabricator-desc": "Enter Phabricator task IDs or URLs.",
"communitywishlist-phabricator-chip-desc": "A list of Phabricator task IDs.",
"communitywishlist-create-success": "Your wish has been submitted.",
"communitywishlist-edit-success": "Your wish has been saved.",
"communitywishlist-view-all-wishes": "View all wishes.",
"communitywishlist-close": "Close",
"communitywishlist-publish": "Publish wish",
"communitywishlist-save": "Save changes",
"communitywishlist-support-focus-area": "Support focus area",
"communitywishlist-support-focus-area-dialog-title": "Support \"$1\"",
"communitywishlist-optional-comment": "Optional comment",
"communitywishlist-supported": "Already supported",
"communitywishlist-support-focus-area-confirmed": "You have voted in support of this focus area.",
"communitywishlist-unsupport-focus-area": "Remove your support vote"
};
var importedMessages = [
"communityrequests-form-error",
"communityrequests-status-draft",
"communityrequests-status-submitted",
"communityrequests-status-open",
"communityrequests-status-in-progress",
"communityrequests-status-delivered",
"communityrequests-status-blocked",
"communityrequests-status-archived",
"communityrequests-wish-loading-error",
"project-localized-name-commonswiki",
"project-localized-name-group-wikinews",
"project-localized-name-group-wikipedia",
"project-localized-name-group-wikiquote",
"project-localized-name-group-wikisource",
"project-localized-name-group-wikiversity",
"project-localized-name-group-wikivoyage",
"project-localized-name-group-wiktionary",
"project-localized-name-mediawikiwiki",
"project-localized-name-metawiki",
"project-localized-name-specieswiki",
"project-localized-name-wikidatawiki",
"project-localized-name-wikifunctionswiki",
"wikimedia-otherprojects-cloudservices",
"cancel",
"wikimedia-copyrightwarning"
];
var config = {
wishHomePage: wishHomePage,
wishIntakePage: wishIntakePage,
wishEditParam: wishEditParam,
voteRecordedStorageName: voteRecordedStorageName,
wishIndexTemplate: wishIndexTemplate,
wishIndexTemplateAll: wishIndexTemplateAll,
wishIndexTemplateRecent: wishIndexTemplateRecent,
wishIndexTemplateArchive: wishIndexTemplateArchive,
wishCategory: wishCategory,
wishPagePrefix: wishPagePrefix,
wishTemplate: wishTemplate,
focusAreaPagePrefix: focusAreaPagePrefix,
focusAreaVoteCountSuffix: focusAreaVoteCountSuffix,
focusAreaTemplate: focusAreaTemplate,
focusAreasTemplate: focusAreasTemplate,
focusAreaTemplateAll: focusAreaTemplateAll,
focusAreaTemplateTop: focusAreaTemplateTop,
supportTemplate: supportTemplate,
messagesPage: messagesPage,
interfaceMessageGroupId: interfaceMessageGroupId,
wishesMessageGroupId: wishesMessageGroupId,
maxRecentWishes: maxRecentWishes,
gadgets: gadgets,
messages: messages,
importedMessages: importedMessages
};
/**
* Utility functions for the gadget
*/
class WebUtil {
/**
* Get the full page name with underscores replaced by spaces.
* We use this instead of wgTitle because it's possible to set up
* the wishlist gadget for use outside the mainspace.
*
* @return {string}
*/
static getPageName() {
return mw.config.get( 'wgPageName' ).replaceAll( '_', ' ' );
}
/**
* Is the current page a wish page?
*
* @return {boolean}
*/
static isWishPage() {
return this.getPageName().startsWith( config.wishPagePrefix );
}
/**
* Are we currently creating a new wish?
*
* @return {boolean}
*/
static isNewWish() {
return this.getPageName().startsWith( config.wishIntakePage ) &&
mw.config.get( 'wgAction' ) === 'view' &&
!this.isWishEdit() &&
// Don't load on diff pages
!mw.config.get( 'wgDiffOldId' );
}
/**
* Are we currently viewing (but not editing) a wish page?
*
* @return {boolean}
*/
static isWishView() {
return this.isWishPage() && !this.isWishEdit() && mw.config.get( 'wgAction' ) === 'view';
}
/**
* Are we currently editing a wish page?
*
* @return {boolean}
*/
static isWishEdit() {
return this.isWishPage() && !!mw.util.getParamValue( config.wishEditParam );
}
/**
* Are we currently manually editing a wish page?
*
* @return {boolean}
*/
static isManualWishEdit() {
return this.isWishPage() &&
(
mw.config.get( 'wgAction' ) === 'edit' ||
document.documentElement.classList.contains( 've-active' )
);
}
/**
* Are we currently on a focus area page?
*
* @return {boolean}
*/
static isFocusAreaPage() {
return this.getPageName().startsWith( config.focusAreaPagePrefix );
}
/**
* Get the user's preferred language.
*
* @return {string}
*/
static userPreferredLang() {
if ( mw.config.get( 'wgArticleId' ) === 0 ) {
// Use interface language for new pages.
return mw.config.get( 'wgUserLanguage' );
}
// Use content language for existing pages.
return mw.config.get( 'wgContentLanguage' );
}
/**
* Is the user's preferred language right-to-left?
*
* @return {boolean}
*/
static isRtl() {
return $( 'body' ).css( 'direction' ) === 'rtl';
}
/**
* Are we on a page related to creating, editing, or viewing a wish?
* This can include viewing the revision history, manual editing of wishes, etc.
*
* @return {boolean}
*/
static isWishRelatedPage() {
return this.isNewWish() || this.isWishEdit() || this.isWishView() || this.isWishPage();
}
/**
* Should we show the intake form?
*
* @return {boolean}
*/
static shouldShowForm() {
// Prevent form from loading on i.e. action=history
return mw.config.get( 'wgAction' ) === 'view' &&
( this.isNewWish() || this.isWishEdit() );
}
/**
* Get the slug for the wish derived from the page title.
* This is the subpage title and not necessarily the wish title,
* which is stored in the proposal content.
*
* @return {string|null} null if not a wish-related page
*/
static getWishSlug() {
if ( this.isNewWish() ) {
// New wishes have no slug yet.
return '';
} else if ( this.isWishPage() ) {
// Existing wishes have the page prefix stripped.
const slugPortion = this.getPageName().slice( config.wishPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in wish slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the slug for the focus area derived from the page title.
*
* @return {string|null} null if not a focus area page
*/
static getFocusAreaSlug() {
if ( this.isFocusAreaPage() ) {
const slugPortion = this.getPageName().slice( config.focusAreaPagePrefix.length );
// Strip off language subpage. Slashes are disallowed in focus area slugs.
return slugPortion.split( '/' )[ 0 ];
}
return null;
}
/**
* Get the full page title of the wish from the slug.
*
* @param {string} slug
* @return {string}
*/
static getWishPageTitleFromSlug( slug ) {
return config.wishPagePrefix + slug;
}
/**
* Is the user WMF staff?
*
* @todo WMF-specific
* @return {boolean}
*/
static isStaff() {
return /\s\(WMF\)$|-WMF$/.test( mw.config.get( 'wgUserName' ) );
}
/**
* Log an error to the console.
*
* @param {string} text
* @param {Error} error
*/
static logError( text, error ) {
mw.log.error( `[WishlistIntake] ${ text }`, error );
}
/**
* Get a CSS-only Codex Message component of the specified type.
* This is for use outside the Vue application.
*
* @param {mw.Message} message
* @param {string} type 'notice', 'warning', 'error' or 'success'
* @return {HTMLDivElement}
*/
static getMessageBox( message, type ) {
const messageBlock = document.createElement( 'div' );
// The following messages may be used here:
// * cdx-message--notice
// * cdx-message--warning
// * cdx-message--error
// * cdx-message--success
messageBlock.classList.add( 'cdx-message', 'cdx-message--block', `cdx-message--${ type }` );
if ( type === 'warning' ) {
messageBlock.role = 'alert';
} else {
messageBlock.ariaLive = 'polite';
}
const icon = document.createElement( 'span' );
icon.classList.add( 'cdx-message__icon' );
const content = document.createElement( 'div' );
content.classList.add( 'cdx-message__content' );
content.innerHTML = message.parse();
messageBlock.appendChild( icon );
messageBlock.appendChild( content );
return messageBlock;
}
/**
* Is the user viewing in mobile format?
*
* @return {boolean}
*/
static isMobile() {
return !!mw.config.get( 'wgMFMode' );
}
/**
* Fetch messages from the wiki and set them in mw.messages.
*
* @param {mw.Api} api
* @return {jQuery.Promise}
*/
static setOnWikiMessages( api ) {
const titles = [ config.messagesPage + '/en' ],
langPageLocal = config.messagesPage + '/' + mw.config.get( 'wgUserLanguage' );
if ( mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
titles.push( langPageLocal );
}
return api.get( {
action: 'query',
prop: 'revisions',
titles,
rvprop: 'content',
rvslots: 'main',
format: 'json',
formatversion: 2,
// Cache for 30 minutes.
maxage: 1800,
smaxage: 1800
} ).then( ( resp ) => {
let messagesLocal = {},
messagesEn = {};
/**
* The content model of the messages page is wikitext so that it can be used with
* Extension:Translate. Consequently, it's possible to break things. This just does
* a try/catch and returns the default English messages if it fails.
*
* @param {string} title
* @param {string} content
* @return {Object}
*/
const parseJSON = ( title, content ) => {
try {
return JSON.parse( content );
} catch ( e ) {
WebUtil.logError( `Failed to parse JSON for ${ title }.`, e );
return { messages: config.messages };
}
};
resp.query.pages.forEach( ( page ) => {
if ( !page.revisions ) {
// Missing
return;
}
const pageObj = page.revisions[ 0 ].slots.main;
const parsedContent = parseJSON( config.messagesPage, pageObj.content );
if ( page.title === langPageLocal && mw.config.get( 'wgUserLanguage' ) !== 'en' ) {
messagesLocal = parsedContent.messages;
} else {
messagesEn = parsedContent.messages;
}
} );
mw.messages.set( Object.assign( messagesEn, messagesLocal ) );
} );
}
}
class DescriptionField {
/**
* @param {HTMLElement|jQuery|string} node
* @param {string} content
*/
constructor( node, content ) {
/**
* The textarea that will be replaced by the VisualEditor.
*
* @type {jQuery}
*/
this.$textarea = $( node );
/**
* The wrapper of the textarea and the VisualEditor.
*
* @type {jQuery}
*/
this.$veWrapper = this.$textarea.parent();
/**
* The MediaWiki target.
*
* @type {ve.init.mw.Target}
*/
this.target = null;
/**
* The VisualEditor surface.
*
* @type {ve.ui.Surface}
*/
this.surface = null;
/**
* The contents of the textarea/surface.
*
* @type {string} Always wikitext.
*/
this.content = content;
}
/**
* All dependencies required for our VisualEditor implementation.
*
* @return {string[]}
*/
get dependencies() {
const dependencies = WebUtil.isMobile() ?
[ 'ext.visualEditor.core.mobile' ] :
[ 'ext.visualEditor.core.desktop' ];
return dependencies.concat( [
'ext.visualEditor.mwcore',
'ext.visualEditor.mwwikitext',
'ext.visualEditor.switching',
'ext.visualEditor.desktopTarget',
'ext.visualEditor.mwextensions',
'oojs-ui-widgets',
'oojs-ui.styles.indicators',
'oojs-ui.styles.icons-editing-styling',
'oojs-ui.styles.icons-editing-list',
'mediawiki.ForeignStructuredUpload.BookletLayout',
// Intentionally hand-picked PluginModules
'ext.cite.visualEditor',
'ext.citoid.visualEditor',
'ext.translate.ve'
] );
}
/**
* Get the current mode of the VisualEditor,
* or the default mode if the ve.ui.Surface is not initialized.
*
* @return {string} 'source' or 'visual'
*/
get mode() {
return this.surface ? this.surface.getMode() : this.defaultMode;
}
/**
* Get the default mode of the VisualEditor.
*
* @return {string} 'source' or 'visual'
*/
get defaultMode() {
return mw.user.options.get( 'visualeditor-editor' ) === 'visualeditor' ?
'visual' :
'source';
}
/**
* Initialize the VisualEditor.
*/
init() {
// Add modes and other tools the toolbar registry.
const { CwVisualEditModeTool, CwSourceEditModeTool } = this.getEditModeTools();
ve.ui.toolFactory.register( CwVisualEditModeTool );
ve.ui.toolFactory.register( CwSourceEditModeTool );
ve.ui.toolFactory.register( ve.ui.MWCitationDialogTool );
ve.ui.toolFactory.register( ve.ui.MWReferenceDialogTool );
ve.ui.toolFactory.register( ve.ui.CitoidInspectorTool );
ve.init.mw.Platform.static.initializedPromise
.fail( () => {
this.$veWrapper.text( 'Sorry, this browser is not supported.' );
} )
.done( this.createTarget.bind( this ) );
}
/**
* Get the VisualEditor edit mode tools, customized to switch between source/visual.
*
* @return {Object} { CwVisualEditModeTool, CwSourceEditModeTool }
*/
getEditModeTools() {
const CwEditModeTool = function () {};
OO.initClass( CwEditModeTool );
OO.inheritClass( CwEditModeTool, mw.libs.ve.MWEditModeTool );
CwEditModeTool.prototype.getMode = function () {
if ( !this.toolbar.getSurface() ) {
return 'source';
}
return this.toolbar.getSurface().getMode();
};
const CwVisualEditModeTool = function () {
CwEditModeTool.super.apply( this, arguments );
CwEditModeTool.call( this );
};
OO.inheritClass( CwVisualEditModeTool, mw.libs.ve.MWEditModeVisualTool );
OO.mixinClass( CwVisualEditModeTool, CwEditModeTool );
const CwSourceEditModeTool = function () {
CwEditModeTool.super.apply( this, arguments );
CwEditModeTool.call( this );
};
OO.inheritClass( CwSourceEditModeTool, mw.libs.ve.MWEditModeSourceTool );
OO.mixinClass( CwSourceEditModeTool, CwEditModeTool );
return {
CwVisualEditModeTool,
CwSourceEditModeTool
};
}
/**
* Get the content of the Surface.
*
* @return {jQuery.Promise<string>} HTML or wikitext
*/
getWikitext() {
return this.mode === 'source' ?
ve.createDeferred().resolve( this.surface.getDom() ).promise() :
this.target.getWikitextFragment( this.surface.getModel().getDocument() );
}
get toolbarGroups() {
if ( WebUtil.isMobile() ) {
// The following is identical to ve.init.mw.MobileArticleTarget.static.toolbarGroups.
// We don't reference it because we don't want to load all of the
// ext.visualEditor.mobileArticleTarget module and its dependencies.
return [
// History
{
name: 'history',
include: [ 'undo' ]
},
// Style
{
name: 'style',
classes: [ 've-test-toolbar-style' ],
type: 'list',
icon: 'textStyle',
title: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
label: OO.ui.deferMsg( 'visualeditor-toolbar-style-tooltip' ),
invisibleLabel: true,
include: [ { group: 'textStyle' }, 'language', 'clear' ],
forceExpand: [ 'bold', 'italic', 'clear' ],
promote: [ 'bold', 'italic' ],
demote: [ 'strikethrough', 'code', 'underline', 'language', 'clear' ]
},
// Link
{
name: 'link',
include: [ 'link' ]
}
];
}
return ve.init.mw.Target.static.toolbarGroups;
}
/**
* Create the VisualEditor target and add initial content to the Surface.
*
* @param {string} mode 'source' or 'visual'
* @return {jQuery.Promise}
*/
createTarget( mode = this.defaultMode ) {
this.target = new ve.init.mw.Target( {
surfaceClasses: [ 'wishlist-intake-ve-surface' ],
modes: [ 'visual', 'source' ],
defaultMode: mode,
toolbarConfig: { position: 'top' },
toolbarGroups: [
...this.toolbarGroups,
{
name: 'editMode',
type: 'list',
icon: 'edit',
title: ve.msg( 'visualeditor-mweditmode-tooltip' ),
label: ve.msg( 'visualeditor-mweditmode-tooltip' ),
invisibleLabel: true,
include: [ 'editModeVisual', 'editModeSource' ],
align: WebUtil.isMobile() ? 'center' : 'after'
}
]
} );
// Listener for edit mode switch.
this.target.getToolbar().on( 'switchEditor', this.switchEditor.bind( this ) );
// Add initial content.
return this.setSurface( this.content, mode )
.then( () => {
// Add the target to the document.
this.$veWrapper.html( this.target.$element );
this.setPending( false );
} );
}
/**
* Switch the editor to the specified mode.
*
* @param {string} mode 'source' or 'visual'
* @return {jQuery.Promise}
*/
switchEditor( mode ) {
if ( mode === this.mode ) {
return ve.createDeferred().resolve().promise();
}
this.setPending( true );
return this.getWikitext().then( ( content ) => {
this.content = content;
} ).then( () => {
const oldTarget = this.target;
return this.createTarget( mode ).then( () => {
oldTarget.destroy();
this.surface.focus();
// Silently save preference.
const editor = mode === 'source' ? 'wikitext' : 'visualeditor';
new mw.Api().saveOption( 'visualeditor-editor', editor ).then( () => {
mw.user.options.set( 'visualeditor-editor', editor );
} );
} );
} );
}
/**
* Set the content of the Surface.
*
* @param {string} wikitext
* @param {string} mode 'source' or 'visual'
* @return {jQuery.Promise}
*/
setSurface( wikitext, mode ) {
return this.getDocFromWikitext( wikitext, mode ).then( ( doc ) => {
this.target.clearSurfaces();
this.surface = this.target.addSurface( doc );
} );
}
/**
* Create a document model from the given wikitext.
*
* @param {string} wikitext
* @param {string} mode 'source' or 'visual'
* @return {jQuery.Promise<ve.dm.Document>}
*/
getDocFromWikitext( wikitext, mode ) {
const options = {
lang: WebUtil.userPreferredLang(),
dir: WebUtil.isRtl() ? 'rtl' : 'ltr'
};
if ( mode === 'source' ) {
return ve.createDeferred().resolve(
ve.dm.sourceConverter.getModelFromSourceText( wikitext, options )
).promise();
}
const outerPromise = ve.createDeferred();
// Transform the wikitext to HTML.
this.target.parseWikitextFragment( wikitext ).then( ( resp ) => {
const htmlDoc = this.target.parseDocument( resp.visualeditor.content );
// Avoids issues like T253584 where IDs could clash.
mw.libs.ve.stripRestbaseIds( htmlDoc );
const doc = ve.dm.converter.getModelFromDom( htmlDoc, options );
outerPromise.resolve( doc );
} );
return outerPromise;
}
/**
* Synchronize changes from the Surface to the textarea.
*
* @return {jQuery.Promise}
*/
syncChangesToTextarea() {
return this.getWikitext().then( ( content ) => {
this.$textarea.val( this.escapePipesInTables( content ) );
// Propagate the change to the Vue model.
this.$textarea[ 0 ].dispatchEvent( new Event( 'input' ) );
} );
}
/**
* Escape pipes in tables where they may confuse the parser.
*
* This algorithm is far from perfect, but should be satisfactory in most cases.
* Known issues include:
* * Template calls within a table, and those calls include a pipe at the beginning of a line.
* * Complex or multiline use of <nowiki> or <pre>
*
* Some code adapted from Extension:VEForAll (GPL-2.0-or-later)
* See https://w.wiki/AVB5
*
* @param {string} wikitext
* @return {string}
*/
escapePipesInTables( wikitext ) {
const lines = wikitext.split( '\n' );
let withinTable = false;
for ( let i = 0; i < lines.length; i++ ) {
const curLine = lines[ i ];
// start of table is {|, but could be also escaped, like this: {{{!}}
if ( curLine.indexOf( '{|' ) === 0 || curLine.indexOf( '{{{!}}' ) === 0 ) {
withinTable = true;
lines[ i ] = curLine.replace( /\|/g, '{{!}}' );
}
if ( withinTable && ( curLine.indexOf( '|' ) === 0 || curLine.indexOf( '!' ) === 0 ) ) {
lines[ i ] = curLine.replace( /\|/g, '{{!}}' );
}
// Table caption case (`|+`). See https://www.mediawiki.org/wiki/Help:Tables
if ( withinTable && lines[ i ].includes( '|+' ) ) {
lines[ i ] = curLine.replace( /\|\+/g, '{{!}}+' );
}
// colspan/rowspan case (`|rowspan=`/`|colspan=`). See https://www.mediawiki.org/wiki/Help:Tables
if ( withinTable && ( curLine.includes( 'colspan' ) || curLine.includes( 'rowspan' ) ) ) {
lines[ i ] = curLine.replace( /(colspan|rowspan)="(\d+?)"\s*\|/, '$1="$2" {{!}}' )
.replace( /^\s*\|/, '{{!}} ' );
}
if ( withinTable ) {
// Unescape pipes in <nowiki>, <pre> and in wiki links.
const chunks = lines[ i ].match( /<nowiki>.*?<\/nowiki>|<pre>.*?<\/pre>|\[\[.*?]]/g ) || [];
chunks.forEach( ( chunk ) => {
lines[ i ] = lines[ i ].replace( chunk, chunk.replace( /\{\{!}}/g, '|' ) );
} );
}
if ( curLine.indexOf( '|}' ) === 0 ) {
withinTable = false;
}
}
return lines.join( '\n' );
}
/**
* Mimic pending state.
*
* @param {boolean} pending
*/
setPending( pending ) {
this.$textarea.prop( 'disabled', pending );
this.$veWrapper.toggleClass( 'wishlist-intake-textarea-wrapper--loading', pending );
}
}
// This must live here outside the Vue component to prevent Vue from interfering with VE.
let descriptionField;
var script$4 = vue.defineComponent( {
name: 'DescriptionSection',
components: {
CdxField: codex.CdxField,
CdxTextInput: codex.CdxTextInput
},
props: {
title: { type: String, default: '' },
description: { type: String, default: '' },
titlestatus: { type: String, default: 'default' },
descriptionstatus: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
},
emits: [
'update:title',
'update:description',
'update:pre-submit-promise'
],
data() {
return {
titlemessage: {},
descriptionmessage: {}
};
},
methods: {
setupDescriptionField() {
if ( descriptionField ) {
return;
}
const textarea = document.querySelector( '.wishlist-intake-textarea' );
descriptionField = new DescriptionField( textarea, this.description );
descriptionField.setPending( true );
return mw.loader.using( descriptionField.dependencies ).then( () => {
descriptionField.init();
this.$emit(
'update:pre-submit-promise',
descriptionField.syncChangesToTextarea.bind( descriptionField )
);
} );
}
},
watch: {
titlestatus: {
handler( newStatus ) {
if ( newStatus === 'error' ) {
this.titlemessage = {
error: mw.msg( 'communitywishlist-title-error', 5, 100 )
};
} else {
this.titlemessage = {};
}
}
},
descriptionstatus: {
handler( newStatus ) {
if ( newStatus === 'error' ) {
this.descriptionmessage = {
error: mw.msg( 'communitywishlist-description-error', 50 )
};
} else {
this.descriptionmessage = {};
}
}
}
},
mounted() {
this.setupDescriptionField();
}
} );
const _hoisted_1$4 = { class: "wishlist-intake-description" };
const _hoisted_2$1 = { class: "wishlist-intake-textarea-wrapper wishlist-intake-textarea-wrapper--loading" };
const _hoisted_3$1 = ["value"];
function render$4(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_text_input = vue.resolveComponent("cdx-text-input");
const _component_cdx_field = vue.resolveComponent("cdx-field");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$4, [
vue.createVNode(_component_cdx_field, {
status: _ctx.titlestatus,
messages: _ctx.titlemessage,
disabled: _ctx.disabled,
class: "community-wishlist-title-field"
}, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-title' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-title-description' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_text_input, {
"model-value": _ctx.title,
onInput: _cache[0] || (_cache[0] = $event => (_ctx.$emit( 'update:title', $event.target.value.trim() )))
}, null, 8 /* PROPS */, ["model-value"])
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["status", "messages", "disabled"]),
vue.createVNode(_component_cdx_field, {
status: _ctx.descriptionstatus,
messages: _ctx.descriptionmessage,
disabled: _ctx.disabled,
class: "community-wishlist-description-field"
}, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-description' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-description-description' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createElementVNode("div", _hoisted_2$1, [
vue.createElementVNode("textarea", {
class: "wishlist-intake-textarea",
rows: 8,
value: _ctx.description,
onInput: _cache[1] || (_cache[1] = $event => (_ctx.$emit( 'update:description', $event.target.value.trim() )))
}, "\n\t\t\t\t", 40 /* PROPS, NEED_HYDRATION */, _hoisted_3$1)
])
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["status", "messages", "disabled"])
]))
}
__$styleInject("/* Overrides to make OOUI sort of mimic Codex */\n.wishlist-intake-textarea-wrapper {\n border: 1px solid #a2a9b1;\n /* TODO: Replace with official Codex loading styles once established. */\n /* See https://w.wiki/AHp3 */\n}\n.wishlist-intake-textarea-wrapper .ve-ui-surface-placeholder,\n.wishlist-intake-textarea-wrapper .ve-ui-surface .ve-ce-attachedRootNode {\n padding: 0.5em 1em;\n}\n.wishlist-intake-textarea-wrapper .ve-ce-surface .ve-ce-attachedRootNode {\n min-height: 194px;\n}\n.wishlist-intake-textarea-wrapper .wishlist-intake-ve-surface {\n transition-property: background-color, color, border-color, box-shadow;\n /* XXX: doesn't appear to be a Codex token for this */\n transition-duration: 0.25s;\n}\n.wishlist-intake-textarea-wrapper .wishlist-intake-ve-surface:has( > .ve-ce-surface-focused ) {\n border: 1px solid #36c;\n box-sizing: border-box;\n box-shadow: inset 0 0 0 1px #36c;\n}\n.wishlist-intake-textarea-wrapper--loading {\n background-color: #eaecf0;\n background-image: linear-gradient(135deg, #fff 25%, transparent 25%, transparent 50%, #fff 50%, #fff 75%, transparent 75%, transparent);\n background-size: 1.25em 1.25em;\n animation-name: cdx-animation-pending-stripes;\n animation-duration: 650ms;\n animation-timing-function: linear;\n animation-iteration-count: infinite;\n}\n@keyframes cdx-animation-pending-stripes {\n 0% {\n background-position: -1.25em 0;\n }\n 100% {\n background-position: 0 0;\n }\n}\n.cdx-field--disabled .wishlist-intake-textarea-wrapper {\n opacity: 0.5;\n pointer-events: none;\n}\n.skin-minerva .wishlist-intake-textarea {\n box-sizing: border-box;\n width: 100%;\n}\n");
script$4.render = render$4;
var script$3 = vue.defineComponent( {
name: 'AudienceSection',
components: {
CdxField: codex.CdxField,
CdxTextInput: codex.CdxTextInput
},
props: {
audience: { type: String, default: '' },
status: { type: String, default: 'default' },
disabled: { type: Boolean, default: false }
},
emits: [
'update:audience'
],
data() {
return {
messages: {}
};
},
watch: {
status: {
handler( newStatus ) {
if ( newStatus === 'error' ) {
this.messages = {
error: mw.msg( 'communitywishlist-audience-error', 5, 300 )
};
} else {
this.messages = {};
}
}
}
}
} );
const _hoisted_1$3 = { class: "wishlist-intake-audience" };
function render$3(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_text_input = vue.resolveComponent("cdx-text-input");
const _component_cdx_field = vue.resolveComponent("cdx-field");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$3, [
vue.createVNode(_component_cdx_field, {
status: _ctx.status,
messages: _ctx.messages,
disabled: _ctx.disabled
}, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-audience-label' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-audience-description' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_text_input, {
"model-value": _ctx.audience,
onInput: _cache[0] || (_cache[0] = $event => (_ctx.$emit( 'update:audience', $event.target.value.trim() )))
}, null, 8 /* PROPS */, ["model-value"])
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["status", "messages", "disabled"])
]))
}
script$3.render = render$3;
/**
* This component accepts and provides a comma-separated wikitext list of
* Phabricator links, and handles all splitting, combining, and normalization
* itself.
*/
var script$2 = vue.defineComponent( {
name: 'PhabricatorTasks',
components: {
CdxField: codex.CdxField,
CdxChipInput: codex.CdxChipInput
},
props: {
tasks: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false }
},
emits: [
'update:tasks'
],
data( props ) {
return {
// An array of ChipInputItems containing Phabricator IDs.
taskList: this.arrayToChipItems( props.tasks )
};
},
methods: {
/**
* Uppercase and sort an array of task IDs.
* @param {Array<string>} taskIds
* @return {Array<string>}
*/
normalizeTaskIds( taskIds ) {
const allTaskIds = Array.prototype.concat( ...taskIds.map( ( taskId ) => {
// One taskId might actually contain multiple,
// e.g. if the user doesn't put a space between them.
const currentTaskIds = taskId.match( /[Tt][0-9]+/g ) || [];
return currentTaskIds.map( ( t ) => t.toUpperCase() );
} ) );
// Filter to be unique, and sort.
return allTaskIds.filter( ( v, i, a ) => a.indexOf( v ) === i ).sort();
},
updateInputChips( chips ) {
const taskIds = this.normalizeTaskIds( chips.map( ( c ) => c.value ) );
this.$emit( 'update:tasks', taskIds );
},
arrayToChipItems( array ) {
return array.map( ( t ) => {
return { value: t };
} );
}
},
watch: {
tasks: {
handler( newVal ) {
this.taskList = this.arrayToChipItems( newVal );
},
deep: true
}
}
} );
const _hoisted_1$2 = { class: "wishlist-intake-tasks" };
function render$2(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_chip_input = vue.resolveComponent("cdx-chip-input");
const _component_cdx_field = vue.resolveComponent("cdx-field");
return (vue.openBlock(), vue.createElementBlock("section", _hoisted_1$2, [
vue.createVNode(_component_cdx_field, { disabled: _ctx.disabled }, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-phabricator-label' ).text()), 1 /* TEXT */)
]),
description: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-phabricator-desc' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_chip_input, {
"input-chips": _ctx.taskList,
"onUpdate:inputChips": [
_cache[0] || (_cache[0] = $event => ((_ctx.taskList) = $event)),
_ctx.updateInputChips
],
"chip-aria-description": _ctx.$i18n( 'communitywishlist-phabricator-chip-desc' ).text()
}, null, 8 /* PROPS */, ["input-chips", "chip-aria-description", "onUpdate:inputChips"])
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["disabled"])
]))
}
script$2.render = render$2;
class TemplateParserError extends Error {
}
/**
* Extension tag names from metawiki
*
* TODO: fetch this list from the wiki being acted on
*
* @type {string[]}
*/
const EXT_NAMES = [
'pre',
'nowiki',
'gallery',
'indicator',
'langconvert',
'graph',
'languages',
'timeline',
'hiero',
'charinsert',
'ref',
'references',
'inputbox',
'imagemap',
'source',
'syntaxhighlight',
'poem',
'categorytree',
'section',
'score',
'dynamicpagelist',
'rss',
'templatestyles',
'templatedata',
'math',
'ce',
'chem',
'maplink',
'mapframe',
'phonos'
];
class TemplateParser {
/**
* @callback TemplateCallback
* @param {string} name
* @param {Object<string>} args
*/
/**
* Parse some wikitext and call a function for each template.
*
* Throw a TemplateParserError if the input text does not match our
* restricted grammar.
*
* @param {string} text
* @param {TemplateCallback} callback
*/
static parse( text, callback ) {
const parser = new TemplateParser( text, callback );
parser.execute();
}
/**
* Parse some wikitext and extract the parameters of the first template
* with a name matching the specified name.
*
* If the template was not found, or if there was a parse error, return
* null.
*
* @param {string} text
* @param {string} targetTemplateName
* @return {?Object<string>}
*/
static getParams( text, targetTemplateName ) {
function normalize( name ) {
name = name.charAt( 0 ).toUpperCase() +
name.slice( 1 );
return name.replaceAll( '_', ' ' );
}
targetTemplateName = normalize( targetTemplateName );
let foundParams = null;
try {
TemplateParser.parse(
text,
function ( templateName, args ) {
if ( foundParams === null &&
normalize( templateName ) === targetTemplateName
) {
foundParams = args;
}
}
);
} catch ( e ) {
if ( !( e instanceof TemplateParserError ) ) {
throw e;
}
}
return foundParams;
}
/**
* @param {string} text
* @param {TemplateCallback} callback
*/
constructor( text, callback ) {
this.text = text;
this.callback = callback;
}
/**
* Run the parser
*/
execute() {
this.consumeWikitext( 0, [] );
}
/**
* Consume the top-level grammar rule
*
* @param {number} pos The offset at which to begin parsing
* @param {string[]} terminators Markup which, if encountered, causes
* the function to return
* @return {number} The new offset beyond the end
*/
consumeWikitext( pos, terminators ) {
while ( pos < this.text.length ) {
for ( const terminator of terminators ) {
if ( this.text.startsWith( terminator, pos ) ) {
return pos;
}
}
const char = this.text.charAt( pos );
const char2 = this.text.slice( pos, pos + 2 );
if ( char2 === '}}' ) {
break;
} else if ( char2 === '{{' ) {
pos = this.consumeTemplate( pos );
} else if ( char2 === '[[' ) {
pos = this.consumeLink( pos );
} else if ( char2 === '<!' &&
this.text.slice( pos, pos + 4 ) === '<!--'
) {
pos = this.consumeComment( pos );
} else if ( char === '<' &&
EXT_NAMES.includes( this.getExtName( pos + 1 ) )
) {
pos = this.consumeExtension( pos );
} else {
pos = this.consumeLiteral( pos );
}
}
return pos;
}
/**
* Consume a template call
*
* @param {number} pos The offset of the start markup "{{"
* @return {number} The offset beyond the end
*/
consumeTemplate( pos ) {
let nextArgIndex = 1;
pos = this.consumeMarkup( pos, '{{' );
const nameStart = pos;
pos = this.consumeWikitext( pos, [ '|', '}}' ] );
const templateName = this.text.slice( nameStart, pos ).trim();
const templateParams = {};
while ( pos < this.text.length && this.text.charAt( pos ) === '|' ) {
pos = this.consumeMarkup( pos, '|' );
const partStart = pos;
let name, value;
pos = this.consumeWikitext( pos, [ '=', '|', '}}' ] );
if ( this.text.charAt( pos ) === '=' ) {
name = this.text.slice( partStart, pos ).trim();
pos++;
const valueStart = pos;
pos = this.consumeWikitext( pos, [ '|', '}}' ] );
value = this.text.slice( valueStart, pos ).trim();
} else {
name = nextArgIndex++;
value = this.text.slice( partStart, pos );
}
templateParams[ name ] = value;
}
this.callback( templateName, templateParams );
pos = this.consumeMarkup( pos, '}}' );
return pos;
}
/**
* Consume a link
*
* @param {number} pos The offset of the start markup "[["
* @return {number} The offset beyond the end
*/
consumeLink( pos ) {
pos = this.consumeMarkup( pos, '[[' );
pos = this.consumeWikitext( pos, [ ']]' ] );
pos = this.consumeMarkup( pos, ']]' );
return pos;
}
/**
* Consume an HTML comment
*
* @param {number} pos The offset of the start markup "<!--"
* @return {number} The offset beyond the end
*/
consumeComment( pos ) {
pos = this.consumeMarkup( pos, '<!--' );
const endPos = this.text.indexOf( '-->', pos );
if ( endPos === -1 ) {
this.error( pos, 'missing comment terminator' );
}
pos = endPos + '-->'.length;
return pos;
}
/**
* Consume an xmlish extension element
*
* @param {number} pos The offset of the start markup
* @return {number} The offset beyond the end
*/
consumeExtension( pos ) {
pos = this.consumeMarkup( pos, '<' );
const name = this.getExtName( pos );
pos += name.length;
const tagEndPos = this.text.indexOf( '>', pos );
if ( tagEndPos === -1 ) {
this.error( pos, 'missing end of extension tag' );
}
pos = tagEndPos + 1;
if ( this.text.charAt( tagEndPos - 1 ) === '/' ) {
// Self-closing
return pos;
} else {
const endTag = `</${ name }>`;
const endPos = this.text.indexOf( endTag, pos );
if ( endPos === -1 ) {
this.error( pos, 'missing extension end tag' );
}
return endPos + endTag.length;
}
}
/**
* Consume at least one literal character
*
* @param {number} pos
* @return {number} The offset beyond the end of the run of literal characters
*/
consumeLiteral( pos ) {
const literal = this.text.slice( pos ).match( /^[^[{|=}\]<]*/ )[ 0 ];
if ( literal.length ) {
// Literal composed of uninteresting characters
return pos + literal.length;
}
if ( pos < this.text.length ) {
// Literal composed of one interesting character not consumed elsewhere
return pos + 1;
}
return pos;
}
/**
* Assert that the specified characters exist at the specified location
*
* @param {number} pos
* @param {string} markup
* @return {number} The position beyond the end of the markup
*/
consumeMarkup( pos, markup ) {
if ( this.text.slice( pos, pos + markup.length ) !== markup ) {
this.error( pos, `expected "${ markup }"` );
}
return pos + markup.length;
}
/**
* Look ahead to find the name of a prospective xmlish extension tag
*
* @param {number} pos
* @return {string}
*/
getExtName( pos ) {
return this.text.slice( pos ).match( /^[a-zA-Z0-9_-]*/ )[ 0 ];
}
/**
* Raise a parse error
*
* @param {number} pos
* @param {string} message
*/
error( pos, message ) {
throw new TemplateParserError( `Syntax error parsing template at offset ${ pos }: ${ message }` );
}
}
class WishlistTemplate {
constructor( templateName ) {
this.templateName = templateName.replace( /^Template:/, '' );
}
/**
* @param {Object} wish
* @return {string}
*/
getWikitext( wish ) {
const paramNames = [
'status',
'type',
'title',
'description',
'audience',
'tasks',
'proposer',
'created',
'projects',
'otherproject',
'area',
'baselang'
];
let out = '{{' + this.templateName + '\n';
for ( const key of paramNames ) {
const value = wish[ key ];
if ( value === '' ) {
out += `| ${ key } =\n`;
} else if ( value !== undefined ) {
out += `| ${ key } = ${ value }\n`;
}
}
out += '}}';
return out;
}
/**
* Get the Wish object from the given wikitext, or null if the wish template
* was not found on the page. Any <translate> tags in the wikitext will be
* removed.
*
* @param {string} wikitext
* @param {string} pageTitle
* @param {?number} pageId
* @param {string} updated
* @return {?Wish}
*/
getWish( wikitext, pageTitle = '', pageId = null, updated = '' ) {
const data = TemplateParser.getParams( wikitext, this.templateName );
if ( data === null ) {
return null;
}
data.page = pageTitle;
if ( pageId !== null ) {
data.pageId = pageId;
}
if ( pageTitle.startsWith( config.wishPagePrefix ) ) {
const relPage = pageTitle.slice( config.wishPagePrefix.length );
const m = relPage.match( /(.*)\/([a-z0-9-]{2,})$/ );
if ( m ) {
data.name = m[ 1 ];
data.lang = m[ 2 ];
} else {
data.name = relPage;
data.lang = '';
}
} else {
data.name = pageTitle;
}
data.updated = updated;
return new Wish( data );
}
/**
* Strip <translate> tags from a string
*
* @param {string} text
* @return {string}
*/
stripTranslate( text ) {
text = text.replace( /<translate( nowrap)?>\n?/g, '' );
text = text.replace( /\n?<\/translate>/g, '' );
text = text.replace( /<tvar\s+name\s*=\s*(('[^']*')|("[^"]*")|([^"'\s>]*))\s*>.*?<\/tvar\s*>/g, '' );
text = text.replace( /(^=.*=) <!--T:[^_/\n<>]+-->$/mg, '$1' );
text = text.replace( /<!--T:[^_/\n<>]+-->[\n ]?/g, '' );
return text;
}
}
/**
* Utility functions for the gadget and bot.
*/
class Util {
/**
* Get the wish template object.
*
* @return {WishlistTemplate}
*/
static getWishTemplate() {
return new WishlistTemplate( config.wishTemplate );
}
}
const api$1 = new mw.Api();
// Functions returning Promises that must be resolved before the form can be validated/submitted.
// This is outside the Vue component as it does not need to be reactive.
const preSubmitFns = [];
var script$1 = vue.defineComponent( {
name: 'WishlistIntake',
components: {
CdxMessage: codex.CdxMessage,
StatusSection: script$7,
WishTypeSection: script$6,
ProjectSection: script$5,
DescriptionSection: script$4,
AudienceSection: script$3,
PhabricatorTasks: script$2,
CdxButton: codex.CdxButton
},
props: {
audience: { type: String, default: '' },
baselang: { type: String, default: mw.config.get( 'wgUserLanguage' ) },
created: { type: String, default: '~~~~~' },
description: { type: String, default: '' },
otherproject: { type: String, default: '' },
projects: { type: Array, default: () => [] },
proposer: { type: String, default: '~~~' },
status: { type: String, default: Wish.STATUS_SUBMITTED },
tasks: { type: Array, default: () => [] },
title: { type: String, default: '' },
type: { type: String, default: null },
area: { type: String, default: '' },
// These are from the initial fetch of wish content,
// and are later used for edit conflict detection on form submission.
basetimestamp: { type: String, default: '' },
curtimestamp: { type: String, default: '' }
},
setup( props ) {
// Add a class to the body so the peer gadget styles become hidden.
document.body.classList.add( 'wishlist-intake-ready' );
// Customize the subtitle.
document.querySelector( '#mw-content-subtitle' ).textContent = mw.msg( 'communitywishlist-form-subtitle' );
// Reactive state for the form fields.
// This should map directly to properties of the Wish class.
const wish = vue.reactive( {
audience: props.audience,
baselang: props.baselang,
created: props.created,
description: props.description,
otherproject: props.otherproject,
projects: props.projects,
proposer: props.proposer,
status: props.status,
tasks: props.tasks,
title: props.title,
type: props.type,
area: props.area
} );
return { wish };
},
data() {
return {
exists: false,
pagetitle: '',
typeStatus: 'default',
projectStatus: 'default',
projectStatusType: 'default',
titleStatus: 'default',
descriptionStatus: 'default',
audienceStatus: 'default',
/**
* Whether the form has been changed since it was loaded.
* @type {boolean}
*/
formChanged: false,
/**
* Disabled state of the form fields and submit button.
* @type {boolean}
*/
formDisabled: false,
/**
* Error state of the form. Either false (no error), true (generic error),
* or a string with a specific error message.
* @type {boolean|string}
*/
formError: false,
/**
* Whether the form has been submitted.
* @type {boolean}
*/
formSubmitted: false,
/**
* @type {ConfirmCloseWindow}
*/
allowCloseWindow: mw.confirmCloseWindow(),
/**
* URL to return to after the form is submitted.
* @type {string}
*/
returnto: mw.util.getUrl( config.wishHomePage )
};
},
computed: {
isStaff: WebUtil.isStaff,
formErrorMsg: () => mw.message( 'communityrequests-form-error', config.wishHomePage ).parse()
},
methods: {
updateField( field, value ) {
if ( this.wish[ field ] !== value ) {
this.wish[ field ] = value;
this.formChanged = true;
}
},
updateTitle( title ) {
this.updateField( 'title', title );
if ( title && !this.exists ) {
// FIXME: this code may not be needed anymore?
// If this is a new proposal, keep the new page title in sync with the title field.
// Existing proposals can't change their page title via the form (but can change the
// title field).
title = mw.Title.newFromUserInput( this.wish.title.replaceAll( '/', '_' ) );
this.pagetitle = WebUtil.getWishPageTitleFromSlug( title.getMainText() );
}
// VisualEditor and other scripts rely on this being the correct post-save page name.
// If the wish title is blank, we use the current page name as a fallback.
mw.config.set(
'wgRelevantPageName',
!this.wish.title ? WebUtil.getPageName() : this.pagetitle
);
},
/**
* Validate the form fields.
*
* @return {boolean} true if the form is valid, false otherwise.
*/
validateForm() {
this.formError = false;
// Remove translate tags before checking title length.
const title = this.wish.title
.replaceAll( /<\/?translate>/g, '' )
.replaceAll( /<!--T:[0-9]+-->/g, '' );
this.titleStatus = ( title.length < 5 || title.length > 100 ) ? 'error' : 'default';
this.descriptionStatus = ( this.wish.description.length < 50 ) ? 'error' : 'default';
this.typeStatus = this.wish.type === null ? 'error' : 'default';
// No project selected, other project is empty
if ( this.wish.projects.length === 0 && !this.wish.otherproject ) {
this.projectStatus = 'error';
this.projectStatusType = 'noSelection';
// Other project has content > 3, but no other project is entered
} else if ( this.wish.otherproject.length < 3 && this.wish.projects.length < 1 ) {
this.projectStatus = 'error';
this.projectStatusType = 'invalidOther';
} else {
this.projectStatus = 'default';
this.projectStatusType = 'default';
}
this.audienceStatus = ( this.wish.audience.length < 5 || this.wish.audience.length > 300 ) ? 'error' : 'default';
return this.typeStatus !== 'error' &&
this.titleStatus !== 'error' &&
this.descriptionStatus !== 'error' &&
this.audienceStatus !== 'error' &&
this.projectStatus !== 'error';
},
/**
* Add a function to be called before the form is submitted.
* The function is expected to return a Promise, which is
* guaranteed to be resolved before the form is submitted.
*
* @param {Function<Promise|jQuery.Promise>} fn
*/
addPreSubmitFn( fn ) {
preSubmitFns.push( fn );
},
/**
* Get a unique page title by appending incremental numbers to the end of the title.
*
* @param {string} pageTitle
* @param {number} [pageCounter]
* @return {jQuery.Promise<string>|Promise<string>}
*/
getUniquePageTitle( pageTitle, pageCounter = 1 ) {
// A wish being edited is always going to already have a unique title.
if ( WebUtil.isWishEdit() ) {
return Promise.resolve( pageTitle );
}
// Otherwise, see if it exists and start appending numbers.
const newTitle = pageTitle + ( pageCounter > 1 ? ' ' + pageCounter : '' );
return this.pageExists( newTitle ).then( ( exists ) => {
if ( exists ) {
return this.getUniquePageTitle( pageTitle, pageCounter + 1 );
} else {
return Promise.resolve( newTitle );
}
} );
},
/**
* Get a promise for saving the wish page.
*
* @param {string} wikitext
* @return {jQuery.Promise}
*/
getEditPromise( wikitext ) {
const params = api$1.assertCurrentUser( {
action: 'edit',
title: this.pagetitle,
text: wikitext,
formatversion: 2,
// Tag the edit to track usage of the form.
tags: [ 'community-wishlist' ],
// Protect against conflicts
basetimestamp: this.basetimestamp,
starttimestamp: this.curtimestamp,
// Localize errors
uselang: mw.config.get( 'wgUserLanguage' ),
errorformat: 'html',
errorlang: mw.config.get( 'wgUserLanguage' ),
errorsuselocal: true
} );
if ( WebUtil.isNewWish() ) {
params.createonly = true;
} else {
params.nocreate = true;
}
return api$1.postWithEditToken( params );
},
/**
* Handle form submission.
*/
handleSubmit() {
this.formDisabled = true;
Promise.all( preSubmitFns.map( ( p ) => p() ) ).then( () => {
// @todo Handle this nicer?
if ( !this.validateForm() ) {
this.formDisabled = false;
return;
}
// Save the wish page, first checking for duplicate titles.
this.getUniquePageTitle( this.pagetitle ).then( ( uniqueTitle ) => {
this.pagetitle = uniqueTitle;
const wikitext = Util.getWishTemplate().getWikitext( new Wish( this.wish ) );
this.getEditPromise( wikitext ).then( ( saveResult ) => {
if ( saveResult.edit && !saveResult.edit.nochange ) {
// Replicate what is done in postEdit's fireHookOnPageReload() function,
// but for a different page.
mw.storage.session.set(
// Same storage key structure as in MediaWiki's
// resources/src/mediawiki.action/mediawiki.action.view.postEdit.js
'mw-PostEdit' + this.pagetitle.replaceAll( ' ', '_' ),
WebUtil.isWishEdit() ? 'saved' : 'created',
// Same duration as EditPage::POST_EDIT_COOKIE_DURATION.
1200
);
}
this.formSubmitted = true; // @todo Unused variable?
// Allow the window to close/navigate after submission was successful.
this.allowCloseWindow.release();
window.location.replace( mw.util.getUrl( this.pagetitle ) );
} ).fail( this.handleError.bind( this ) );
} );
} ).catch( this.handleError.bind( this ) );
},
/**
* Handle an error from the API.
*
* @param {Error} errObj
* @param {Object} error Response from the API
* @param {string} error.info
*/
handleError( errObj, error ) {
WebUtil.logError( 'edit failed', errObj );
this.formError = api$1.getErrorMessage( error ).html();
this.formDisabled = false;
},
/**
* Check if a page exists.
*
* @param {string} title
* @return {Promise<boolean>}
*/
pageExists( title ) {
return api$1.get( {
action: 'query',
format: 'json',
titles: title
} ).then( ( res ) => Promise.resolve( res.query.pages[ -1 ] === undefined ) );
}
},
beforeMount() {
this.pagetitle = WebUtil.getWishPageTitleFromSlug( WebUtil.getWishSlug() );
if ( WebUtil.isNewWish() ) {
this.exists = false;
} else {
this.returnto = mw.util.getUrl( this.pagetitle );
this.exists = true;
}
}
} );
const _hoisted_1$1 = { class: "wishlist-intake-form-footer" };
const _hoisted_2 = /*#__PURE__*/vue.createElementVNode("hr", null, null, -1 /* HOISTED */);
const _hoisted_3 = ["innerHTML"];
const _hoisted_4 = { key: 0 };
const _hoisted_5 = { key: 1 };
const _hoisted_6 = ["href"];
const _hoisted_7 = ["innerHTML"];
const _hoisted_8 = ["innerHTML"];
function render$1(_ctx, _cache, $props, $setup, $data, $options) {
const _component_status_section = vue.resolveComponent("status-section");
const _component_description_section = vue.resolveComponent("description-section");
const _component_wish_type_section = vue.resolveComponent("wish-type-section");
const _component_project_section = vue.resolveComponent("project-section");
const _component_audience_section = vue.resolveComponent("audience-section");
const _component_phabricator_tasks = vue.resolveComponent("phabricator-tasks");
const _component_cdx_button = vue.resolveComponent("cdx-button");
const _component_cdx_message = vue.resolveComponent("cdx-message");
return (vue.openBlock(), vue.createElementBlock("form", {
onSubmit: _cache[15] || (_cache[15] = vue.withModifiers((...args) => (_ctx.handleSubmit && _ctx.handleSubmit(...args)), ["prevent"]))
}, [
(_ctx.isStaff)
? (vue.openBlock(), vue.createBlock(_component_status_section, {
key: 0,
status: _ctx.wish.status,
"onUpdate:status": [
_cache[0] || (_cache[0] = $event => ((_ctx.wish.status) = $event)),
_cache[1] || (_cache[1] = $event => (_ctx.updateField( 'status', $event )))
],
disabled: _ctx.formDisabled
}, null, 8 /* PROPS */, ["status", "disabled"]))
: vue.createCommentVNode("v-if", true),
vue.createVNode(_component_description_section, {
title: _ctx.wish.title,
"onUpdate:title": [
_cache[2] || (_cache[2] = $event => ((_ctx.wish.title) = $event)),
_ctx.updateTitle
],
description: _ctx.wish.description,
"onUpdate:description": [
_cache[3] || (_cache[3] = $event => ((_ctx.wish.description) = $event)),
_cache[4] || (_cache[4] = $event => (_ctx.updateField( 'description', $event )))
],
titlestatus: _ctx.titleStatus,
descriptionstatus: _ctx.descriptionStatus,
disabled: _ctx.formDisabled,
"onUpdate:preSubmitPromise": _ctx.addPreSubmitFn
}, null, 8 /* PROPS */, ["title", "description", "titlestatus", "descriptionstatus", "disabled", "onUpdate:title", "onUpdate:preSubmitPromise"]),
vue.createVNode(_component_wish_type_section, {
type: _ctx.wish.type,
"onUpdate:type": [
_cache[5] || (_cache[5] = $event => ((_ctx.wish.type) = $event)),
_cache[6] || (_cache[6] = $event => (_ctx.updateField( 'type', $event )))
],
status: _ctx.typeStatus,
disabled: _ctx.formDisabled
}, null, 8 /* PROPS */, ["type", "status", "disabled"]),
vue.createVNode(_component_project_section, {
projects: _ctx.wish.projects,
"onUpdate:projects": [
_cache[7] || (_cache[7] = $event => ((_ctx.wish.projects) = $event)),
_cache[9] || (_cache[9] = $event => (_ctx.updateField( 'projects', $event )))
],
otherproject: _ctx.wish.otherproject,
"onUpdate:otherproject": [
_cache[8] || (_cache[8] = $event => ((_ctx.wish.otherproject) = $event)),
_cache[10] || (_cache[10] = $event => (_ctx.updateField( 'otherproject', $event )))
],
disabled: _ctx.formDisabled,
status: _ctx.projectStatus,
statustype: _ctx.projectStatusType
}, null, 8 /* PROPS */, ["projects", "otherproject", "disabled", "status", "statustype"]),
vue.createVNode(_component_audience_section, {
audience: _ctx.wish.audience,
"onUpdate:audience": [
_cache[11] || (_cache[11] = $event => ((_ctx.wish.audience) = $event)),
_cache[12] || (_cache[12] = $event => (_ctx.updateField( 'audience', $event )))
],
status: _ctx.audienceStatus,
disabled: _ctx.formDisabled
}, null, 8 /* PROPS */, ["audience", "status", "disabled"]),
vue.createVNode(_component_phabricator_tasks, {
tasks: _ctx.wish.tasks,
"onUpdate:tasks": [
_cache[13] || (_cache[13] = $event => ((_ctx.wish.tasks) = $event)),
_cache[14] || (_cache[14] = $event => (_ctx.updateField( 'tasks', $event )))
],
disabled: _ctx.formDisabled
}, null, 8 /* PROPS */, ["tasks", "disabled"]),
vue.createElementVNode("section", _hoisted_1$1, [
_hoisted_2,
vue.createCommentVNode(" eslint-disable-next-line vue/no-v-html "),
vue.createElementVNode("p", {
innerHTML: _ctx.$i18n( 'wikimedia-copyrightwarning' ).parse()
}, null, 8 /* PROPS */, _hoisted_3),
vue.createVNode(_component_cdx_button, {
weight: "primary",
action: "progressive",
disabled: _ctx.formDisabled,
class: "wishlist-intake-submit",
onClick: _ctx.handleSubmit
}, {
default: vue.withCtx(() => [
(_ctx.exists)
? (vue.openBlock(), vue.createElementBlock("span", _hoisted_4, vue.toDisplayString(_ctx.$i18n( 'communitywishlist-save' ).text()), 1 /* TEXT */))
: (vue.openBlock(), vue.createElementBlock("span", _hoisted_5, vue.toDisplayString(_ctx.$i18n( 'communitywishlist-publish' ).text()), 1 /* TEXT */))
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["disabled", "onClick"]),
vue.createElementVNode("a", {
href: _ctx.returnto,
class: "cdx-button cdx-button--fake-button--enabled cdx-button--weight-quiet wishlist-intake-cancel"
}, vue.toDisplayString(_ctx.$i18n( 'cancel' ).text()), 9 /* TEXT, PROPS */, _hoisted_6),
(_ctx.formError)
? (vue.openBlock(), vue.createBlock(_component_cdx_message, {
key: 0,
type: "error",
class: "wishlist-intake-form-error"
}, {
default: vue.withCtx(() => [
vue.createCommentVNode(" eslint-disable-next-line vue/no-v-html "),
vue.createElementVNode("p", null, [
vue.createElementVNode("strong", { innerHTML: _ctx.formErrorMsg }, null, 8 /* PROPS */, _hoisted_7)
]),
vue.createCommentVNode(" eslint-disable-next-line vue/no-v-html "),
vue.createElementVNode("div", { innerHTML: _ctx.formError }, null, 8 /* PROPS */, _hoisted_8)
]),
_: 1 /* STABLE */
}))
: vue.createCommentVNode("v-if", true)
])
], 32 /* NEED_HYDRATION */))
}
__$styleInject(".wishlist-intake-container .cdx-field,\n.wishlist-intake-container section:last-child,\n.wishlist-intake-container .wishlist-intake-form-error {\n margin-top: 32px;\n}\n.wishlist-intake-form-footer .cdx-checkbox {\n margin: 16px auto;\n}\n.wishlist-intake-cancel {\n margin-left: 12px;\n}\n[dir='rtl'] .wishlist-intake-cancel {\n margin-left: 0;\n margin-right: 12px;\n}\n");
script$1.render = render$1;
/**
* This class loads and saves the focus area voting subpages.
*/
class FocusAreaPage {
constructor() {
this.api = new mw.Api();
this.votesPageName = this.getBasePageName() + '/Votes';
this.voteCountPageName = this.getBasePageName() + config.focusAreaVoteCountSuffix;
}
/**
* Get the name of the Translate 'source' page for this page.
*
* @return {string}
*/
getBasePageName() {
let pageName = mw.config.get( 'wgPageName' );
if ( mw.config.get( 'wgTranslatePageTranslation' ) === 'translation' ) {
pageName = pageName.slice( 0, pageName.length - mw.config.get( 'wgPageContentLanguage' ).length - 1 );
}
return pageName;
}
/**
* Load the current page's votes data.
*
* @return {Promise<string>}
*/
loadVotes() {
return this.api.get( {
action: 'query',
titles: this.votesPageName,
prop: 'revisions',
rvprop: [ 'content', 'timestamp' ],
rvslots: 'main',
curtimestamp: true,
assert: 'user',
format: 'json',
formatversion: 2
} ).then( ( response ) => {
const page = response.query && response.query.pages ? response.query.pages[ 0 ] : {};
if ( page.missing ) {
return '';
}
this.starttimestamp = response.curtimestamp;
this.basetimestamp = page.revisions[ 0 ].timestamp;
return page.revisions[ 0 ].slots.main.content;
} );
}
/**
* Add a vote template to the votes list, and save the full wikitext
* for both the Votes page and the Vote_count page.
*
* @param {string} votes The votes wikitext.
* @param {string} comment The current user's support comment.
* @return {Promise}
*/
addVote( votes, comment ) {
if ( this.alreadyVoted( votes ) ) {
// @todo If already voted, change the timestamp and comment of the existing vote.
return Promise.resolve();
}
// @todo Construct support template wikitext somewhere else.
const newVote = '{{' + config.supportTemplate +
' |username=' + mw.config.get( 'wgUserName' ) +
' |timestamp=' + ( new Date() ).toISOString() +
' |comment=' + comment.replace( '|', '{{!}}' ) +
' }}';
votes = votes.trim() + '\n' + newVote;
// Count the votes by counting the Support template occurences.
const escapedTemplateName = mw.util.escapeRegExp( config.supportTemplate );
const voteCount = votes.match( new RegExp( '\\{\\{' + escapedTemplateName, 'g' ) ).length;
// Save the votes page.
return this.api.postWithEditToken( this.api.assertCurrentUser( {
action: 'edit',
title: this.votesPageName,
text: votes,
formatversion: 2,
// Tag as having been edited by the gadget.
tags: [ 'community-wishlist' ],
// Protect against conflicts
basetimestamp: this.basetimestamp,
starttimestamp: this.curtimestamp,
// Localize errors
uselang: mw.config.get( 'wgUserLanguage' ),
errorformat: 'html',
errorlang: mw.config.get( 'wgUserLanguage' ),
errorsuselocal: true
} ) ).then( () => {
// Update vote count after saving the vote.
return this.api.postWithEditToken( this.api.assertCurrentUser( {
action: 'edit',
title: this.voteCountPageName,
text: voteCount,
formatversion: 2,
starttimestamp: this.curtimestamp,
// Tag as having been edited by the gadget.
tags: [ 'community-wishlist' ],
// Localize errors
uselang: mw.config.get( 'wgUserLanguage' ),
errorformat: 'html',
errorlang: mw.config.get( 'wgUserLanguage' ),
errorsuselocal: true
} ) );
} );
}
/**
* Check if the current user has already voted.
*
* @param {string} votesWikitext
* @return {boolean}
*/
alreadyVoted( votesWikitext ) {
const escapedUsername = mw.util.escapeRegExp( mw.config.get( 'wgUserName' ) );
const regex = new RegExp( 'username\\s*=\\s*' + escapedUsername );
return votesWikitext.match( regex ) !== null;
}
}
const focusAreaPage = new FocusAreaPage();
var script = vue.defineComponent( {
name: 'FocusArea',
components: {
CdxButton: codex.CdxButton,
CdxDialog: codex.CdxDialog,
CdxField: codex.CdxField,
CdxTextArea: codex.CdxTextArea,
CdxMessage: codex.CdxMessage
},
data() {
return {
open: false,
submitting: false,
comment: '',
hasVoted: false,
showVotedMessage: false
};
},
computed: {
dialogTitle() {
return mw.msg(
'communitywishlist-support-focus-area-dialog-title',
WebUtil.getFocusAreaSlug()
);
},
primaryAction() {
return {
label: mw.msg( 'communitywishlist-support' ),
actionType: 'progressive',
disabled: this.submitting
};
},
defaultAction() {
return {
label: mw.msg( 'cancel' ),
disabled: this.submitting
};
}
},
methods: {
updateComment( event ) {
this.comment = event.target.value;
},
onPrimaryAction() {
this.submitting = true;
focusAreaPage.loadVotes().then( ( votes ) => {
focusAreaPage.addVote( votes, this.comment ).then( ( editResult ) => {
this.open = false;
if ( !editResult ) {
return;
}
if ( editResult.edit.result === 'Success' ) {
// Purge and reload the page.
const postArgs = { action: 'purge', titles: mw.config.get( 'wgPageName' ) };
( new mw.Api() ).post( postArgs ).then( ( purgeRes ) => {
mw.storage.session.set( config.voteRecordedStorageName, 1 );
location.href = mw.util.getUrl( purgeRes.purge[ 0 ].title ) + '#voting';
// Also reload, in case they were already at #voting.
location.reload();
} );
}
} );
} );
},
removeVote() {
// @todo Implement.
}
},
beforeMount() {
focusAreaPage.loadVotes().then( ( votes ) => {
if ( votes && focusAreaPage.alreadyVoted( votes ) ) {
this.hasVoted = true;
// Also check for the session storage param for the post-voting message.
this.showVotedMessage = mw.storage.session.get( config.voteRecordedStorageName ) !== null;
mw.storage.session.remove( config.voteRecordedStorageName );
}
} );
}
} );
const _withScopeId = n => (vue.pushScopeId("data-v-67acc686"),n=n(),vue.popScopeId(),n);
const _hoisted_1 = /*#__PURE__*/ _withScopeId(() => /*#__PURE__*/vue.createElementVNode("span", { class: "cdx-button__icon cdx-demo-css-icon--check" }, null, -1 /* HOISTED */));
function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_cdx_button = vue.resolveComponent("cdx-button");
const _component_cdx_message = vue.resolveComponent("cdx-message");
const _component_cdx_text_area = vue.resolveComponent("cdx-text-area");
const _component_cdx_field = vue.resolveComponent("cdx-field");
const _component_cdx_dialog = vue.resolveComponent("cdx-dialog");
return (vue.openBlock(), vue.createElementBlock(vue.Fragment, null, [
(!_ctx.hasVoted)
? (vue.openBlock(), vue.createBlock(_component_cdx_button, {
key: 0,
action: "progressive",
weight: "primary",
onClick: _cache[0] || (_cache[0] = $event => (_ctx.open = true))
}, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-support-focus-area' ).text()), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}))
: (vue.openBlock(), vue.createBlock(_component_cdx_button, {
key: 1,
onClick: _ctx.removeVote
}, {
default: vue.withCtx(() => [
_hoisted_1,
vue.createTextVNode(" " + vue.toDisplayString(_ctx.$i18n( 'communitywishlist-supported' ).text()), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["onClick"])),
(_ctx.showVotedMessage)
? (vue.openBlock(), vue.createBlock(_component_cdx_message, {
key: 2,
type: "success",
"allow-user-dismiss": ""
}, {
default: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-support-focus-area-confirmed' ).text()), 1 /* TEXT */)
]),
_: 1 /* STABLE */
}))
: vue.createCommentVNode("v-if", true),
vue.createVNode(_component_cdx_dialog, {
open: _ctx.open,
"onUpdate:open": _cache[1] || (_cache[1] = $event => ((_ctx.open) = $event)),
title: _ctx.dialogTitle,
"use-close-button": true,
"primary-action": _ctx.primaryAction,
"default-action": _ctx.defaultAction,
onPrimary: _ctx.onPrimaryAction,
onDefault: _cache[2] || (_cache[2] = $event => (_ctx.open = false))
}, {
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_field, null, {
label: vue.withCtx(() => [
vue.createTextVNode(vue.toDisplayString(_ctx.$i18n( 'communitywishlist-optional-comment' ).text()), 1 /* TEXT */)
]),
default: vue.withCtx(() => [
vue.createVNode(_component_cdx_text_area, {
disabled: _ctx.submitting,
onInput: _ctx.updateComment
}, null, 8 /* PROPS */, ["disabled", "onInput"])
]),
_: 1 /* STABLE */
})
]),
_: 1 /* STABLE */
}, 8 /* PROPS */, ["open", "title", "primary-action", "default-action", "onPrimary"])
], 64 /* STABLE_FRAGMENT */))
}
__$styleInject(".cdx-button[data-v-67acc686],\n.cdx-message[data-v-67acc686] {\n margin-bottom: 16px;\n}\n/* Hack in check icon. */\n/* FIXME: Use actual Codex component when we move to the extension. */\n.cdx-demo-css-icon--check[data-v-67acc686] {\n min-width: 20px;\n min-height: 20px;\n width: 1.25em;\n height: 1.25em;\n display: inline-block;\n /* stylelint-disable-next-line declaration-no-important */\n background-color: transparent !important;\n background-image: url(https://upload.wikimedia.org/wikipedia/commons/f/f4/Codex_icon_check.svg );\n}\n");
script.render = render;
script.__scopeId = "data-v-67acc686";
const api = new mw.Api();
/**
* If the page already exists, pre-fetch the content
* so that it's available when the form is loaded.
*
* @return {Promise|jQuery.Promise}
*/
function loadWishData() {
if ( WebUtil.isNewWish() ) {
return Promise.resolve();
}
return api.get( {
action: 'query',
format: 'json',
prop: 'revisions',
titles: mw.config.get( 'wgPageName' ),
rvprop: [ 'content', 'timestamp' ],
rvslots: 'main',
formatversion: 2,
assert: 'user',
curtimestamp: true
} ).then( ( res ) => {
const page = res.query && res.query.pages ? res.query.pages[ 0 ] : {};
if ( page.missing ) {
// TODO: show button to create wish and pre-fill the title with the subpage name.
return {};
}
const revision = page.revisions[ 0 ];
const template = Util.getWishTemplate();
const wikitext = revision.slots.main.content;
const wish = template.getWish( wikitext, mw.config.get( 'wgPageName' ) );
// Confirm that we can parse the wikitext.
if ( !wish ) {
return null;
}
const wishData = {
status: wish.status,
type: wish.type,
title: wish.title,
description: wish.description,
audience: wish.audience,
tasks: Wish.getArrayFromValue( wish.tasks ),
proposer: wish.proposer,
created: wish.created,
projects: Wish.getArrayFromValue( wish.projects ),
otherproject: wish.otherproject,
area: wish.area,
baselang: wish.baselang
};
// Confirm that we can parse and then re-create the same wikitext.
if ( template.getWikitext( wishData ) !== wikitext ) {
WebUtil.logError( 'Parsing failed for ', mw.config.get( 'wgPageName' ) );
}
return Object.assign( {
// For edit conflict detection.
basetimestamp: revision.timestamp,
curtimestamp: res.curtimestamp
}, wishData );
} );
}
/**
* Show a banner after a wish has been saved.
*/
function showPostEditBanner() {
// Close image.
const closeImg = document.createElement( 'img' );
closeImg.src = 'https://upload.wikimedia.org/wikipedia/commons/8/82/Codex_icon_close.svg';
closeImg.alt = mw.msg( 'communitywishlist-close' );
// Close button.
const closeButton = document.createElement( 'button' );
closeButton.className = 'cdx-button cdx-button--action-default cdx-button--weight-quiet cdx-button--size-medium cdx-button--icon-only cdx-message__dismiss-button';
closeButton.ariaLabel = mw.msg( 'communitywishlist-close' );
// Message icon.
const messageIcon = document.createElement( 'span' );
messageIcon.className = 'cdx-message__icon';
// View wishes link.
const viewWishesLink = document.createElement( 'a' );
viewWishesLink.href = mw.util.getUrl( 'Special:MyLanguage/Community Wishlist/Wishes' );
viewWishesLink.textContent = mw.msg( 'communitywishlist-view-all-wishes' );
// Message content.
const messageContent = document.createElement( 'div' );
messageContent.className = 'cdx-message__content';
const messageContentMsg = mw.config.get( 'wgPostEdit' ) === 'created' ? 'communitywishlist-create-success' : 'communitywishlist-edit-success';
// Messages that can be used here:
// * communitywishlist-create-success
// * communitywishlist-edit-success
messageContent.textContent = mw.msg( messageContentMsg ) + ' ';
// Message container.
const messageContainer = document.createElement( 'div' );
messageContainer.className = 'cdx-message cdx-message--block cdx-message--success';
messageContainer.ariaLive = 'polite';
// Append elements.
closeButton.appendChild( closeImg );
messageContent.appendChild( viewWishesLink );
messageContainer.appendChild( messageIcon );
messageContainer.appendChild( messageContent );
messageContainer.appendChild( closeButton );
document.querySelector( '.mw-body-content' ).prepend( messageContainer );
// Close the banner when the close button is clicked.
closeButton.addEventListener( 'click', () => messageContainer.remove() );
}
/**
* Show an error message when a wish fails to load.
*
* @param {HTMLElement} mwContentText
*/
function handleWishLoadError( mwContentText ) {
const errorMsg = mw.message( 'communityrequests-wish-loading-error',
window.location.href,
`Special:EditPage/${ mw.config.get( 'wgPageName' ) }`,
'Talk:Community Wishlist'
);
mwContentText.prepend(
WebUtil.getMessageBox( errorMsg, 'error' )
);
}
/**
* Entry point for the gadget.
*/
$( () => {
// In case Category:Community_Wishlist/Intake is on an unexpected page.
if ( !( WebUtil.isWishRelatedPage() || WebUtil.isFocusAreaPage() ) ) {
return;
}
// Don't load the form on protected pages (T369352).
// We don't have config.importedMessages yet, so instead of showing an error
// we'll redirect to action=edit where they can review the source.
if ( !mw.config.get( 'wgIsProbablyEditable' ) && WebUtil.isWishEdit() ) {
window.location.replace( mw.util.getUrl( WebUtil.getPageName(), { action: 'edit' } ) );
return;
}
const mwContentText = document.querySelector( '#mw-content-text' );
// Load all required i18n messages.
const promises = [
api.loadMessages( config.importedMessages ),
WebUtil.setOnWikiMessages( api )
];
if ( WebUtil.isWishEdit() ) {
// Pre-fetch the wish data.
promises.push( loadWishData() );
}
// Load all required i18n messages, and then the rest of the gadget.
Promise.all( promises ).then( ( res ) => {
const Vue = require( 'vue' );
// Load the focus area app if we're on a focus area page.
if ( WebUtil.isFocusAreaPage() ) {
const focusAreaApp = document.createElement( 'div' );
document.querySelector( '.community-wishlist-voting-btn' )
.closest( 'p' )
.replaceWith( focusAreaApp );
Vue.createMwApp( script ).mount( focusAreaApp );
return;
}
mw.hook( 'postEdit' ).add( showPostEditBanner );
if ( !WebUtil.shouldShowForm() ) {
return;
}
if ( res[ 0 ] === null ) {
handleWishLoadError( mwContentText );
return;
}
let wishData = {};
if ( res[ 2 ] && res[ 2 ].title ) {
wishData = res[ 2 ];
}
const root = document.createElement( 'div' );
root.className = 'wishlist-intake-container';
mwContentText.replaceWith( root );
Vue.createMwApp( script$1, wishData ).mount( root );
} );
} );
} );
// </nowiki>