diff --git a/Bugzilla/Test/Selenium.pm b/Bugzilla/Test/Selenium.pm
index ac7afc9210..1df5343c82 100644
--- a/Bugzilla/Test/Selenium.pm
+++ b/Bugzilla/Test/Selenium.pm
@@ -467,7 +467,16 @@ sub is_editable_ok {
sub attach_file {
my ($self, $locator, $filename) = @_;
my $path = Mojo::File->new($filename);
- $self->type_ok('att-textarea', $path->slurp, 'Add attachment data');
+ $self->attach_text($path->slurp);
+}
+
+# Enter the given text as the content of a new attachment. The attachment
+# selector keeps its textarea hidden until the Enter Text button is clicked,
+# so it has to be revealed before it can be typed into.
+sub attach_text {
+ my ($self, $text, $desc) = @_;
+ $self->click_ok('att-enter-button', undef, 'Show the attachment text editor');
+ $self->type_ok('att-textarea', $text, $desc || 'Add attachment data');
}
# Private Helpers
diff --git a/extensions/BugModal/template/en/default/bug_modal/field.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/field.html.tmpl
index 0daebc5d93..823aa48285 100644
--- a/extensions/BugModal/template/en/default/bug_modal/field.html.tmpl
+++ b/extensions/BugModal/template/en/default/bug_modal/field.html.tmpl
@@ -152,7 +152,7 @@ END;
aria-required="true" aria-invalid="false" aria-errormessage="field-[% name FILTER html %]-error"
[% END %]
[% error_message = BLOCK %]
-
+
[% IF field_type == constants.FIELD_TYPE_SINGLE_SELECT %]
You must select a
[% ELSIF field_type == constants.FIELD_TYPE_MULTI_SELECT %]
diff --git a/extensions/BugModal/web/bug_modal.css b/extensions/BugModal/web/bug_modal.css
index c1e0a5fd12..95f4bde1f8 100644
--- a/extensions/BugModal/web/bug_modal.css
+++ b/extensions/BugModal/web/bug_modal.css
@@ -66,22 +66,6 @@
color: var(--control-foreground-color);
}
-[aria-invalid="true"],
-input:invalid,
-.attention {
- border-color: var(--invalid-control-border-color) !important;
-}
-
-.input-error {
- margin: 2px 4px;
- color: var(--error-message-foreground-color);
- font-size: var(--font-size-small);
-}
-
-.input-error.hidden {
- display: none !important;
-}
-
a.activity-ref {
color: inherit;
}
diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js
index bbc1336716..cf4210b17a 100644
--- a/extensions/BugModal/web/bug_modal.js
+++ b/extensions/BugModal/web/bug_modal.js
@@ -803,13 +803,7 @@ $(function() {
.map(($input) => {
let invalid = false;
- if ($input.id === 'att-data') {
- invalid = !$input.value.trim()
- && !document.querySelector('#att-textarea').value.trim()
- && !document.querySelector('#att-file').files.length;
- document.querySelector('#att-dropbox')?.classList
- .toggle('attention', invalid);
- } else if ($input.type === 'text') {
+ if ($input.type === 'text') {
invalid = !$input.value.trim();
} else if ($input.type === 'select-one') {
invalid = $input.selectedIndex === -1;
@@ -833,6 +827,13 @@ $(function() {
return !!$input;
});
+ // attachment.js: the attachment selector markup is generated at runtime, so it is
+ // not covered by the `[aria-required="true"]` loop above. `$form.submit()` below also
+ // bypasses both native constraint validation and the form's `submit` event, so the
+ // selector's own listeners never get a chance to block the submission here.
+ if (window.bzAttachmentForm && !window.bzAttachmentForm.validate(event))
+ return;
+
if (hasInvalidField || !$form.checkValidity())
return;
diff --git a/extensions/BugModal/web/create.js b/extensions/BugModal/web/create.js
index 2a664cb86b..0c412f1a7a 100644
--- a/extensions/BugModal/web/create.js
+++ b/extensions/BugModal/web/create.js
@@ -103,7 +103,7 @@ window.addEventListener('DOMContentLoaded', () => {
window.status_comment_required = statusCommentRequired;
// attachment.js
- bz_attachment_form.update_requirements(false);
+ bzAttachmentForm.updateRequirements(false);
// bug_modal.js
initKeywordsAutocomplete(keywords);
@@ -133,7 +133,7 @@ window.addEventListener('DOMContentLoaded', () => {
},
{
key: 'update_token',
- label: '',
+ label: 'Action',
formatter: Bugzilla.DupTable.formatCcButton,
allowHTML: true,
sortable: false,
@@ -283,9 +283,7 @@ window.addEventListener('DOMContentLoaded', () => {
const $attachFileActionOuter = document.querySelector('#attach-file-action-outer');
const updatedRequiredFields = (required) => {
- $attachFileContentOuter.querySelectorAll('[aria-required]').forEach(($input) => {
- $input.setAttribute('aria-required', required);
- });
+ bzAttachmentForm.updateRequirements(required);
};
$attachNewFile.addEventListener('click', () => {
diff --git a/extensions/GuidedBugEntry/Extension.pm b/extensions/GuidedBugEntry/Extension.pm
index b4b82e6562..94aeff46be 100644
--- a/extensions/GuidedBugEntry/Extension.pm
+++ b/extensions/GuidedBugEntry/Extension.pm
@@ -93,7 +93,6 @@ sub _init_vars {
$vars->{'platform'} = detect_platform();
$vars->{'op_sys'} = detect_op_sys();
- $vars->{'webdev'} = Bugzilla->cgi->param('webdev');
}
sub page_before_template {
@@ -113,8 +112,6 @@ sub page_before_template {
}
$vars->{'create_bug_formats'} = \%bug_formats;
}
-
- $vars->{'webdev'} = $cgi->param('webdev');
}
__PACKAGE__->NAME;
diff --git a/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl b/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl
index 8485dbf6e5..17ac63d363 100644
--- a/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl
+++ b/extensions/GuidedBugEntry/template/en/default/bug/create/comment-guided.txt.tmpl
@@ -8,21 +8,19 @@ Firefox for Android
[% END %]
[% IF cgi.param('bug_steps') %]
-Steps to reproduce:
+### Steps to reproduce
[%+ cgi.param('bug_steps') %]
[% END %]
[% IF cgi.param('actual') %]
-
-Actual results:
+### Actual results
[%+ cgi.param('actual') %]
[% END %]
[% IF cgi.param('expected') %]
-
-Expected results:
+### Expected results
[%+ cgi.param('expected') %]
[% END %]
diff --git a/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl b/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl
index 3a640ea1a0..fd63fe4889 100644
--- a/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl
+++ b/extensions/GuidedBugEntry/template/en/default/guided/guided.html.tmpl
@@ -11,108 +11,109 @@
[% js_urls = [ 'extensions/GuidedBugEntry/web/js/products.js',
'extensions/GuidedBugEntry/web/js/guided.js',
'extensions/ProdCompSearch/web/js/prod_comp_search.js',
- 'js/field.js', 'js/TUI.js', 'js/data-table.js', 'js/bug.js' ] %]
+ 'js/field.js', 'js/TUI.js', 'js/data-table.js', 'js/bug.js',
+ 'js/attachment.js' ] %]
[% PROCESS global/header.html.tmpl
- title = "Enter A Bug"
+ title = "Enter " _ terms.Bug
generate_api_token = 1
+ responsive = 1
+ use_text_editor = 1
javascript_urls = js_urls
- style_urls = [
+ style_urls = [
'extensions/GuidedBugEntry/web/style/guided.css',
- 'skins/standard/index.css'
+ 'skins/standard/index.css',
+ 'skins/standard/attachment.css',
]
%]
-
-
-
-Please wait...
-
-
-
-
-[% INCLUDE webdev_step %]
-[% INCLUDE product_step %]
-[% INCLUDE otherProducts_step %]
-[% INCLUDE dupes_step %]
-[% INCLUDE bugForm_step %]
-
-[% WRAPPER product_block
- icon="component.png"
- onclick="product.select('Core', 'Untriaged')" %]
-HTML, CSS, JS, SVG, or some other web technology or combination of
-web technologies?
-[% END %]
-
-[% WRAPPER product_block
- icon="devedition.png"
- onclick="product.select('Firefox', 'Developer Tools')" %]
-Firefox's developer tools
-[% END %]
-
-[% WRAPPER product_block
- icon="firefox.png"
- onclick="product.select('Firefox', 'Untriaged')" %]
-Firefox's user interface (for example, an issue with bookmarks,
-tabbed browsing or the location bar)
-[% END %]
-
+
+
+
Select a product category:
+
+ [% WRAPPER product_block
+ icon="component.png"
+ product_name="Core"
+ component_name="Untriaged"
+ caption="Web Standards"
+ %]
+ Support for HTML, CSS, JavaScript, SVG, or any other web technology
+ [% END %]
+ [% WRAPPER product_block
+ icon="devedition.png"
+ product_name="DevTools"
+ caption="Firefox DevTools"
+ %]
+ Firefox browser’s built-in developer tools
+ [% END %]
+ [% WRAPPER product_block
+ icon="firefox.png"
+ product_name="Firefox"
+ component_name="Untriaged"
+ %]
+ Firefox browser’s user interface, including features like bookmarking,
+ tabbed browsing, location bar, etc.
+ [% END %]
+
+
[% END %]
@@ -121,65 +122,49 @@ tabbed browsing or the location bar)
[%############################################################################%]
[% BLOCK product_step %]
-
-
-[% INCLUDE page_title %]
-
-[% INCLUDE exits
- show = "all"
-%]
-
-
-[% INCLUDE 'guided/products.html.tmpl' %]
-
-
-
- [% PROCESS prodcompsearch/form.html.tmpl
- input_label = "Find product:"
- format = "guided"
- script_name = "enter_bug.cgi" %]
-
+
+ [% INCLUDE exits
+ show = "all"
+ %]
+
+
Select a product to enter a new [% terms.bug %]:
+
+ [% INCLUDE 'guided/products.html.tmpl' %]
+
+
[% END %]
[% BLOCK product_block %]
[% IF !caption %]
- [% caption = name %]
+ [% caption = product_name %]
[% END %]
[% IF !desc %]
[% FOREACH cls = classifications %]
[% FOREACH p = cls.products %]
- [% IF p.name == name %]
+ [% IF p.name == product_name %]
[% desc = p.description %]
[% LAST %]
[% END %]
[% END %]
[% END %]
[% END %]
-
[% END %]
@@ -344,252 +303,269 @@ Product: ?:
[%# bug form step #%]
[%############################################################################%]
-[% BLOCK bugForm_step %]
-
-
-[% INCLUDE page_title %]
-
-
-
+
[% END %]
@@ -598,6 +574,11 @@ explain how to write effective [% terms.bug %] reports.
[%############################################################################%]
[% BLOCK help %]
-
+
+
+ [% content FILTER none %]
+
[% END %]
diff --git a/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl b/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl
index a12a4d07e4..67255d7bfb 100644
--- a/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl
+++ b/extensions/GuidedBugEntry/template/en/default/guided/products.html.tmpl
@@ -7,35 +7,65 @@
#%]
[% WRAPPER product_block
- name="Firefox"
+ product_name="Firefox"
icon="firefox.png"
%]
-For [% terms.bugs %] in Firefox, the Mozilla Foundation's web browser.
-
-(more info)
+Web browser for Windows, Mac and Linux
[% END %]
-[% INCLUDE product_block
- name="Firefox for Android"
+
+[% WRAPPER product_block
+ product_name="Firefox for Android"
icon="firefox_android.png"
%]
-[% INCLUDE product_block
- name="Firefox for iOS"
+Web browser for Android phones and tablets
+[% END %]
+
+[% WRAPPER product_block
+ product_name="Firefox for iOS"
icon="firefox_ios.png"
- onclick="document.location='https://github.com/mozilla-mobile/firefox-ios/issues'"
- security_bug_link = "enter_bug.cgi?format=guided#h=dupes|Focus|Security%3A iOS"
+ link="https://github.com/mozilla-mobile/firefox-ios/issues"
%]
-[% INCLUDE product_block
- name="Focus"
+Web browser for iPhone and iPad
+Use this form
+for security issues.
+[% END %]
+
+[% WRAPPER product_block
+ caption="Firefox Focus for Android"
+ product_name="Focus"
+ icon="focus.png"
+%]
+Privacy browser for Android phones and tablets
+[% END %]
+
+[% WRAPPER product_block
+ caption="Firefox Focus for iOS"
+ product_name="Focus"
icon="focus.png"
+ link="https://github.com/mozilla-mobile/firefox-ios/issues"
+%]
+Privacy browser for iPhone and iPad
+[% END %]
+
+[% WRAPPER product_block
+ product_name="Thunderbird"
+ icon="thunderbird.png"
%]
-[% INCLUDE product_block
- name="Thunderbird"
+Email software for Windows, Mac and Linux
+[% END %]
+
+[% WRAPPER product_block
+ caption="Thunderbird for Android"
+ product_name="Thunderbird"
icon="thunderbird.png"
+ link="https://github.com/thunderbird/thunderbird-android/issues"
%]
+Email software for Android phones and tablets
+[% END %]
+
[% WRAPPER product_block
- name="Other Products"
+ product_name="Other Products"
icon="other.png"
- onclick="guided.setStep('otherProducts')"
%]
-Other Mozilla products which aren't listed here
+Other Mozilla products that are not listed here
[% END %]
diff --git a/extensions/GuidedBugEntry/web/images/feedback.png b/extensions/GuidedBugEntry/web/images/feedback.png
deleted file mode 100644
index 03637fda37..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/feedback.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/help.png b/extensions/GuidedBugEntry/web/images/help.png
deleted file mode 100644
index 5c870176d4..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/help.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/input.png b/extensions/GuidedBugEntry/web/images/input.png
deleted file mode 100644
index 34c10e9894..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/input.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/message.png b/extensions/GuidedBugEntry/web/images/message.png
deleted file mode 100644
index 55b6add196..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/message.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/sumo.png b/extensions/GuidedBugEntry/web/images/sumo.png
deleted file mode 100644
index d5773647c2..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/sumo.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/support.png b/extensions/GuidedBugEntry/web/images/support.png
deleted file mode 100644
index 21f1a17d20..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/support.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/warning.png b/extensions/GuidedBugEntry/web/images/warning.png
deleted file mode 100644
index 86bed170d3..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/warning.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/images/webbug.png b/extensions/GuidedBugEntry/web/images/webbug.png
deleted file mode 100644
index 027aad6848..0000000000
Binary files a/extensions/GuidedBugEntry/web/images/webbug.png and /dev/null differ
diff --git a/extensions/GuidedBugEntry/web/js/guided.js b/extensions/GuidedBugEntry/web/js/guided.js
index 2e514ec284..c7ed1d5fd0 100644
--- a/extensions/GuidedBugEntry/web/js/guided.js
+++ b/extensions/GuidedBugEntry/web/js/guided.js
@@ -5,259 +5,524 @@
* This Source Code Form is "Incompatible With Secondary Licenses", as
* defined by the Mozilla Public License, v. 2.0. */
-// global
-
-var guided = {
- _currentStep: '',
- _defaultStep: 'product',
- currentUser: '',
- openStates: [],
- updateStep: true,
-
- setStep: function(newStep, noSetHistory) {
- // initialize new step
+/**
+ * Implement the guided bug entry wizard.
+ */
+class GuidedBugEntry {
+ /**
+ * Wizard steps.
+ * @type {string[]}
+ */
+ static WIZARD_STEPS = ['product', 'dupes', 'form'];
+
+ /**
+ * Current step name.
+ * @type {string}
+ */
+ static currentStep = '';
+
+ /**
+ * Default step name.
+ * @type {string}
+ */
+ static defaultStep = 'product';
+
+ /**
+ * Whether to update the current step.
+ * @type {boolean}
+ */
+ static updateStep = true;
+
+ /**
+ * Statuses considered "open" for following bugs.
+ * @type {string[]}
+ */
+ static openStates = [];
+
+ /**
+ * Whether the web development entry flow is enabled.
+ * @type {boolean}
+ */
+ static webdev = false;
+
+ /**
+ * Initiate a step change.
+ * @param {string} newStep New step name.
+ * @param {boolean} noSetHistory Whether to avoid updating history.
+ */
+ static setStep(newStep, noSetHistory) {
this.updateStep = true;
- switch(newStep) {
+
+ switch (newStep) {
case 'webdev':
- webdev.onShow();
+ GuidedBugEntryWebDevPage.onShow();
break;
case 'product':
- product.onShow();
+ GuidedBugEntryProductPage.onShow();
break;
- case 'otherProducts':
- otherProducts.onShow();
+ case 'other-products':
+ GuidedBugEntryOtherProductsPage.onShow();
break;
case 'dupes':
- dupes.onShow();
+ GuidedBugEntryOtherDupesPage.onShow();
break;
- case 'bugForm':
- bugForm.onShow();
+ case 'bug-form':
+ GuidedBugEntryFormPage.onShow();
break;
default:
- guided.setStep(this._defaultStep);
+ GuidedBugEntry.setStep(this.defaultStep, noSetHistory);
return;
}
- if (!this.updateStep)
- return;
+ if (!this.updateStep) {
+ return;
+ }
// change visibility of _step div
- if (this._currentStep)
- document.getElementById(`${this._currentStep}_step`).classList.add('hidden');
- this._currentStep = newStep;
- document.getElementById(`${this._currentStep}_step`).classList.remove('hidden');
+ if (this.currentStep) {
+ document.querySelector(`#${this.currentStep}-step`).hidden = true;
+ }
+
+ this.currentStep = newStep;
+ document.querySelector(`#${this.currentStep}-step`).hidden = false;
// scroll to top of page to mimic real navigation
- scroll(0,0);
+ scroll(0, 0);
- // update history
- if (!noSetHistory) {
- const params = new URLSearchParams(window.location.search);
- const isDefaultStep = newStep === this._defaultStep;
- const _product = isDefaultStep ? '' : product.getName();
- const _component = isDefaultStep ? '' : product.getPreselectedComponent();
+ if (noSetHistory) {
+ return;
+ }
- if (_product) {
- params.set('product', _product);
- } else {
- params.delete('product');
- }
+ const { search, pathname } = window.location;
+ const params = new URLSearchParams(search);
+ const isDefaultStep = newStep === this.defaultStep;
+ const productName = isDefaultStep ? '' : GuidedBugEntryProductPage.productName;
+ const componentName = isDefaultStep ? '' : GuidedBugEntryProductPage.preselectedComponent;
- if (_component) {
- params.set('component', _component);
- } else {
- params.delete('component');
- }
+ if (productName) {
+ params.set('product', productName);
+ } else {
+ params.delete('product');
+ }
- window.history.pushState(
- {
- step: newStep,
- product: _product,
- component: _component,
- },
- '',
- `${window.location.pathname}?${params.toString()}`
- );
+ if (componentName) {
+ params.set('component', componentName);
+ } else {
+ params.delete('component');
}
- },
- init: function(conf) {
- // init history manager
- if (conf.webdev) {
- this._defaultStep = 'webdev';
+ window.history.pushState(
+ {
+ step: newStep,
+ product: productName,
+ component: componentName,
+ },
+ '',
+ `${pathname}?${params.toString()}`,
+ );
+ }
+
+ /**
+ * Update the stepper indicators.
+ * @param {'product' | 'dupes' | 'form'} page Page name.
+ */
+ static updateSteppers(page) {
+ const wizardIndex = this.WIZARD_STEPS.indexOf(page);
+
+ this.WIZARD_STEPS.forEach((step, index) => {
+ const indicator = document.querySelector(`#stepper-${step}`);
+
+ indicator.classList.toggle('done', index < wizardIndex);
+ indicator.setAttribute('aria-current', index === wizardIndex ? 'step' : 'false');
+ });
+ }
+
+ /**
+ * Initialize the guided bug entry and history management.
+ */
+ static init() {
+ const webdev = new URLSearchParams(location.search).get('webdev');
+
+ if (webdev && webdev !== '0') {
+ this.defaultStep = 'webdev';
this.webdev = true;
}
- // init steps
- webdev.onInit();
- product.onInit();
- dupes.onInit();
- bugForm.onInit();
- bugForm.initHelp();
-
- const noSetHistory = !window.history.state;
+ document.querySelector('#steps').hidden = false;
- if (!window.history.state) {
- const params = new URLSearchParams(window.location.search);
- // Support for the legacy, hash-based YUI history state handler
- const [_step = '', _product = '', _component = '']
- = window.location.hash.replace('#h=', '').split('|').map(str => decodeURIComponent(str));
+ this.openStates = JSON.parse(document.querySelector('#guided').dataset.openStates);
- if (_product) {
- params.set('product', _product);
- }
+ // init steps
+ GuidedBugEntryProductPage.onInit();
+ GuidedBugEntryOtherDupesPage.onInit();
+ GuidedBugEntryFormPage.onInit();
+ GuidedBugEntryFormPage.initHelp();
- if (_component) {
- params.set('component', _component);
- }
+ const noSetHistory = !window.history.state;
- const _params = Object.fromEntries(params);
+ if (noSetHistory) {
+ const { search, pathname } = window.location;
+ const params = new URLSearchParams(search);
+ const { product: productName, component: componentName } = Object.fromEntries(params);
window.history.replaceState(
{
- step: _step || (_params.product ? 'dupes' : this._defaultStep),
- product: _params.product || '',
- component: _params.component || '',
+ step: productName ? 'dupes' : this.defaultStep,
+ product: productName ?? '',
+ component: componentName ?? '',
},
'',
- `${window.location.pathname}?${params.toString()}`
+ `${pathname}?${params.toString()}`,
);
}
- this._onStateChange(noSetHistory);
+ this.onStateChange(noSetHistory);
window.addEventListener('popstate', () => {
- this._onStateChange(true);
+ this.onStateChange(true);
});
- },
- _onStateChange: function(noSetHistory) {
- const state = window.history.state ?? {};
+ document.querySelectorAll('.product-link').forEach(($item) => {
+ const handleEvent = (event) => {
+ if (event.target.matches('p a')) {
+ return;
+ }
+
+ event.preventDefault();
- product.setName(state.product ?? '');
- product.setPreselectedComponent(state.component ?? '');
- guided.setStep(state.step, noSetHistory);
- },
+ const { link, product: productName, component: componentName } = $item.dataset;
- setAdvancedLink: function() {
- var href = `${BUGZILLA.config.basepath}enter_bug.cgi?format=__default__` +
- `&product=${encodeURIComponent(product.getName())}` +
- `&short_desc=${encodeURIComponent(dupes.getSummary())}`;
- document.getElementById('advanced_img').href = href;
- document.getElementById('advanced_link').href = href;
+ if (link) {
+ if (!URL.canParse(link, location.href)) {
+ return;
+ }
+
+ const { protocol, href } = new URL(link, location.href);
+
+ // Only allow web links; never navigate to `javascript:` etc.
+ if (!protocol.match(/^https?:$/)) {
+ return;
+ }
+
+ if (event.metaKey || event.ctrlKey) {
+ window.open(href, '_blank');
+ } else {
+ location.href = href;
+ }
+ } else if (productName === 'Other Products') {
+ GuidedBugEntry.setStep('other-products');
+ } else {
+ GuidedBugEntryProductPage.select(productName, componentName);
+ }
+ };
+
+ $item.addEventListener('click', (event) => {
+ handleEvent(event);
+ });
+
+ $item.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ handleEvent(event);
+ }
+ });
+ });
+
+ document.querySelectorAll('[data-step]').forEach(($link) => {
+ $link.addEventListener('click', (event) => {
+ event.preventDefault();
+ GuidedBugEntry.setStep($link.dataset.step);
+ });
+ });
+ }
+
+ /**
+ * Callback for history state changes.
+ * @param {boolean} noSetHistory Whether to avoid updating history.
+ */
+ static onStateChange(noSetHistory) {
+ const { product, component, step } = window.history.state ?? {};
+
+ GuidedBugEntryProductPage.preselectedComponent = component ?? '';
+ GuidedBugEntryProductPage.setProduct(product ?? '');
+ GuidedBugEntry.setStep(step, noSetHistory);
}
-};
-// webdev step
+ /**
+ * Show or clear the inline error message for a field. The message element is keyed off the
+ * field’s own ID, so repeated calls update it in place instead of stacking duplicates.
+ * @param {HTMLElement} $field Field the message belongs to.
+ * @param {string} [message] Message to show. Pass nothing to clear the error.
+ */
+ static setFieldError($field, message = '') {
+ const id = `${$field.id}-error`;
+ let $message = document.getElementById(id);
+
+ if (!message) {
+ $field.removeAttribute('aria-invalid');
+ $field.removeAttribute('aria-errormessage');
+ $message?.remove();
-var webdev = {
- details: false,
+ return;
+ }
- onInit: function () { },
+ $field.setAttribute('aria-invalid', 'true');
+ $field.setAttribute('aria-errormessage', id);
+
+ if (!$message) {
+ $message = document.createElement('div');
+ $message.id = id;
+ $message.className = 'error-message';
+ $field.insertAdjacentElement('afterend', $message);
+ }
- onShow: function () { }
-};
+ $message.textContent = message;
+ }
-// product step
+ /**
+ * Set the advanced bug entry link.
+ */
+ static setAdvancedLink() {
+ const params = new URLSearchParams({ format: '__default__' });
-var product = {
- details: false,
- _loaded: '',
- _preselectedComponent: '',
+ const { productName } = GuidedBugEntryProductPage;
+ const { summary } = GuidedBugEntryOtherDupesPage;
- onInit: function() { },
+ if (productName) {
+ params.set('product', productName);
+ }
- onShow: function() {
- document.getElementById('advanced').classList.remove('hidden');
- },
+ if (summary) {
+ params.set('short_desc', summary);
+ }
- select: function(productName, componentName) {
- var prod = products[productName];
+ const href = `${BUGZILLA.config.basepath}enter_bug.cgi?${params}`;
+
+ document.querySelector('#advanced-link').href = href;
+ }
+}
+
+/**
+ * Web development product selection page.
+ */
+class GuidedBugEntryWebDevPage {
+ /**
+ * Show callback.
+ */
+ static onShow() {
+ GuidedBugEntry.updateSteppers('product');
+ }
+}
+
+/**
+ * Product selection page.
+ */
+class GuidedBugEntryProductPage {
+ /**
+ * Loaded product details.
+ * @type {{ components: { name: string, is_active: boolean }[], versions: { name: string,
+ * is_active: boolean }[] } | null}
+ */
+ static details = null;
+
+ /**
+ * Preselected component name.
+ * @type {string}
+ */
+ static preselectedComponent = '';
+
+ /**
+ * Currently loaded product name.
+ * @type {string | null}
+ */
+ static loadedProductName = null;
+
+ /**
+ * Reference to the hidden product input element.
+ * @type {HTMLInputElement}
+ */
+ static $product = null;
+
+ /**
+ * Reference to the product name label on the form page.
+ * @type {HTMLElement}
+ */
+ static $productLabel = null;
+
+ /**
+ * Reference to the product name label on the duplicate search page.
+ * @type {HTMLElement}
+ */
+ static $dupeProductName = null;
+
+ /**
+ * Reference to the “See All Components” link element.
+ * @type {HTMLAnchorElement}
+ */
+ static $listComp = null;
+
+ /**
+ * Reference to the product support message container element.
+ * @type {HTMLElement}
+ */
+ static $support = null;
+
+ /**
+ * Reference to the product support message element.
+ * @type {HTMLElement}
+ */
+ static $supportMessage = null;
+
+ /**
+ * Reference to the component selection section element.
+ * @type {HTMLElement}
+ */
+ static $componentSection = null;
+
+ /**
+ * Initialization callback.
+ */
+ static onInit() {
+ this.$product = document.querySelector('#product');
+ this.$productLabel = document.querySelector('#product-label');
+ this.$dupeProductName = document.querySelector('#dupe-product-name');
+ this.$listComp = document.querySelector('#list-comp');
+ this.$support = document.querySelector('#product-support');
+ this.$supportMessage = document.querySelector('#product-support-message');
+ this.$componentSection = document.querySelector('#component-section');
+ }
+
+ /**
+ * Show callback.
+ */
+ static onShow() {
+ GuidedBugEntry.updateSteppers('product');
+ }
+
+ /**
+ * Select a product and optional component.
+ * @param {string} productName Product name.
+ * @param {string} [componentName] Component name.
+ */
+ static select(productName, componentName) {
+ const prod = products[productName];
// called when a product is selected
if (componentName) {
- if (prod && prod.defaultComponent) {
+ if (prod?.defaultComponent) {
prod.originalDefaultComponent = prod.originalDefaultComponent || prod.defaultComponent;
prod.defaultComponent = componentName;
}
- }
- else {
- if (prod && prod.defaultComponent && prod.originalDefaultComponent) {
+ } else {
+ if (prod?.defaultComponent && prod.originalDefaultComponent) {
prod.defaultComponent = prod.originalDefaultComponent;
}
}
- this.setPreselectedComponent(prod?.defaultComponent || '');
- this.setName(productName);
- dupes.reset();
- guided.setStep('dupes');
- },
- getName: function() {
- return document.getElementById('product').value;
- },
+ this.preselectedComponent = prod?.defaultComponent || componentName || '';
+
+ // This is a fresh choice by the user, so drop the component carried over from the previous
+ // selection, then re-apply the preselection. `setProduct()` returns early when the product
+ // itself hasn’t changed, so it can’t be relied on to do this.
+ GuidedBugEntryFormPage.resetComponent();
+ this.setProduct(productName);
+ GuidedBugEntryFormPage.onProductUpdated();
- getPreselectedComponent: function() {
- return this._preselectedComponent;
- },
+ GuidedBugEntryOtherDupesPage.reset();
+ GuidedBugEntry.setStep('dupes');
+ }
- setPreselectedComponent: function(value) {
- this._preselectedComponent = value;
- },
+ /**
+ * Get the currently selected product name.
+ */
+ static get productName() {
+ return this.$product.value;
+ }
- _getNameAndRelated: function() {
- var result = [];
+ /**
+ * Get the currently selected product name and related products.
+ */
+ static get productNameAndRelated() {
+ const { productName } = this;
- var name = this.getName();
- result.push(name);
+ return [productName, ...(products[productName]?.related ?? [])];
+ }
- if (products[name] && products[name].related) {
- for (var i = 0, n = products[name].related.length; i < n; i++) {
- result.push(products[name].related[i]);
- }
+ /**
+ * Get the component to be used as-is for the given product, without asking the user. This is
+ * empty unless the product’s component selection is suppressed *and* a component is actually
+ * known; otherwise the component selector has to be shown, because submitting the form without a
+ * component always fails.
+ * @param {string} [productName] Product name. Defaults to the selected product.
+ * @returns {string} Component name, or an empty string if the user has to pick one.
+ */
+ static fixedComponent(productName = this.productName) {
+ const { noComponentSelection, defaultComponent } = products[productName] ?? {};
+
+ if (!noComponentSelection && !GuidedBugEntry.webdev) {
+ return '';
}
- return result;
- },
+ return defaultComponent || this.preselectedComponent || '';
+ }
+
+ /**
+ * Show or hide the component selector according to `fixedComponent()`.
+ * @param {string} [productName] Product name. Defaults to the selected product.
+ * @returns {string} The fixed component name, or an empty string if the user has to pick one.
+ */
+ static updateComponentSection(productName = this.productName) {
+ const fixedComponent = this.fixedComponent(productName);
- setName: async function(productName) {
- if (productName == this.getName() && this.details)
+ this.$componentSection.hidden = !!fixedComponent;
+
+ return fixedComponent;
+ }
+
+ /**
+ * Set the currently selected product.
+ * @param {string} productName Product name.
+ */
+ static async setProduct(productName) {
+ if (productName === this.productName && this.details) {
return;
+ }
// display the product name
- document.getElementById('product').value = productName;
- document.getElementById('product_label').innerHTML = productName.htmlEncode();
- document.getElementById('dupes_product_name').innerHTML = productName.htmlEncode();
- document.getElementById('list_comp').href
- = `${BUGZILLA.config.basepath}describecomponents.cgi?product=${encodeURIComponent(productName)}`;
- guided.setAdvancedLink();
-
- if (productName == '') {
- document.getElementById('product_support').classList.add('hidden');
+ this.$product.value = productName;
+ this.$productLabel.innerHTML = productName.htmlEncode();
+ this.$dupeProductName.innerHTML = productName.htmlEncode();
+
+ const { basepath } = BUGZILLA.config;
+ const params = new URLSearchParams({ product: productName });
+
+ this.$listComp.href = `${basepath}describecomponents.cgi?${params}`;
+
+ GuidedBugEntry.setAdvancedLink();
+
+ if (productName === '') {
+ this.$support.hidden = true;
return;
}
// show support message
- if (products[productName] && products[productName].support) {
- document.getElementById('product_support_message').innerHTML = products[productName].support;
- document.getElementById('product_support').classList.remove('hidden');
- } else {
- document.getElementById('product_support').classList.add('hidden');
+ const { support } = products[productName] ?? {};
+
+ if (support) {
+ this.$supportMessage.innerHTML = support;
}
+ this.$support.hidden = !support;
+
// show/hide component selection row
- const $row = document.getElementById('componentTR');
- if (products[productName] && products[productName].noComponentSelection || guided.webdev) {
- $row.classList.add('hidden');
- } else {
- $row.classList.remove('hidden');
- }
+ this.updateComponentSection(productName);
- if (this._loaded == productName)
+ if (this.loadedProductName === productName) {
return;
+ }
// grab the product information
- this.details = false;
- this._loaded = productName;
+ this.details = null;
+ this.loadedProductName = productName;
try {
const { products } = await Bugzilla.API.get('product', {
@@ -266,501 +531,886 @@ var product = {
});
if (products.length) {
- product.details = products[0];
- bugForm.onProductUpdated();
+ this.details = products[0];
+ GuidedBugEntryFormPage.onProductUpdated();
} else {
- document.location.href = `${BUGZILLA.config.basepath}enter_bug.cgi?format=guided`;
+ document.location.href = `${basepath}enter_bug.cgi?format=guided`;
}
- } catch ({ message }) {
- this._loaded = '';
- product.details = false;
- bugForm.onProductUpdated();
- alert(`Failed to retrieve components for product "${productName}"\n\n${message}`);
+ } catch (err) {
+ this.loadedProductName = null;
+ this.details = null;
+ GuidedBugEntryFormPage.onProductUpdated();
+ console.error(err);
+ alert(`Failed to retrieve components for product "${productName}"\n\n${err.message}`);
}
}
-};
-
-// other products step
-
-var otherProducts = {
- onInit: function() { },
+}
- onShow: function() {
- document.getElementById('advanced').classList.remove('hidden');
+/**
+ * Other products selection page.
+ */
+class GuidedBugEntryOtherProductsPage {
+ /**
+ * Show callback.
+ */
+ static onShow() {
+ GuidedBugEntry.updateSteppers('product');
}
-};
-
-// duplicates step
-
-var dupes = {
- _dataTable: null,
- _dataTableColumns: null,
- _elSummary: null,
- _elSearch: null,
- _elList: null,
- _currentSearchQuery: '',
-
- onInit: function() {
- this._elSummary = document.getElementById('dupes_summary');
- this._elSearch = document.getElementById('dupes_search');
- this._elList = document.getElementById('dupes_list');
+}
- this._elSummary.addEventListener('blur', this._onSummaryBlur);
- this._elSummary.addEventListener('input', this._onSummaryBlur);
- this._elSummary.addEventListener('keydown', this._onSummaryKeyDown);
- this._elSummary.addEventListener('keyup', this._onSummaryKeyUp);
- this._elSearch.addEventListener('click', this._doSearch);
- },
+/**
+ * Duplicate search page.
+ */
+class GuidedBugEntryOtherDupesPage {
+ /**
+ * Reference to the duplicate results data table.
+ * @type {Bugzilla.DataTable}
+ */
+ static dataTable = null;
+
+ /**
+ * Data table columns.
+ * @type {{ key: string, label: string , formatter: function, allowHTML: boolean, sortable:
+ * boolean }[]}
+ */
+ static dataTableColumns = null;
+
+ /**
+ * Reference to the summary input element.
+ * @type {HTMLInputElement}
+ */
+ static $summary = null;
+
+ /**
+ * Reference to the search button element.
+ * @type {HTMLButtonElement}
+ */
+ static $search = null;
+
+ /**
+ * Reference to the duplicate list container element.
+ * @type {HTMLDivElement}
+ */
+ static $list = null;
+
+ /**
+ * Reference to the “continue to the form” container element.
+ * @type {HTMLDivElement}
+ */
+ static $continue = null;
+
+ /**
+ * Reference to the “continue to the form” button element.
+ * @type {HTMLButtonElement}
+ */
+ static $continueButton = null;
+
+ /**
+ * Reference to the localization message element.
+ * @type {HTMLElement}
+ */
+ static $l10nMessage = null;
+
+ /**
+ * Reference to the product name within the localization message.
+ * @type {HTMLElement}
+ */
+ static $l10nProduct = null;
+
+ /**
+ * Reference to the localization message link element.
+ * @type {HTMLAnchorElement}
+ */
+ static $l10nLink = null;
+
+ /**
+ * Current search query.
+ * @type {string}
+ */
+ static currentSearchQuery = '';
+
+ /**
+ * Initialization callback.
+ */
+ static onInit() {
+ this.$summary = document.querySelector('#dupe-summary');
+ this.$search = document.querySelector('#dupe-search');
+ this.$list = document.querySelector('#dupe-list');
+ this.$continue = document.querySelector('#dupe-continue');
+ this.$continueButton = document.querySelector('#dupe-continue-button');
+ this.$l10nMessage = document.querySelector('#l10n-message');
+ this.$l10nProduct = document.querySelector('#l10n-product');
+ this.$l10nLink = document.querySelector('#l10n-link');
+
+ this.$summary.addEventListener('blur', this.onSummaryBlur.bind(this));
+ this.$summary.addEventListener('input', this.onSummaryBlur.bind(this));
+ this.$summary.addEventListener('keydown', this.onSummaryKeyDown.bind(this));
+ this.$summary.addEventListener('keyup', this.onSummaryKeyUp.bind(this));
+ this.$search.addEventListener('click', this.doSearch.bind(this));
+ }
- setLabels: function(labels) {
- this._dataTableColumns = [
- { key: "id", label: labels.id, formatter: this._formatId, allowHTML: true },
- { key: "summary", label: labels.summary },
- { key: "component", label: labels.component },
- { key: "status", label: labels.status, formatter: this._formatStatus },
- { key: "update_token", label: '', formatter: this._formatCc, allowHTML: true, sortable: false }
+ /**
+ * Initialize the duplicate results data table.
+ */
+ static initDataTable() {
+ const labels = JSON.parse(document.querySelector('#guided').dataset.dupeLabels);
+
+ this.dataTableColumns = [
+ { key: 'id', label: labels.id, formatter: this.formatId.bind(this), allowHTML: true },
+ { key: 'summary', label: labels.summary },
+ { key: 'component', label: labels.component },
+ { key: 'status', label: labels.status, formatter: this.formatStatus.bind(this) },
+ {
+ key: 'update_token',
+ label: 'Action',
+ formatter: this.formatCc.bind(this),
+ allowHTML: true,
+ sortable: false,
+ },
];
- },
- _initDataTable: function() {
- this._dataTable = new Bugzilla.DataTable({
- container: '#dupes_list',
- columns: this._dataTableColumns,
+ this.dataTable = new Bugzilla.DataTable({
+ container: '#dupe-list',
+ columns: this.dataTableColumns,
strings: {
EMPTY: 'No similar issues found.',
- ERROR: 'An error occurred while searching for similar issues, please try again.'
- }
+ ERROR: 'An error occurred while searching for similar issues, please try again.',
+ },
});
- },
+ }
- _formatId: function({ value }) {
- return `${value}`;
- },
+ /**
+ * Format bug ID as a link.
+ * @param {object} params Formatter parameters.
+ * @param {number} params.value Bug ID.
+ * @returns {string} Formatted HTML.
+ */
+ static formatId({ value: id }) {
+ return `${id}`;
+ }
- _formatStatus: function({ value, data: { resolution } }) {
+ /**
+ * Format bug status with resolution.
+ * @param {object} params Formatter parameters.
+ * @param {string} params.value Bug status.
+ * @param {object} params.data Bug data.
+ * @param {string} params.data.resolution Bug resolution.
+ * @returns {string} Formatted status.
+ */
+ static formatStatus({ value, data: { resolution } }) {
const status = display_value('bug_status', value);
return resolution ? `${status} ${display_value('resolution', resolution)}` : status;
- },
+ }
- _formatCc: function({ data: { id, status, cc } }) {
- return dupes._buildCcHTML(id, status, cc.some((email) => email === guided.currentUser));
- },
+ /**
+ * Format CC column with follow/unfollow button.
+ * @param {object} params Formatter parameters.
+ * @returns {string | HTMLButtonElement} Follow/unfollow button, or empty string if not
+ * applicable.
+ */
+ static formatCc({ data: { id, status, cc } }) {
+ return this.buildCcHTML(
+ id,
+ status,
+ cc.some((email) => email === BUGZILLA.user.login),
+ );
+ }
- _buildCcHTML: function(id, bugStatus, isCCed, button) {
- const isOpen = guided.openStates.includes(bugStatus);
+ /**
+ * Build the CC column HTML.
+ * @param {number} id Bug ID.
+ * @param {string} bugStatus Bug status.
+ * @param {boolean} isCCed Whether the user is CCed.
+ * @param {HTMLButtonElement} [button] Existing button element.
+ * @returns {string | HTMLButtonElement} Button element or empty string.
+ */
+ static buildCcHTML(id, bugStatus, isCCed, button) {
+ const isOpen = GuidedBugEntry.openStates.includes(bugStatus);
if (!isOpen && !isCCed) {
- // you can't cc yourself to a closed bug here
+ // you can’t cc yourself to a closed bug here
return '';
}
- button ||= document.createElement('button');
+ button ??= document.createElement('button');
button.type = 'button';
button.disabled = false;
if (isCCed) {
button.innerHTML = 'Stop following';
button.onclick = () => {
- dupes.updateFollowing(id, bugStatus, false, button); return false;
+ this.updateFollowing(id, bugStatus, false, button);
+ return false;
};
} else {
button.innerHTML = 'Follow bug';
button.onclick = () => {
- dupes.updateFollowing(id, bugStatus, true, button); return false;
+ this.updateFollowing(id, bugStatus, true, button);
+ return false;
};
}
return button;
- },
+ }
- updateFollowing: async function(bugID, bugStatus, follow, button) {
+ /**
+ * Update following status for a bug.
+ * @param {number} bugID Bug ID.
+ * @param {string} bugStatus Bug status.
+ * @param {boolean} follow Whether to follow or unfollow.
+ * @param {HTMLButtonElement} button Button element.
+ * @returns {Promise} Updated button element.
+ */
+ static async updateFollowing(bugID, bugStatus, follow, button) {
button.disabled = true;
button.innerHTML = 'Updating...';
- var ccObject;
- if (follow) {
- ccObject = { add: [ guided.currentUser ] };
- } else {
- ccObject = { remove: [ guided.currentUser ] };
- }
+ const { login } = BUGZILLA.user;
+ const ccObject = follow ? { add: [login] } : { remove: [login] };
try {
await Bugzilla.API.put(`bug/${bugID}`, { ids: [bugID], cc: ccObject });
- return dupes._buildCcHTML(bugID, bugStatus, follow, button);
+ return this.buildCcHTML(bugID, bugStatus, follow, button);
} catch ({ message }) {
alert(`Update failed:\n\n${message}`);
- return dupes._buildCcHTML(bugID, bugStatus, !follow, button);
- }
- },
-
- reset: function() {
- this._elSummary.value = '';
- this._elList.classList.add('hidden');
- document.getElementById('dupes_continue').classList.add('hidden');
- this._elList.innerHTML = '';
- this._showProductSupport();
- this._currentSearchQuery = '';
- this._elSummary.focus();
- },
-
- _showProductSupport: function() {
- const elSupportId = `product_support_${product.getName().replace(' ', '_').toLowerCase()}`;
- document.querySelectorAll('.product_support').forEach(($element) => {
- $element.classList.toggle('hidden', $element.id !== elSupportId);
+ return this.buildCcHTML(bugID, bugStatus, !follow, button);
+ }
+ }
+
+ /**
+ * Reset the duplicate search page.
+ */
+ static reset() {
+ this.$summary.value = '';
+ this.$list.hidden = true;
+ this.$list.innerHTML = '';
+ this.$continue.hidden = true;
+ this.currentSearchQuery = '';
+
+ window.requestAnimationFrame(() => {
+ this.$summary.focus();
+ this.$summary.select();
});
- },
-
- onShow: function() {
- this._showProductSupport();
- this._onSummaryBlur();
-
- // hide the advanced form and top continue button entry until
- // a search has happened
- document.getElementById('advanced').classList.add('hidden');
- document.getElementById('dupes_continue_button_top').classList.add('hidden');
- var prod = product.getName();
- if (products[prod] && products[prod].l10n) {
- document.getElementById('l10n_message').classList.remove('hidden');
- document.getElementById('l10n_product').textContent = product.getName();
- document.getElementById('l10n_link').onclick = function () {
- product.select('Mozilla Localizations');
+ }
+
+ /**
+ * Show callback.
+ */
+ static onShow() {
+ this.onSummaryBlur();
+
+ GuidedBugEntry.updateSteppers('dupes');
+
+ const { productName } = GuidedBugEntryProductPage;
+
+ if (products[productName]?.l10n) {
+ this.$l10nProduct.textContent = productName;
+ this.$l10nLink.onclick = (event) => {
+ event.preventDefault();
+ GuidedBugEntryProductPage.select('Mozilla Localizations');
};
- }
- else {
- document.getElementById('l10n_message').classList.add('hidden');
+ this.$l10nMessage.hidden = false;
+ } else {
+ this.$l10nMessage.hidden = true;
}
- if (!this._elSearch.disabled && this.getSummary().length >= 4) {
- // do an immediate search after a page refresh if there's a query
- this._doSearch();
-
+ if (!this.$search.disabled && this.summary.length >= 4) {
+ // do an immediate search after a page refresh if there’s a query
+ this.doSearch();
} else {
// prepare for a search
this.reset();
}
- },
+ }
- _onSummaryBlur: function() {
- dupes._elSearch.disabled = dupes._elSummary.value == '';
- guided.setAdvancedLink();
- },
+ /**
+ * Summary input blur handler.
+ */
+ static onSummaryBlur() {
+ this.$search.disabled = !this.summary;
+ GuidedBugEntry.setAdvancedLink();
+ }
- _onSummaryKeyDown: function(e) {
+ /**
+ * Summary input keydown handler.
+ * @param {KeyboardEvent} event `keydown` event.
+ */
+ static onSummaryKeyDown(event) {
// map to doSearch()
- if (e && (e.keyCode == 13)) {
- dupes._doSearch();
- e.stopPropagation();
+ if (event.keyCode === 13) {
+ this.doSearch();
+ event.stopPropagation();
}
- },
+ }
- _onSummaryKeyUp: function(e) {
- // disable search button until there's a query
- dupes._elSearch.disabled = dupes._elSummary.value.trim() == '';
- },
+ /**
+ * Summary input keyup handler.
+ */
+ static onSummaryKeyUp() {
+ // disable search button until there’s a query
+ this.$search.disabled = !this.summary;
+ }
+
+ /**
+ * Perform the duplicate search.
+ */
+ static async doSearch() {
+ if ([...this.summary].length < 4) {
+ GuidedBugEntry.setFieldError(this.$summary, 'The summary must be at least 4 characters.');
- _doSearch: async function() {
- if (dupes.getSummary().length < 4) {
- alert('The summary must be at least 4 characters long.');
return;
}
- dupes._elSummary.blur();
- // don't query if we already have the results (or they are pending)
- if (dupes._currentSearchQuery == dupes.getSummary())
+ GuidedBugEntry.setFieldError(this.$summary);
+
+ this.$search.blur();
+
+ // don’t query if we already have the results (or they are pending)
+ if (this.currentSearchQuery === this.summary) {
return;
- dupes._currentSearchQuery = dupes.getSummary();
+ }
+
+ this.currentSearchQuery = this.summary;
// initialize the datatable as late as possible
- dupes._initDataTable();
+ this.initDataTable();
try {
// run the search
- dupes._elList.classList.remove('hidden');
+ this.$list.hidden = false;
+
+ const src = `${BUGZILLA.config.basepath}extensions/GuidedBugEntry/web/images/throbber.gif`;
- dupes._dataTable.render([]);
- dupes._dataTable.setMessage(
- 'Searching for similar issues... ' +
- `'
+ this.dataTable.render([]);
+ this.dataTable.setMessage(
+ `Searching for similar issues... `,
);
- document.getElementById('dupes_continue_button_top').disabled = true;
- document.getElementById('dupes_continue_button_bottom').disabled = true;
- document.getElementById('dupes_continue').classList.remove('hidden');
+ this.$continueButton.disabled = true;
+ this.$continue.hidden = false;
let data;
+ const includeFields = [
+ 'id',
+ 'summary',
+ 'status',
+ 'resolution',
+ 'update_token',
+ 'cc',
+ 'component',
+ ];
+
try {
const { bugs } = await Bugzilla.API.get('bug/possible_duplicates', {
- product: product._getNameAndRelated(),
- summary: dupes.getSummary(),
+ product: GuidedBugEntryProductPage.productNameAndRelated,
+ summary: this.summary,
limit: 12,
- include_fields: ['id', 'summary', 'status', 'resolution', 'update_token', 'cc', 'component'],
+ include_fields: includeFields,
});
data = { results: bugs };
} catch (ex) {
- dupes._currentSearchQuery = '';
+ console.error(ex);
+ this.currentSearchQuery = '';
data = { error: true };
}
- document.getElementById('advanced').classList.remove('hidden');
- document.getElementById('dupes_continue_button_top').classList.remove('hidden');
- document.getElementById('dupes_continue_button_top').disabled = false;
- document.getElementById('dupes_continue_button_bottom').disabled = false;
- dupes._dataTable.update(data);
- } catch(err) {
- if (console)
- console.error(err.message);
- }
- },
-
- getSummary: function() {
- var summary = this._elSummary.value.trim();
- // work around chrome bug
- if (summary == dupes._elSummary.getAttribute('placeholder')) {
- return '';
- } else {
- return summary;
+ this.$continueButton.disabled = false;
+ this.dataTable.update(data);
+ } catch (err) {
+ console.error(err.message);
}
}
-};
-// bug form step
+ /**
+ * Get the current summary value.
+ */
+ static get summary() {
+ return this.$summary.value.trim();
+ }
+}
+
+/**
+ * Bug entry form page.
+ */
+class GuidedBugEntryFormPage {
+ /**
+ * Reference to the bug form element.
+ * @type {HTMLFormElement}
+ */
+ static $form = null;
+
+ /**
+ * Reference to the submit button element.
+ * @type {HTMLInputElement}
+ */
+ static $submitButton = null;
+
+ /**
+ * Reference to the short description input element.
+ * @type {HTMLInputElement}
+ */
+ static $shortDescInput = null;
+
+ /**
+ * Reference to the hidden component input element.
+ * @type {HTMLInputElement}
+ */
+ static $component = null;
+
+ /**
+ * Reference to the component select element.
+ * @type {HTMLSelectElement}
+ */
+ static $componentSelect = null;
+
+ /**
+ * Reference to the component description element.
+ * @type {HTMLDivElement}
+ */
+ static $componentDesc = null;
+
+ /**
+ * Reference to the hidden version input element.
+ * @type {HTMLInputElement}
+ */
+ static $version = null;
+
+ /**
+ * Reference to the version select element.
+ * @type {HTMLSelectElement}
+ */
+ static $versionSelect = null;
+
+ /**
+ * Reference to the version selection section element.
+ * @type {HTMLElement}
+ */
+ static $versionSection = null;
+
+ /**
+ * Reference to the element the attachment selector is rendered into.
+ * @type {HTMLElement}
+ */
+ static $attPlaceholder = null;
+
+ /**
+ * Reference to the attachment description section element.
+ * @type {HTMLElement}
+ */
+ static $attDescSection = null;
+
+ /**
+ * Reference to the attachment description input element.
+ * @type {HTMLInputElement}
+ */
+ static $attDescription = null;
+
+ /**
+ * Reference to the hidden attachment content type input element.
+ * @type {HTMLInputElement}
+ */
+ static $attMimeType = null;
+
+ /**
+ * Reference to the hidden attachment patch flag input element.
+ * @type {HTMLInputElement}
+ */
+ static $attIsPatch = null;
+
+ /**
+ * Reference to the currently visible help panel.
+ * @type {HTMLElement}
+ */
+ static $visibleHelpPanel = null;
+
+ /**
+ * The attachment selector, created once the form page is first shown.
+ * @type {Bugzilla.AttachmentSelector | undefined}
+ */
+ static attachmentSelector;
+
+ /**
+ * Whether the user has edited the attachment description themselves, in which case it’s no
+ * longer updated automatically.
+ * @type {boolean}
+ */
+ static attDescOverridden = false;
+
+ /**
+ * List of elements that are conditionally displayed.
+ * @type {{ check: () => boolean, id: string }[]}
+ */
+ static conditionalDetails = [
+ {
+ check: () => GuidedBugEntryProductPage.productName === 'Firefox',
+ id: 'firefox-android-row',
+ },
+ ];
+
+ /**
+ * Initialization callback.
+ */
+ static onInit() {
+ this.$form = document.querySelector('#bug-form');
+ this.$submitButton = document.querySelector('#submit-button');
+ this.$shortDescInput = document.querySelector('#short-desc');
+ this.$component = document.querySelector('#component');
+ this.$componentSelect = document.querySelector('#component-select');
+ this.$componentDesc = document.querySelector('#component-description');
+ this.$version = document.querySelector('#version');
+ this.$versionSelect = document.querySelector('#version-select');
+ this.$versionSection = document.querySelector('#version-section');
+ this.$attPlaceholder = document.querySelector('#att-placeholder');
+ this.$attDescSection = document.querySelector('#att-desc-section');
+ this.$attDescription = document.querySelector('#att-description');
+ this.$attMimeType = document.querySelector('[name="contenttypeentry"]');
+ this.$attIsPatch = document.querySelector('[name="ispatch"]');
+
+ document.querySelector('#user_agent').value = navigator.userAgent;
+
+ this.$shortDescInput.addEventListener('blur', () => {
+ GuidedBugEntryOtherDupesPage.$summary.value = this.$shortDescInput.value;
+ GuidedBugEntry.setAdvancedLink();
+ });
+
+ this.$form.addEventListener('submit', async (event) => this.submitForm(event));
+
+ this.$versionSelect.addEventListener('change', (event) => {
+ this.onVersionChange(event.target.value);
+ });
+
+ this.$componentSelect.addEventListener('change', (event) => {
+ this.onComponentChange(event.target.value);
+ });
-var bugForm = {
- _visibleHelpPanel: null,
- _mandatoryFields: [],
- _conditionalDetails: [
- { check: function () { return product.getName() == 'Firefox'; }, id: 'firefox_for_android_row' }
- ],
+ this.$attDescription.addEventListener('change', () => {
+ this.attDescOverridden = true;
+ });
- onInit: function() {
- var user_agent = navigator.userAgent;
- document.getElementById('user_agent').value = navigator.userAgent;
- document.getElementById('short_desc').addEventListener('blur', () => {
- document.getElementById('dupes_summary').value = document.getElementById('short_desc').value;
- guided.setAdvancedLink();
+ const useMarkdown = BUGZILLA.param.use_markdown;
+
+ document.querySelectorAll('#bug-form textarea.description').forEach(($textarea) => {
+ new Bugzilla.CommentEditor({ $textarea, useMarkdown, showTips: false }).render();
});
- },
+ }
- initHelp: function() {
- document.querySelectorAll('.help_icon').forEach(($icon) => {
+ /**
+ * Show help tooltip.
+ * @param {HTMLElement} $tooltip Tooltip element.
+ * @param {HTMLElement} $icon Help icon element.
+ */
+ static showHelp($tooltip, $icon) {
+ if (this.$visibleHelpPanel) {
+ this.$visibleHelpPanel.hidden = true;
+ }
+
+ const { top, left } = $icon.getBoundingClientRect();
+
+ $tooltip.style.inset = `${top + 24}px auto auto ${left - 320}px`;
+ $tooltip.hidden = false;
+ this.$visibleHelpPanel = $tooltip;
+ }
+
+ /**
+ * Hide help tooltip.
+ * @param {HTMLElement} $tooltip Tooltip element.
+ */
+ static hideHelp($tooltip) {
+ $tooltip.hidden = true;
+ this.$visibleHelpPanel = null;
+ }
+
+ /**
+ * Initialize help tooltips.
+ */
+ static initHelp() {
+ document.querySelectorAll('.help-trigger').forEach(($icon) => {
const $tooltip = document.getElementById($icon.getAttribute('aria-describedby'));
$icon.addEventListener('mouseover', () => {
- if (this._visibleHelpPanel) {
- this._visibleHelpPanel.hidden = true;
- }
+ this.showHelp($tooltip, $icon);
+ });
- const { top, left } = $icon.getBoundingClientRect();
+ $icon.addEventListener('mouseout', () => {
+ this.hideHelp($tooltip);
+ });
- $tooltip.style.inset = `${top}px auto auto ${left + 24}px`;
- $tooltip.hidden = false;
- this._visibleHelpPanel = $tooltip;
+ $icon.addEventListener('focus', () => {
+ this.showHelp($tooltip, $icon);
});
- $icon.addEventListener('mouseout', () => {
- $tooltip.hidden = true;
- this._visibleHelpPanel = null;
+ $icon.addEventListener('blur', () => {
+ this.hideHelp($tooltip);
+ });
+
+ $icon.addEventListener('click', (event) => {
+ event.preventDefault();
});
});
- },
+ }
- onShow: function() {
+ /**
+ * Show callback.
+ */
+ static onShow() {
// check for a forced format
- var productName = product.getName();
- var visibleCount = 0;
- if (products[productName] && products[productName].format) {
- document.getElementById('advanced').classList.add('hidden');
- document.location.href = `${BUGZILLA.config.basepath}enter_bug.cgi?` +
- `format=${encodeURIComponent(products[productName].format)}` +
- `&product=${encodeURIComponent(productName)}` +
- `&short_desc=${encodeURIComponent(dupes.getSummary())}`;
- guided.updateStep = false;
- return;
+ const productName = GuidedBugEntryProductPage.productName;
+ const productSummary = GuidedBugEntryOtherDupesPage.summary;
+ const { format } = products[productName] ?? {};
+
+ if (format) {
+ const params = new URLSearchParams({
+ format,
+ product: productName,
+ short_desc: productSummary,
+ });
+
+ document.location.href = `${BUGZILLA.config.basepath}enter_bug.cgi?${params}`;
+ GuidedBugEntry.updateStep = false;
+
+ return;
}
- document.getElementById('advanced').classList.remove('hidden');
+
// default the summary to the dupes query
- document.getElementById('short_desc').value = dupes.getSummary();
+ this.$shortDescInput.value = productSummary;
this.resetSubmitButton();
- if (document.getElementById('component_select').length == 0)
+
+ if (this.$componentSelect.length === 0) {
this.onProductUpdated();
- this.onFileChange();
- this._mandatoryFields.forEach((id) => {
- document.getElementById(id).classList.remove('missing');
+ }
+
+ this.attachmentSelector ??= new Bugzilla.AttachmentSelector({
+ $placeholder: this.$attPlaceholder,
+ eventHandlers: {
+ AttachmentProcessed: (event) => this.onAttachmentProcessed(event),
+ AttachmentTextUpdated: (event) => this.onAttachmentTextUpdated(event),
+ },
});
- this._conditionalDetails.forEach(function (cond) {
- if (cond.check()) {
- visibleCount++;
- document.getElementById(cond.id).classList.remove('hidden');
- }
- else {
- document.getElementById(cond.id).classList.add('hidden');
- }
+ this.requiredFields.forEach(($field) => {
+ GuidedBugEntry.setFieldError($field);
+ });
+
+ this.conditionalDetails.forEach((cond) => {
+ document.getElementById(cond.id).hidden = !cond.check();
});
- if (visibleCount > 0) {
- document.getElementById('details').classList.remove('hidden');
- document.getElementById('submitTR').classList.remove('even');
+
+ window.requestAnimationFrame(() => {
+ this.$shortDescInput.focus();
+ this.$shortDescInput.select();
+ });
+
+ GuidedBugEntry.updateSteppers('form');
+ }
+
+ /**
+ * Called whenever a file is processed by `AttachmentSelector`. Update the Description, Content
+ * Type, Patch checkbox, etc. based on the file properties and content.
+ * @param {object} params An object with the following properties:
+ * @param {File} params.file A processed `File` object.
+ * @param {string} params.type A MIME type to be selected in the Content Type field.
+ * @param {boolean} params.isPatch `true` if the file is detected as a patch, `false` otherwise.
+ */
+ static onAttachmentProcessed({ file, type, isPatch }) {
+ this.$attDescSection.hidden = false;
+ this.$attDescription.value = file.name;
+ this.$attDescription.disabled = false;
+ this.$attDescription.setAttribute('aria-required', true);
+ this.$attMimeType.value = type;
+ this.$attIsPatch.value = isPatch ? 'on' : '';
+ }
+
+ /**
+ * Called whenever the attachment text is updated. Update the Content Type, Patch checkbox, etc.
+ * based on the new content.
+ * @param {object} params An object with the following properties:
+ * @param {string} params.text The new text content.
+ * @param {boolean} params.hasText `true` if the textarea has non-empty content, `false`
+ * otherwise.
+ * @param {boolean} params.isPatch `true` if the content is detected as a patch, `false`
+ * otherwise.
+ * @param {boolean} params.isGhpr `true` if the content is detected as a GitHub Pull Request link,
+ * `false` otherwise.
+ */
+ static onAttachmentTextUpdated({ text, hasText, isPatch, isGhpr }) {
+ if (!this.attDescOverridden) {
+ this.$attDescription.value = isPatch ? 'patch' : isGhpr ? 'GitHub Pull Request' : '';
}
- else {
- document.getElementById('details').classList.add('hidden');
- document.getElementById('submitTR').classList.add('even');
+
+ this.$attDescSection.hidden = !hasText;
+ this.$attDescription.disabled = !hasText;
+ this.$attDescription.setAttribute('aria-required', hasText);
+ this.$attMimeType.value = isGhpr ? 'text/x-github-pull-request' : 'text/plain';
+ this.$attIsPatch.value = isPatch ? 'on' : '';
+
+ if (!hasText) {
+ this.attDescOverridden = false;
}
- },
+ }
+
+ /**
+ * Reset the submit button state.
+ */
+ static resetSubmitButton() {
+ this.$submitButton.disabled = false;
+ this.$submitButton.value = 'Submit Bug';
+ }
+
+ /**
+ * Escape special characters for use in a regex.
+ * @param {string} value Input string.
+ * @returns {string} Escaped string.
+ */
+ static quoteMeta(value) {
+ return value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
+ }
- resetSubmitButton: function() {
- document.getElementById('submit').disabled = false;
- document.getElementById('submit').value = 'Submit Bug';
- },
+ /**
+ * Clear the currently selected component. Called when the user picks a product, so the component
+ * chosen for the previously selected product isn’t silently reused.
+ */
+ static resetComponent() {
+ this.$component.value = '';
+ this.$componentDesc.innerHTML = '';
+ this.$componentDesc.hidden = true;
+ }
- onProductUpdated: function() {
- var productName = product.getName();
+ /**
+ * Product updated callback.
+ */
+ static onProductUpdated() {
+ const productName = GuidedBugEntryProductPage.productName;
// init
- var elComponents = document.getElementById('component_select');
- document.getElementById('component_description').classList.add('hidden');
- elComponents.options.length = 0;
+ const $components = this.$componentSelect;
+ const $versions = this.$versionSelect;
- var elVersions = document.getElementById('version_select');
- elVersions.length = 0;
+ this.$componentDesc.hidden = true;
+ $components.options.length = 0;
+ $versions.options.length = 0;
// product not loaded yet, bail out
- if (!product.details) {
- document.getElementById('versionTD').classList.add('hidden');
- document.getElementById('productTD').colSpan = 2;
- document.getElementById('submit').disabled = true;
+ if (!GuidedBugEntryProductPage.details) {
+ this.$submitButton.disabled = true;
+
return;
}
- document.getElementById('submit').disabled = false;
+
+ this.$submitButton.disabled = false;
+
+ const { componentFilter, defaultComponent } = products[productName] ?? {};
+ const fixedComponent = GuidedBugEntryProductPage.updateComponentSection(productName);
// filter components
- if (products[productName] && products[productName].componentFilter) {
- product.details.components = products[productName].componentFilter(product.details.components);
+ if (componentFilter) {
+ GuidedBugEntryProductPage.details.components = componentFilter(
+ GuidedBugEntryProductPage.details.components,
+ );
}
// build components
-
- var elComponent = document.getElementById('component');
- if (products[productName] && products[productName].noComponentSelection || guided.webdev) {
- elComponent.value = products[productName].defaultComponent;
- bugForm._mandatoryFields = [ 'short_desc', 'version_select' ];
+ if (fixedComponent) {
+ this.$component.value = fixedComponent;
+ this.$componentSelect.removeAttribute('aria-required');
} else {
- bugForm._mandatoryFields = [ 'short_desc', 'component_select', 'version_select' ];
+ this.$componentSelect.setAttribute('aria-required', 'true');
+
+ const { preselectedComponent } = GuidedBugEntryProductPage;
// check for the default component
- var defaultRegex;
- if (product.getPreselectedComponent()) {
- defaultRegex = new RegExp('^' + quoteMeta(product.getPreselectedComponent()) + '$', 'i');
- } else if(products[productName] && products[productName].defaultComponent) {
- defaultRegex = new RegExp('^' + quoteMeta(products[productName].defaultComponent) + '$', 'i');
- } else {
- defaultRegex = new RegExp('General', 'i');
+ const defaultRegex = preselectedComponent
+ ? new RegExp(`^${this.quoteMeta(preselectedComponent)}$`, 'i')
+ : defaultComponent
+ ? new RegExp(`^${this.quoteMeta(defaultComponent)}$`, 'i')
+ : new RegExp('General', 'i');
+
+ const { components } = GuidedBugEntryProductPage.details;
+ const component = components.find((c) => c.is_active && defaultRegex.test(c.name));
+ const preselectedComponentName = component?.name ?? null;
+
+ // if there isn’t a default component, default to blank
+ if (!preselectedComponentName) {
+ $components.options.add(new Option('', ''));
}
- var preselectedComponent = false;
- var i, n;
- var component;
- for (i = 0, n = product.details.components.length; i < n; i++) {
- component = product.details.components[i];
- if (component.is_active == '1') {
- if (defaultRegex.test(component.name)) {
- preselectedComponent = component.name;
- break;
- }
+ // build component select
+ components.forEach((c) => {
+ if (c.is_active) {
+ $components.options.add(new Option(c.name, c.name));
}
- }
+ });
- // if there isn't a default component, default to blank
- if (!preselectedComponent) {
- elComponents.options[elComponents.options.length] = new Option('', '');
- }
+ const validComponent = [...$components.options].some(
+ (o) => o.value === this.$component.value,
+ );
- // build component select
- for (i = 0, n = product.details.components.length; i < n; i++) {
- component = product.details.components[i];
- if (component.is_active == '1') {
- elComponents.options[elComponents.options.length] =
- new Option(component.name, component.name);
- }
+ if (!validComponent) {
+ this.$component.value = '';
}
- var validComponent = false;
- for (i = 0, n = elComponents.options.length; i < n && !validComponent; i++) {
- if (elComponents.options[i].value == elComponent.value)
- validComponent = true;
- }
- if (!validComponent)
- elComponent.value = '';
- if (elComponent.value == '' && preselectedComponent)
- elComponent.value = preselectedComponent;
- if (elComponent.value != '') {
- elComponents.value = elComponent.value;
- this.onComponentChange(elComponent.value);
+ if (this.$component.value === '' && preselectedComponentName) {
+ this.$component.value = preselectedComponentName;
}
+ if (this.$component.value !== '') {
+ $components.value = this.$component.value;
+ this.onComponentChange(this.$component.value);
+ }
}
// build versions
- var defaultVersion = '';
- var currentVersion = document.getElementById('version').value;
- for (i = 0, n = product.details.versions.length; i < n; i++) {
- var version = product.details.versions[i];
- if (version.is_active == '1') {
- elVersions.options[elVersions.options.length] =
- new Option(version.name, version.name);
- if (currentVersion == version.name)
- defaultVersion = version.name;
+ const currentVersion = this.$version.value;
+ let defaultVersion = '';
+
+ GuidedBugEntryProductPage.details.versions.forEach(({ is_active, name }) => {
+ if (is_active) {
+ $versions.options.add(new Option(name, name));
+
+ if (currentVersion === name) {
+ defaultVersion = name;
+ }
}
- }
+ });
if (!defaultVersion) {
// try to detect version on a per-product basis
- if (products[productName] && products[productName].version) {
- var detectedVersion = products[productName].version();
- var options = elVersions.options;
- for (i = 0, n = options.length; i < n; i++) {
- if (options[i].value == detectedVersion) {
- defaultVersion = detectedVersion;
- break;
- }
+ if (products[productName]?.version) {
+ const detectedVersion = products[productName].version();
+
+ if ([...$versions.options].some((o) => o.value === detectedVersion)) {
+ defaultVersion = detectedVersion;
}
}
}
- if (elVersions.length > 1) {
- // more than one version, show select
- document.getElementById('productTD').colSpan = 1;
- document.getElementById('versionTD').classList.remove('hidden');
+ // If there’s only one version, we don’t need to ask the user
+ const singleVersion = $versions.length <= 1;
- } else {
- // if there's only one version, we don't need to ask the user
- document.getElementById('versionTD').classList.add('hidden');
- document.getElementById('productTD').colSpan = 2;
- defaultVersion = elVersions.options[0].value;
+ this.$versionSection.hidden = singleVersion;
+
+ if (singleVersion) {
+ defaultVersion = $versions.options[0]?.value;
}
if (defaultVersion) {
- elVersions.value = defaultVersion;
-
+ $versions.value = defaultVersion;
+ } else if ([...$versions.options].some((o) => o.value === 'unspecified')) {
+ // Fallback to 'unspecified' if available
+ $versions.value = 'unspecified';
} else {
- // no default version, select an empty value to force a decision
- var opt = new Option('', '');
- try {
- // standards
- elVersions.add(opt, elVersions.options[0]);
- } catch(ex) {
- // IE only
- elVersions.add(opt, 0);
- }
- elVersions.value = '';
+ // No default version, select an empty value to force a decision
+ $versions.options.add(new Option('', ''), $versions.options[0]);
+ $versions.value = '';
}
- bugForm.onVersionChange(elVersions.value);
+
+ this.onVersionChange($versions.value);
// Set default Platform, OS and Security Group
// Skip if the default value is empty = auto-detect
- const { default_platform, default_op_sys, default_security_group } = product.details;
+ const { default_platform, default_op_sys, default_security_group } =
+ GuidedBugEntryProductPage.details;
if (default_platform) {
document.querySelector('#rep_platform').value = default_platform;
@@ -773,106 +1423,113 @@ var bugForm = {
if (default_security_group) {
document.querySelector('#groups').value = default_security_group;
}
- },
-
- onComponentChange: function(componentName) {
- // show the component description
- document.getElementById('component').value = componentName;
- var elComponentDesc = document.getElementById('component_description');
- elComponentDesc.innerHTML = '';
- for (var i = 0, n = product.details.components.length; i < n; i++) {
- var component = product.details.components[i];
- if (component.name == componentName) {
- elComponentDesc.innerHTML = component.description;
- break;
- }
- }
- elComponentDesc.classList.remove('hidden');
- },
-
- onVersionChange: function(version) {
- document.getElementById('version').value = version;
- },
-
- onFileChange: function() {
- // toggle ui enabled when a file is uploaded or cleared
- var elFile = document.getElementById('data');
- var elReset = document.getElementById('reset_data');
- var elDescription = document.getElementById('data_description');
- var filename = bugForm._getFilename();
- elReset.disabled = !filename;
- elDescription.value = filename || '';
- elDescription.disabled = !filename;
- document.getElementById('reset_data').classList.toggle('hidden', !filename);
- document.getElementById('data_description_tr').classList.toggle('hidden', !filename);
- },
-
- onFileClear: function() {
- document.getElementById('data').value = '';
- this.onFileChange();
- return false;
- },
-
- _getFilename: function() {
- var filename = document.getElementById('data').value;
- if (!filename)
- return '';
- filename = filename.replace(/^.+[\\\/]/, '');
- return filename;
- },
-
- _mandatoryMissing: function() {
- var result = new Array();
- for (var i = 0, n = this._mandatoryFields.length; i < n; i++ ) {
- var id = this._mandatoryFields[i];
- var el = document.getElementById(id);
- var value;
-
- if (el.type.toString() == "checkbox") {
- value = el.checked;
- } else {
- value = el.value.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
- el.value = value;
- }
+ }
- if (value == '') {
- document.getElementById(id).classList.add('missing');
- result.push(id);
- } else {
- document.getElementById(id).classList.remove('missing');
+ /**
+ * Component change handler. Sets the hidden component input and shows the description.
+ * @param {string} componentName Component name.
+ */
+ static onComponentChange(componentName) {
+ this.$component.value = componentName;
+
+ const component = GuidedBugEntryProductPage.details.components.find(
+ (c) => c.name === componentName,
+ );
+
+ this.$componentDesc.innerHTML = component?.description ?? '';
+ this.$componentDesc.hidden = false;
+ }
+
+ /**
+ * Version change handler. Sets the hidden version input.
+ * @param {string} version Version name.
+ */
+ static onVersionChange(version) {
+ this.$version.value = version;
+ }
+
+ /**
+ * Get all required fields in the form.
+ * @returns {HTMLElement[]} Array of required field elements.
+ */
+ static get requiredFields() {
+ // Only the field’s own `` decides whether it’s reachable. Don’t look at every
+ // ancestor: the comment editor hides its edit tabpanel while the Preview tab is selected, and
+ // the field inside it still has to be validated.
+ return [...this.$form.querySelectorAll('[aria-required="true"]')].filter(
+ ($field) => !$field.closest('section')?.hidden,
+ );
+ }
+
+ /**
+ * Check for missing required fields.
+ * @returns {string[]} Array of IDs of missing required fields.
+ */
+ static checkMissing() {
+ /** @type {string[]} */
+ const result = [];
+
+ this.requiredFields.forEach(($field) => {
+ const invalid =
+ $field.getAttribute('role') === 'radiogroup'
+ ? ![...$field.querySelectorAll('input')].some((r) => r.checked)
+ : !$field.value.trim();
+
+ if (!invalid) {
+ GuidedBugEntry.setFieldError($field);
+
+ return;
}
- }
- return result;
- },
- validate: function() {
+ const message = $field.matches('[role="radiogroup"], select')
+ ? 'Please select an option.'
+ : $field.matches('[data-allow-na]')
+ ? 'This field is required. Write “N/A” if not applicable.'
+ : 'This field is required.';
- // check mandatory fields
+ GuidedBugEntry.setFieldError($field, message);
+ result.push($field.id);
+ });
+
+ return result;
+ }
+
+ /**
+ * Validate the form before submission.
+ * @returns {boolean} Whether the form is valid for submission.
+ */
+ static validate() {
+ const missing = this.checkMissing();
- var missing = bugForm._mandatoryMissing();
if (missing.length) {
- var message = 'The following field' +
- (missing.length == 1 ? ' is' : 's are') + ' required:\n\n';
- for (var i = 0, n = missing.length; i < n; i++ ) {
- var id = missing[i];
- if (id == 'short_desc') message += ' Summary\n';
- if (id == 'component_select') message += ' Component\n';
- if (id == 'version_select') message += ' Version\n';
- }
- alert(message);
+ document.getElementById(missing[0])?.focus();
return false;
}
- if (document.getElementById('data').value && !document.getElementById('data_description').value)
- document.getElementById('data_description').value = bugForm._getFilename();
+ return true;
+ }
+
+ /**
+ * Submit the bug form.
+ */
+ static async submitForm(event) {
+ // The attachment selector can cancel the submission from its own `submit` listener, which runs
+ // after this one, so ask it first: the submit button must not be left disabled in that case
+ const attachmentValid = this.attachmentSelector?.validate(event) ?? true;
- document.getElementById('submit').disabled = true;
- document.getElementById('submit').value = 'Submitting Bug...';
+ if (!this.validate() || !attachmentValid) {
+ event.preventDefault();
- return true;
- },
-};
+ return false;
+ }
-function quoteMeta(value) {
- return value.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
+ this.$submitButton.disabled = true;
+ this.$submitButton.value = 'Submitting Bug...';
+
+ return true;
+ }
}
+
+window.addEventListener('DOMContentLoaded', () => {
+ GuidedBugEntry.init();
+});
diff --git a/extensions/GuidedBugEntry/web/js/products.js b/extensions/GuidedBugEntry/web/js/products.js
index 2f1ccb8b7e..c3da57655e 100644
--- a/extensions/GuidedBugEntry/web/js/products.js
+++ b/extensions/GuidedBugEntry/web/js/products.js
@@ -9,128 +9,100 @@
*
* related: array of product names which will also be searched for duplicates
* version: function which returns a version (e.g. detected from UserAgent)
- * support: string which is displayed at the top of the duplicates page
- * secgroup: the group to place confidential bugs into
* defaultComponent: the default component to select. Defaults to 'General'
* noComponentSelection: when true, the default component will always be
* used. Defaults to 'false';
- * detectPlatform: when true the platform and op_sys will be set from the
- * browser's user agent. when false, these will be set to All
+ * l10n: allow selecting Localization product
+ * support: string which is displayed at the top of the duplicates page
*/
-var products = {
- "addons.mozilla.org": {
- l10n: true
+const products = {
+ 'addons.mozilla.org': {
+ l10n: true,
},
- "Firefox": {
- related: [ "Core", "Toolkit" ],
- version: function() {
- var re = /Firefox\/(\d+)\.(\d+)/i;
- var match = re.exec(navigator.userAgent);
- if (match) {
- var maj = match[1];
- var min = match[2];
- if (maj * 1 >= 80) {
- return "Firefox " + maj;
- } else if (maj * 1 >= 5) {
- return maj + " Branch";
+ Firefox: {
+ related: ['Core', 'Toolkit'],
+ version: () => {
+ const re = /Firefox\/(?\d+)\.(?\d+)/i;
+ const groups = navigator.userAgent.match(re)?.groups;
+ if (groups) {
+ const { major, minor } = groups;
+ if (major * 1 >= 80) {
+ return 'Firefox ' + major;
+ } else if (major * 1 >= 5) {
+ return `${major} Branch`;
} else {
- return maj + "." + min + " Branch";
+ return `${major}.${minor} Branch`;
}
- } else {
- return false;
}
+ return false;
},
- defaultComponent: "Untriaged",
+ defaultComponent: 'Untriaged',
noComponentSelection: true,
- detectPlatform: true,
l10n: true,
support:
'If you are new to Firefox or Bugzilla, please consider checking ' +
- '' +
- `` +
- ' Firefox Help instead of creating a bug.'
+ '' +
+ 'Firefox Support instead of creating a bug.',
},
- "Firefox for Android": {
- related: [ "Core", "Toolkit" ],
- detectPlatform: true,
+ 'Firefox for Android': {
+ related: ['Core', 'Toolkit'],
l10n: true,
support:
'If you are new to Firefox or Bugzilla, please consider checking ' +
- '' +
- `` +
- ' Firefox Help instead of creating a bug.'
+ '' +
+ 'Firefox for Android Support instead of creating a bug.',
},
- "SeaMonkey": {
- related: [ "Core", "Toolkit", "MailNews Core" ],
- detectPlatform: true,
+ Focus: {
l10n: true,
- version: function() {
- var re = /SeaMonkey\/(\d+)\.(\d+)/i;
- var match = re.exec(navigator.userAgent);
- if (match) {
- var maj = match[1];
- var min = match[2];
- return "SeaMonkey " + maj + "." + min + " Branch";
- } else {
- return false;
- }
- }
},
- "Calendar": {
- l10n: true
- },
-
- "Camino": {
- related: [ "Core", "Toolkit" ],
- detectPlatform: true
+ SeaMonkey: {
+ related: ['Core', 'Toolkit', 'MailNews Core'],
+ l10n: true,
+ version: () => {
+ const re = /SeaMonkey\/(?\d+)\.(?\d+)/i;
+ const groups = navigator.userAgent.match(re)?.groups;
+ if (groups) {
+ const { major, minor } = groups;
+ return `SeaMonkey ${major}.${minor} Branch`;
+ }
+ return false;
+ },
},
- "Core": {
- detectPlatform: true
+ Calendar: {
+ l10n: true,
},
- "Thunderbird": {
- related: [ "Core", "Toolkit", "MailNews Core" ],
- detectPlatform: true,
+ Thunderbird: {
+ related: ['Core', 'Toolkit', 'MailNews Core'],
l10n: true,
- defaultComponent: "Untriaged",
- componentFilter : function(components) {
- var index = -1;
- for (var i = 0, l = components.length; i < l; i++) {
- if (components[i].name == 'General') {
- index = i;
- break;
- }
- }
- if (index != -1) {
- components.splice(index, 1);
- }
- return components;
+ defaultComponent: 'Untriaged',
+ componentFilter: (components) => {
+ const index = components.findIndex((c) => c.name === 'General');
+ if (index !== -1) {
+ components.splice(index, 1);
+ }
+ return components;
},
support:
'If you are new to Thunderbird or Bugzilla, please consider checking ' +
- '' +
- `` +
- ' Thunderbird Help instead of creating a bug.'
- },
-
- "Penelope": {
- related: [ "Core", "Toolkit", "MailNews Core" ]
+ '' +
+ 'Thunderbird Support instead of creating a bug.',
},
- "Bugzilla": {
+ Bugzilla: {
support:
- 'Please use our test server to file "test bugs".'
+ 'Please use our test server to file "test bugs".',
},
- "bugzilla.mozilla.org": {
- related: [ "Bugzilla" ],
+ 'bugzilla.mozilla.org': {
+ related: ['Bugzilla'],
support:
- 'Please use our test server to file "test bugs".'
- }
+ 'Please use our test server to file "test bugs".',
+ },
};
diff --git a/extensions/GuidedBugEntry/web/style/guided.css b/extensions/GuidedBugEntry/web/style/guided.css
index 1c615fd805..da53337a7e 100644
--- a/extensions/GuidedBugEntry/web/style/guided.css
+++ b/extensions/GuidedBugEntry/web/style/guided.css
@@ -9,54 +9,209 @@
* global
*/
-#page_title h2 {
- margin-bottom: 0;
+#bugzilla-body:has(#guided) {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100dvh - var(--global-header-height));
}
-#page_title h3 {
- margin-top: 0;
+#main-inner:has(#guided) {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+#guided {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+}
+
+#guided > * {
+ width: 100%;
+}
+
+#guided :is([role="region"], [role="search"]) {
+ border: 1px solid var(--primary-region-border-color);
+ border-radius: var(--primary-region-border-radius);
+ padding: 16px;
+ background-color: var(--primary-region-background-color);
+ box-shadow: var(--primary-region-box-shadow);
+}
+
+#guided button.iconic {
+ border: none !important;
+ padding: 0 !important;
+ width: 24px;
+ height: 24px;
+ color: var(--secondary-label-color) !important;
+ background-color: transparent !important;
+ line-height: 1 !important;
+}
+
+#guided .icon {
+ font-size: var(--icon-size-medium);
+ line-height: 1;
+ font-family: var(--icon-font-family);
+ font-variation-settings:
+ "FILL" 0,
+ "wght" 300,
+ "GRAD" 0,
+ "opsz" 24;
+}
+
+#guided .messages {
+ margin: 0;
+ border: 1px solid var(--control-border-color);
+ border-radius: 8px;
+ padding: 12px;
+ background-color: var(--control-background-color);
}
-.hidden {
+#guided .messages:has(#product-support[hidden]):has(#l10n-message[hidden]) {
display: none;
}
+#page-header {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ box-sizing: border-box;
+ margin: 0 auto;
+ border-bottom: 1px solid var(--control-border-color);
+ padding: 0 0 16px;
+ max-width: 1200px;
+}
+
+#page-header h1 {
+ margin: 0;
+ text-align: center;
+}
+
+#page-header h3 {
+ margin-top: 0;
+}
+
+#stepper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 16px;
+ margin: 0;
+ padding: 0;
+}
+
+#stepper div {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ width: 120px;
+ color: var(--secondary-label-color);
+}
+
+#stepper div:not(:last-child)::before {
+ position: absolute;
+ inset: 13px 0 0 calc(50% + 20px);
+ border-radius: 8px;
+ width: calc(100% - 24px);
+ height: 6px;
+ background-color: var(--control-border-color);
+ content: "";
+}
+
+#stepper span {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ background-color: var(--control-border-color);
+ font-size: 20px;
+}
+
+#stepper div[aria-current="step"] {
+ color: var(--primary-label-color);
+ font-weight: 500;
+}
+
+#stepper div:is(.done, [aria-current="step"]) span {
+ color: var(--inverted-label-color);
+ background-color: var(--selected-tab-border-color);
+}
+
+#stepper div.done::before {
+ background-color: var(--selected-tab-border-color);
+}
+
#steps {
- margin-left: auto;
- margin-right: auto;
+ flex: auto;
+ margin-inline: auto;
max-width: 1200px;
}
-.product-icon {
- float: left;
- margin: 8px 15px 8px 0;
- width: 64px;
- height: 64px;
+#steps h2 {
+ margin-block: 0 16px;
+ font-size: var(--font-size-h3);
+ font-weight: normal;
+ text-align: center;
}
-#product_step {
- margin-left: auto;
- margin-right: auto;
+#page-footer {
+ flex: none;
+ margin-top: 50px;
+ text-align: center;
+}
+
+#advanced-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/**
+ * products/webdev step
+ */
+
+:is(#product-step, #webdev-step) {
+ margin-inline: auto;
max-width: 1200px;
}
-ul.product-list {
- display: flex;
- flex-wrap: wrap;
- margin: 0 -10px 20px;
+:is(#product-step, #webdev-step) > section {
+ margin-block: 32px;
+}
+
+.product-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 16px;
padding: 0;
list-style: outside none none;
}
-ul.product-list > li {
- display: inline-block;
+.product-list svg,
+.product-icon {
+ width: 64px;
+ height: 64px;
+}
+
+.product-list li {
+ display: flex;
+ align-items: center;
+ gap: 24px;
position: relative;
z-index: 1;
- margin: 4px;
border: 1px solid var(--primary-region-border-color);
border-radius: var(--primary-region-border-radius);
- padding: 1px;
- width: 300px;
+ padding: 24px;
background-color: var(--primary-region-background-color);
background-clip: padding-box;
box-shadow: var(--primary-region-box-shadow);
@@ -65,147 +220,218 @@ ul.product-list > li {
cursor: pointer;
}
-ul.product-list > li > .product-item {
- display: block;
- padding: 10px;
+.product-list li:hover,
+.product-list li:focus {
+ background-color: var(--primary-region-header-background-color);
}
-#steps a img {
- border: none;
+.product-list h3 {
+ margin: 0;
+ line-height: var(--line-height-default);
}
-#advanced {
- margin-top: 50px;
+.product-list p {
+ margin-block: 4px 0;
+}
+
+/**
+ * other-products step
+ */
+
+#other-products {
+ margin-top: 32px;
+}
+
+#other-products .classification :is(th, td) {
+ font-size: var(--font-size-x-large);
+ font-weight: 500;
+}
+
+#other-products :is(th, td) {
+ padding: 4px 8px;
+ line-height: var(--line-height-comfortable);
+ vertical-align: top;
+}
+
+#other-products th {
text-align: right;
- margin-left: auto;
}
-#advanced img {
- vertical-align: middle;
+#prod-comp-search-main {
+ margin: 32px auto;
+ width: 400px;
}
-#advanced a {
- cursor: pointer;
+#prod-comp-search-main .pcs-header {
+ margin-bottom: 4px;
}
/**
- * products and other_products step
+ * duplicates step
*/
-#prod_comp_search_main {
- width: 400px;
+#dupe-header {
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ margin-block: 16px;
}
-#prod_comp_search_label {
- margin-bottom: 1px;
+#dupe-header .product,
+#dupe-header .messages {
+ flex: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 200px;
+ text-align: left;
}
-#products {
- width: 600px;
+#dupe-header .messages {
+ list-style: none;
}
-#products td {
- padding: 5px;
- padding-bottom: 10px;
+#dupe-header .messages li:not([hidden]) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
}
-#products h2 {
- margin-bottom: 0;
+#dupe-header .product .name {
+ font-size: var(--font-size-h3);
}
-#products p {
- margin-top: 0;
+#dupe-finder {
+ margin-block: 16px;
}
-.product_img {
- width: 64px;
+#dupe-form {
+ text-align: center;
}
-#other_products .classification {
- font-weight: bold;
+#dupe-form .input {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 8px;
+ margin: 0 auto;
+ max-width: 600px;
+ text-align: left;
}
-#other_products .classification th {
- font-size: var(--font-size-x-large);
+#dupe-form .input > :first-child {
+ flex: auto;
+ width: 100%;
}
-/**
- * duplicates step
- */
+#dupe-summary {
+ width: 100%;
+}
-#dupes_summary {
- width: 500px;
+#dupe-search {
+ white-space: nowrap;
}
-#dupes_list {
- margin-top: 1em;
- margin-bottom: 1em;
+#dupe-list {
+ margin-block: 16px;
}
-#product_support {
- border: 1px solid var(--primary-region-border-color);
+#dupe-list .message,
+#dupe-continue {
+ text-align: center;
}
/**
* bug form step
*/
-#bugForm {
- border: 1px solid var(--primary-region-border-color);
- border-radius: var(--primary-region-border-radius);
- width: 600px;
- background-color: var(--primary-region-background-color);
- box-shadow: var(--primary-region-box-shadow);
+#bug-form-inner {
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
}
-#bugForm th,
-#bugForm td {
- padding: 8px 16px;
+#bug-form .messages {
+ margin-block: 16px;
+ padding-left: 32px;
}
-#bugForm .label {
- text-align: left;
- font-weight: bold;
- white-space: nowrap;
+
+#bug-form .row {
+ display: flex;
+ gap: 32px;
+}
+
+#bug-form .col {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
}
-#bugzilla-body #bugForm th {
- vertical-align: middle;
+#bug-form .col.main {
+ flex: auto;
}
-#bugForm .textInput {
- width: 450px;
+#bug-form .col.side {
+ flex: none;
+ width: 320px;
+}
+
+#bug-form .col.side > section:not(:last-child) {
+ border-bottom: 1px solid var(--control-border-color);
+ padding-bottom: 16px;
+}
+
+#bug-form .col > section > header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 8px;
}
-#bugForm textarea {
- width: 590px;
+#bug-form .col > section > header h3 {
+ margin: 0;
+ font-size: var(--font-size-x-large);
+ font-weight: 500;
}
-#bugForm .mandatory_mark {
+#bug-form .col > section > header h3 label span {
+ color: var(--tertiary-label-color);
+}
+
+#bug-form input[type="text"],
+#bug-form .text-editor,
+#version-select,
+#component-select {
+ width: 100%;
+}
+
+#bug-form .required-star {
color: var(--required-label-color);
- font-size: var(--font-size-small);
}
-#versionTD {
- text-align: right;
- white-space: nowrap;
+#bug-form :is(.checkbox, .radio) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
-#component_select {
- width: 450px;
+#bug-form :is(.checkbox, .radio)[hidden] {
+ display: none;
}
-#component_description {
- padding: 5px;
+#bug-form .checkbox {
+ margin-block: 8px;
}
-#bugForm .missing {
- border: 1px solid var(--error-message-foreground-color);
- box-shadow: 0 0 4px var(--error-message-foreground-color);
+#bug-form .radio {
+ margin-block: 4px;
}
-#submitTD {
- text-align: right;
+#bug-form :is(.checkbox, .radio) input {
+ flex: none;
}
.help {
@@ -213,22 +439,89 @@ ul.product-list > li > .product-item {
z-index: 9999;
border: 1px solid var(--menu-border-color);
border-radius: var(--menu-border-radius);
- padding: 8px;
+ padding: 12px;
width: 320px;
background: var(--menu-background-color);
box-shadow: var(--menu-box-shadow);
cursor: default;
+ font-weight: normal;
+ white-space: normal;
+}
+
+.help p {
+ margin-block: 8px 0;
}
-.help-bad {
- color: var(--error-message-foreground-color);
+.help p:first-child {
+ margin-top: 0;
+}
+
+#product-display {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+#component-description {
+ padding: 5px;
}
-.help-good {
- color: var(--positive-message-foreground-color);
+#submit-row {
+ text-align: right;
}
-#guided-markdown-help {
- float: right;
- font-size: var(--font-size-small);
+@media (width < 1024px) {
+ .product-list {
+ gap: 8px;
+ }
+
+ .product-list svg,
+ .product-icon {
+ width: 48px;
+ height: 48px;
+ }
+
+ .product-list li {
+ gap: 16px;
+ padding: 16px;
+ }
+
+ #bug-form .row {
+ flex-direction: column;
+ }
+
+ #bug-form .col.side {
+ width: 100%;
+ }
+}
+
+@media (width < 768px) {
+ #guided :is([role="region"], [role="search"]) {
+ padding: 16px;
+ }
+
+ #stepper {
+ display: none;
+ }
+
+ #dupe-form .input {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ #dupe-header {
+ flex-direction: column;
+ align-items: normal;
+ gap: 16px;
+ }
+
+ #dupe-header .product {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ #dupe-header .product .name {
+ white-space: normal;
+ }
}
diff --git a/js/attachment.js b/js/attachment.js
index 19935e41a8..c6112a2580 100644
--- a/js/attachment.js
+++ b/js/attachment.js
@@ -263,16 +263,16 @@ function handleWantsAttachment(wants_attachment) {
else {
showElementById('attachment_false');
hideElementById('attachment_true');
- bz_attachment_form.reset_fields();
+ bzAttachmentForm.resetFields();
}
- bz_attachment_form.update_requirements(wants_attachment);
+ bzAttachmentForm.updateRequirements(wants_attachment);
}
/**
* Expose an `AttachmentForm` instance on global.
*/
-var bz_attachment_form;
+var bzAttachmentForm;
/**
* Reference or define the Bugzilla app namespace.
@@ -281,226 +281,375 @@ var bz_attachment_form;
var Bugzilla = Bugzilla || {};
/**
- * Implement the attachment selector functionality that can be used standalone or on the New Bug page. This supports 3
- * input methods: traditional `` field, drag & dropping of a file or text, as well as copy & pasting
- * an image or text.
+ * Implement the attachment selector functionality that can be used standalone or on the New Bug
+ * page. This supports multiple input methods:
+ * - Drag & drop: the user drags a file or text and drops it on the drop target. The drop target is
+ * highlighted when a file is dragged over it.
+ * - Browse: the user clicks the Browse button and selects a file with the file picker dialog.
+ * - Enter text: the user clicks the Enter Text button and enters text in the textarea.
+ * - Paste: the user clicks the Paste button and pastes an image or text from the clipboard. This is
+ * supported only in browsers with the Async Clipboard API. In other browsers, the Paste button is
+ * hidden.
+ * - Capture: the user clicks the Take a Screenshot button and captures a screen, window or browser
+ * tab. The capture is attached as a PNG image.
*/
-Bugzilla.AttachmentForm = class AttachmentForm {
+Bugzilla.AttachmentSelector = class AttachmentSelector {
/**
- * Initialize a new `AttachmentForm` instance.
+ * Whether the currently selected file exceeds the maximum allowed size.
+ * @type {boolean}
*/
- constructor() {
- this.$file = document.querySelector('#att-file');
- this.$data = document.querySelector('#att-data');
- this.$filename = document.querySelector('#att-filename');
- this.$dropbox = document.querySelector('#att-dropbox');
- this.$browse_label = document.querySelector('#att-browse-label');
- this.$capture_label = document.querySelector('#att-capture-label');
- this.$textarea = document.querySelector('#att-textarea');
- this.$preview = document.querySelector('#att-preview');
- this.$preview_name = this.$preview.querySelector('[itemprop="name"]');
- this.$preview_type = this.$preview.querySelector('[itemprop="encodingFormat"]');
- this.$preview_text = this.$preview.querySelector('[itemprop="text"]');
- this.$preview_image = this.$preview.querySelector('[itemprop="image"]');
- this.$remove_button = document.querySelector('#att-remove-button');
- this.$description = document.querySelector('#att-description');
- this.$error_message = document.querySelector('#att-error-message');
- this.$ispatch = document.querySelector('#att-ispatch');
- this.$hide_preview = document.querySelector('#att-hide-preview');
- this.$type_outer = document.querySelector('#att-type-outer');
- this.$type_list = document.querySelector('#att-type-list');
- this.$type_manual = document.querySelector('#att-type-manual');
- this.$type_select = document.querySelector('#att-type-select');
- this.$type_input = document.querySelector('#att-type-input');
- this.$isprivate = document.querySelector('#isprivate');
- this.$takebug = document.querySelector('#takebug');
-
- // Add event listeners
- this.$file.addEventListener('change', () => this.file_onchange());
- this.$dropbox.addEventListener('dragover', event => this.dropbox_ondragover(event));
- this.$dropbox.addEventListener('dragleave', () => this.dropbox_ondragleave());
- this.$dropbox.addEventListener('dragend', () => this.dropbox_ondragend());
- this.$dropbox.addEventListener('drop', event => this.dropbox_ondrop(event));
- this.$browse_label.addEventListener('click', () => this.$file.click());
- this.$capture_label.addEventListener('click', () => this.capture_onclick());
- this.$textarea.addEventListener('input', () => this.textarea_oninput());
- this.$textarea.addEventListener('paste', event => this.textarea_onpaste(event));
- this.$remove_button.addEventListener('click', () => this.remove_button_onclick());
- this.$description.addEventListener('input', () => this.description_oninput());
- this.$description.addEventListener('change', () => this.description_onchange());
- this.$ispatch.addEventListener('change', () => this.ispatch_onchange());
- this.$hide_preview.addEventListener('change', () => this.hide_preview_onchange());
- this.$type_select.addEventListener('change', () => this.type_select_onchange());
- this.$type_input.addEventListener('change', () => this.type_input_onchange());
-
- // Prepare the file reader
- this.data_reader = new FileReader();
- this.text_reader = new FileReader();
- this.data_reader.addEventListener('load', () => this.data_reader_onload());
- this.text_reader.addEventListener('load', () => this.text_reader_onload());
-
- // Initialize the view
- this.enable_keyboard_access();
- this.reset_fields();
+ sizeError = false;
+
+ /**
+ * Whether a selected file is still being read into `$data`. The read is asynchronous, so the form
+ * must not be submitted until it completes, otherwise `data_base64` would be posted empty.
+ * @type {boolean}
+ */
+ readingFile = false;
+
+ /**
+ * Initialize a new `AttachmentSelector` instance.
+ * @param {object} params An object of parameters.
+ * @param {HTMLElement} params.$placeholder An element to be enhanced with the attachment selector
+ * functionality.
+ * @param {Record} [params.eventHandlers] An object of event handlers to be
+ * called when the user performs certain actions.
+ */
+ constructor({ $placeholder, eventHandlers = {} }) {
+ this.$placeholder = $placeholder;
+ this.eventHandlers = eventHandlers;
+
+ this.#renderUI();
+ this.#cacheElements();
+ this.#setupEventListeners();
+ this.#setupFileReaders();
+ this.#initializeView();
+ this.#checkBrowserSupport();
}
/**
- * Enable keyboard access on the buttons. Treat the Enter keypress as a click.
+ * Render the attachment selector UI template, detecting device type to conditionally show
+ * drag-and-drop hint.
*/
- enable_keyboard_access() {
- document.querySelectorAll('#att-selector [role="button"]').forEach($button => {
- $button.addEventListener('keypress', event => {
- if (!event.isComposing && event.key === 'Enter') {
- event.target.click();
- }
- });
- });
+ #renderUI() {
+ // Assume a fine pointer (e.g., a mouse) means a desktop device, and a coarse pointer means a
+ // mobile device. Don’t show the “Drag & drop” hint on mobile because it’s not a common input
+ // method on that platform.
+ const isDesktop = window.matchMedia('(pointer: fine)').matches;
+
+ this.$placeholder.innerHTML = `
+
+
+
+
+ ${isDesktop ? `Drag & drop a file here, or` : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
}
/**
- * Reset all the input fields to the initial state, and remove the preview and message.
+ * Cache references to DOM elements for quick access.
*/
- reset_fields() {
- this.description_override = false;
- this.$file.value = this.$data.value = this.$filename.value = this.$type_input.value = this.$description.value = '';
- this.$type_list.checked = this.$type_select.options[0].selected = true;
+ #cacheElements() {
+ this.$file = this.$placeholder.querySelector('#att-file');
+ this.$data = this.$placeholder.querySelector('#att-data');
+ this.$filename = this.$placeholder.querySelector('#att-filename');
+ this.$dropbox = this.$placeholder.querySelector('#att-dropbox');
+ this.$selectorActions = this.$placeholder.querySelector('#att-selector .actions');
+ this.$browseButton = this.$placeholder.querySelector('#att-browse-button');
+ this.$enterButton = this.$placeholder.querySelector('#att-enter-button');
+ this.$pasteButton = this.$placeholder.querySelector('#att-paste-button');
+ this.$captureButton = this.$placeholder.querySelector('#att-capture-button');
+ this.$editor = this.$placeholder.querySelector('#att-editor');
+ this.$textarea = this.$editor.querySelector('#att-textarea');
+ this.$preview = this.$placeholder.querySelector('#att-preview');
+ this.$previewName = this.$preview.querySelector('[itemprop="name"]');
+ this.$previewType = this.$preview.querySelector('[itemprop="encodingFormat"]');
+ this.$previewText = this.$preview.querySelector('[itemprop="text"]');
+ this.$previewImage = this.$preview.querySelector('[itemprop="image"]');
+ this.$textRemoveButton = this.$placeholder.querySelector('#att-text-remove-button');
+ this.$fileRemoveButton = this.$placeholder.querySelector('#att-file-remove-button');
+ this.$errorMessage = this.$placeholder.querySelector('#att-error-message');
+ }
- if (this.$isprivate) {
- this.$isprivate.checked = this.$isprivate.disabled = false;
- }
+ /**
+ * Register all event listeners for form submission, file input, drag-and-drop, buttons, and text
+ * input.
+ */
+ #setupEventListeners() {
+ this.$placeholder.closest('form').addEventListener('submit', (event) => this.validate(event));
+ this.$placeholder.closest('form').querySelector('[type="submit"]')
+ ?.addEventListener('click', (event) => this.validate(event));
+ this.$file.addEventListener('change', () => this.fileOnChange());
+ this.$dropbox.addEventListener('dragover', (event) => this.dropboxOnDragOver(event));
+ this.$dropbox.addEventListener('dragleave', () => this.dropboxOnDragLeave());
+ this.$dropbox.addEventListener('dragend', () => this.dropboxOnDragEnd());
+ this.$dropbox.addEventListener('drop', (event) => this.dropboxOnDrop(event));
+ this.$browseButton.addEventListener('click', () => this.$file.click());
+ this.$enterButton.addEventListener('click', () => this.enterButtonOnClick());
+ this.$pasteButton.addEventListener('click', () => this.pasteButtonOnClick());
+ this.$captureButton.addEventListener('click', () => this.captureButtonOnClick());
+ this.$textarea.addEventListener('input', () => this.textareaOnInput());
+ this.$textRemoveButton.addEventListener('click', () => this.removeButtonOnClick());
+ this.$fileRemoveButton.addEventListener('click', () => this.removeButtonOnClick());
+ }
- if (this.$takebug) {
- this.$takebug.checked = this.$takebug.disabled = false;
- }
+ /**
+ * Initialize FileReader instances for reading file data and text content.
+ */
+ #setupFileReaders() {
+ this.dataReader = new FileReader();
+ this.textReader = new FileReader();
+ this.dataReader.addEventListener('load', () => this.dataReaderOnLoad());
+ this.textReader.addEventListener('load', () => this.textReaderOnLoad());
+ }
- this.clear_preview();
- this.clear_error();
- this.update_requirements();
- this.update_text();
- this.update_ispatch();
+ /**
+ * Cancel any file read still in progress. Without this, the `load` event of an earlier read could
+ * repopulate `$data` after the fields have been cleared, and starting a second read while the
+ * first is still running throws an `InvalidStateError`.
+ */
+ #abortReads() {
+ this.readingFile = false;
+ this.dataReader.abort();
+ this.textReader.abort();
}
/**
- * Update the `required` property on the Base64 data and Description fields.
- * @param {Boolean} [required=true] `true` if these fields are required, `false` otherwise.
+ * Initialize the UI state and prepare the form for use.
*/
- update_requirements(required = true) {
- this.$data.required = this.$description.required = required;
- this.update_validation();
+ #initializeView() {
+ this.enableKeyboardAccess();
+ this.resetFields();
}
/**
- * Update the custom validation message on the Base64 data field depending on the requirement and value.
+ * Hide action buttons if the required browser APIs are not supported.
*/
- update_validation() {
- this.$data.setCustomValidity(this.$data.required && !this.$data.value ? 'Please select a file or enter text.' : '');
+ #checkBrowserSupport() {
+ // Hide the Paste button if the Clipboard API is not available
+ this.$pasteButton.hidden = typeof navigator.clipboard?.read !== 'function';
+ // Hide the Capture button if the Screen Capture API is not available
+ this.$captureButton.hidden = typeof navigator.mediaDevices?.getDisplayMedia !== 'function';
+ }
+
+ /**
+ * Whether the attachment is required.
+ * @type {boolean}
+ */
+ get required() {
+ return this.$placeholder.dataset.required === 'true';
+ }
+
+ /**
+ * Show or hide the header with the action buttons.
+ * @param {boolean} value `true` to show the header, `false` to hide it.
+ */
+ set actionsDisplayed(value) {
+ this.$selectorActions.hidden = !value;
+ }
+
+ /**
+ * Show or hide the editor.
+ * @param {boolean} value `true` to show the editor, `false` to hide it.
+ */
+ set editorDisplayed(value) {
+ this.$editor.hidden = !value;
+ }
+
+ /**
+ * Show or hide the preview.
+ * @param {boolean} value `true` to show the preview, `false` to hide it.
+ */
+ set previewDisplayed(value) {
+ this.$preview.hidden = !value;
+ }
+
+ /**
+ * Dispatch a custom event to the registered event handlers.
+ * @param {string} name The name of the event.
+ * @param {Record} [detail] Additional data to pass to the event handler.
+ */
+ dispatchEvent(name, detail = {}) {
+ this.eventHandlers[name]?.(detail);
+ }
+
+ /**
+ * Enable keyboard access on the buttons. Treat the Enter keypress as a click.
+ */
+ enableKeyboardAccess() {
+ this.$placeholder.querySelectorAll('[role="button"]').forEach(($button) => {
+ $button.addEventListener('keypress', (event) => {
+ if (!event.isComposing && event.key === 'Enter') {
+ event.target.click();
+ }
+ });
+ });
+ }
- // In Firefox, the message won't be displayed once the field becomes valid then becomes invalid again. This is a
- // workaround for the issue.
- this.$data.hidden = false;
- this.$data.hidden = true;
+ /**
+ * Reset all the input fields to the initial state, and remove the preview and message.
+ */
+ resetFields() {
+ this.#abortReads();
+ this.$file.value = '';
+ this.$data.value = '';
+ this.$filename.value = '';
+
+ this.clearPreview();
+ this.clearError();
+ this.updateText();
}
/**
- * Process a user-selected file for upload. Read the content if it's been transferred with a paste or drag operation.
- * Update the Description, Content Type, etc. and show the preview.
+ * Process a file for upload regardless of how it was provided (file picker, drag-and-drop, paste
+ * or screen capture). Read the file content, update the filename field, and show the preview.
* @param {File} file A file to be read.
- * @param {Boolean} [transferred=true] `true` if the source is `DataTransfer`, `false` if it's been selected via
- * ``.
*/
- process_file(file, transferred = true) {
+ processFile(file) {
// Detect patches that should have the `text/plain` MIME type
- const is_patch = !!file.name.match(/\.(?:diff|patch)$/) || !!file.type.match(/^text\/x-(?:diff|patch)$/);
- // Detect Markdown files that should have `text/plain` instead of `text/markdown` due to Firefox Bug 1421032
- const is_markdown = !!file.name.match(/\.(?:md|mkdn?|mdown|markdown)$/);
+ const isPatch =
+ !!file.name.match(/\.(?:diff|patch)$/) || !!file.type.match(/^text\/x-(?:diff|patch)$/);
+ // Detect Markdown files that should have `text/plain` instead of `text/markdown` due to Firefox
+ // Bug 1421032
+ const isMarkdown = !!file.name.match(/\.(?:md|mkdn?|mdown|markdown)$/);
// Detect common source files that may have no MIME type or `application/*` MIME type
- const is_source = !!file.name.match(/\.(?:cpp|es|h|js|json|rs|rst|sh|toml|ts|tsx|xml|yaml|yml)$/);
+ const isSource = !!file.name.match(
+ /\.(?:cpp|es|h|js|json|rs|rst|sh|toml|ts|tsx|xml|yaml|yml)$/,
+ );
// Detect any plaintext file
- const is_text = file.type.startsWith('text/') || is_patch || is_markdown || is_source;
- // Reassign the MIME type: use `text/plain` for most text files and `application/octet-stream` as a fallback
- const type = (is_patch || is_markdown || (is_source && !file.type)) ?
- 'text/plain' : (file.type || 'application/octet-stream');
-
- if (this.check_file_size(file.size)) {
- this.$data.required = transferred;
-
- if (transferred) {
- this.data_reader.readAsDataURL(file);
- this.$file.value = '';
- this.$filename.value = file.name.replace(/\s/g, '-');
- } else {
- this.$data.value = this.$filename.value = '';
- }
- } else {
- this.$data.required = true;
- this.$file.value = this.$data.value = this.$filename.value = '';
- }
+ const isText = file.type.startsWith('text/') || isPatch || isMarkdown || isSource;
+ // Reassign the MIME type: use `text/plain` for most text files and `application/octet-stream`
+ // as a fallback
+ const type =
+ isPatch || isMarkdown || (isSource && !file.type)
+ ? 'text/plain'
+ : file.type || 'application/octet-stream';
+
+ this.#abortReads();
+
+ if (!this.checkFileSize(file.size)) {
+ this.$file.value = '';
+ this.$data.value = '';
+ this.$filename.value = '';
+ this.clearPreview();
+ this.updateText();
+ this.actionsDisplayed = true;
+ this.editorDisplayed = false;
- this.update_validation();
- this.show_preview(file, is_text);
- this.update_text();
- this.update_content_type(type);
- this.update_ispatch(is_patch);
-
- if (!this.description_override) {
- this.$description.value = file.name;
+ return;
}
- this.$description.select();
- this.$description.focus();
+ this.readingFile = true;
+ this.dataReader.readAsDataURL(file);
+ // Note that this clears `$file.files` as well, so `$filename` is what tells us a file has been
+ // picked from here on
+ this.$file.value = '';
+ this.$filename.value = file.name.replace(/\s/g, '-');
+
+ this.editorDisplayed = false;
+ this.showPreview(file, isText);
+ this.updateText();
+ this.dispatchEvent('AttachmentProcessed', { file, type, isPatch });
}
/**
- * Check the current file size and show an error message if it exceeds the application-defined limit.
- * @param {Number} size A file size in bytes.
- * @returns {Boolean} `true` if the file is less than the maximum allowed size, `false` otherwise.
+ * Check the current file size and show an error message if it exceeds the application-defined
+ * limit.
+ * @param {number} size A file size in bytes.
+ * @returns {boolean} `true` if the file is less than the maximum allowed size, `false` otherwise.
*/
- check_file_size(size) {
- const file_size = size / 1024; // Convert to KB
- const max_size = BUGZILLA.param.maxattachmentsize; // Defined in KB
- const invalid = file_size > max_size;
- const message = invalid ?
- `This file (${(file_size / 1024).toFixed(1)} MB) is larger than the maximum allowed size ` +
- `(${(max_size / 1024).toFixed(1)} MB). Please consider uploading it to an online file storage ` +
- 'and sharing the link in a bug comment instead.' : '';
- const message_short = invalid ? 'File too large' : '';
-
- this.$error_message.innerHTML = message;
- this.$textarea.setCustomValidity(message_short);
- this.$textarea.setAttribute('aria-invalid', invalid);
+ checkFileSize(size) {
+ const fileSize = size / 1024; // Convert to KB
+ const maxSize = BUGZILLA.param.maxattachmentsize; // Defined in KB
+ const invalid = fileSize > maxSize;
+ const message = invalid
+ ? `This file (${(fileSize / 1024).toFixed(1)} MB) is larger than the ` +
+ `maximum allowed size (${(maxSize / 1024).toFixed(1)} MB). Please ` +
+ `consider uploading it to an online file storage and sharing the link in a ` +
+ `${BUGZILLA.string.bug} comment instead.`
+ : '';
+
+ this.sizeError = invalid;
+ this.$errorMessage.hidden = !invalid;
+ this.$errorMessage.innerHTML = message;
this.$dropbox.classList.toggle('invalid', invalid);
return !invalid;
}
/**
- * Called whenever a file's data URL is read by `FileReader`. Embed the Base64-encoded content for upload.
+ * Called whenever a file’s data URL is read by `FileReader`. Embed the Base64-encoded content for
+ * upload.
*/
- data_reader_onload() {
- this.$data.value = this.data_reader.result.split(',')[1];
- this.update_validation();
+ dataReaderOnLoad() {
+ this.$data.value = this.dataReader.result.split(',')[1];
+ this.readingFile = false;
+
+ // Clear any error shown while the read was still in progress
+ this.clearError();
}
/**
- * Called whenever a file's text content is read by `FileReader`. Show the preview of the first 10 lines.
+ * Called whenever a file’s text content is read by `FileReader`. Show the preview of the first 10
+ * lines.
*/
- text_reader_onload() {
- this.$preview_text.textContent = this.text_reader.result.split(/\r\n|\r|\n/, 10).join('\n');
+ textReaderOnLoad() {
+ this.$previewText.textContent = this.textReader.result.split(/\r\n|\r|\n/, 10).join('\n');
}
/**
* Called whenever a file is selected by the user by using the file picker. Prepare for upload.
*/
- file_onchange() {
- this.process_file(this.$file.files[0], false);
+ fileOnChange() {
+ this.processFile(this.$file.files[0]);
}
/**
- * Called whenever a file is being dragged on the drop target. Allow the `copy` drop effect, and set a class name on
- * the drop target for styling.
+ * Called whenever a file is being dragged on the drop target. Allow the `copy` drop effect, and
+ * set a class name on the drop target for styling.
* @param {DragEvent} event A `dragover` event.
*/
- dropbox_ondragover(event) {
+ dropboxOnDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed = 'copy';
@@ -512,280 +661,652 @@ Bugzilla.AttachmentForm = class AttachmentForm {
/**
* Called whenever a dragged file leaves the drop target. Reset the styling.
*/
- dropbox_ondragleave() {
+ dropboxOnDragLeave() {
this.$dropbox.classList.remove('dragover');
}
/**
* Called whenever a drag operation is being ended. Reset the styling.
*/
- dropbox_ondragend() {
+ dropboxOnDragEnd() {
this.$dropbox.classList.remove('dragover');
}
/**
- * Called whenever a file or text is dropped on the drop target. If it's a file, read the content. If it's plaintext,
- * fill in the textarea.
+ * Called whenever a file or text is dropped on the drop target. If it’s a file, read the content.
+ * If it’s plaintext, fill in the textarea.
* @param {DragEvent} event A `drop` event.
*/
- dropbox_ondrop(event) {
+ dropboxOnDrop(event) {
event.preventDefault();
const files = event.dataTransfer.files;
const text = event.dataTransfer.getData('text');
if (files.length > 0) {
- this.process_file(files[0]);
+ this.processFile(files[0]);
} else if (text) {
- this.clear_preview();
- this.clear_error();
- this.update_text(text);
+ this.clearPreview();
+ this.clearError();
+ this.updateText(text);
+ this.actionsDisplayed = false;
+ this.editorDisplayed = true;
+ this.previewDisplayed = false;
}
this.$dropbox.classList.remove('dragover');
}
/**
- * Insert text to the textarea, and show it if it's not empty.
- * @param {String} [text=''] Text to be inserted.
+ * Insert text to the textarea, and show it if it’s not empty.
+ * @param {string} [text] Text to be inserted.
*/
- update_text(text = '') {
+ updateText(text = '') {
this.$textarea.value = text;
- this.textarea_oninput();
+ this.textareaOnInput();
- if (text) {
+ if (text.trim()) {
+ this.readingFile = false;
+ this.sizeError = false;
this.$textarea.hidden = false;
+ this.$dropbox.classList.remove('invalid');
+ this.$errorMessage.hidden = true;
}
}
/**
- * Called whenever the Take a Screenshot button is clicked. Capture a screen, window or browser tab if the Screen
- * Capture API is supported, then attach it as a PNG image.
- * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API
+ * Called whenever the Enter Text button is clicked. Show the textarea for text input.
*/
- capture_onclick() {
- if (typeof navigator.mediaDevices.getDisplayMedia !== 'function') {
- alert('This function requires the most recent browser version such as Firefox 66 or Chrome 72.');
- return;
+ enterButtonOnClick() {
+ this.actionsDisplayed = false;
+ this.editorDisplayed = true;
+ this.$textarea.focus();
+ }
+
+ /**
+ * Called whenever the Paste button is clicked. Read the clipboard content and process it. This
+ * supports pasting of regular images, links and text.
+ */
+ async pasteButtonOnClick() {
+ try {
+ const items = [...(await navigator.clipboard.read())];
+ let pasted = false;
+
+ // Process only the first item until multiple items are supported
+ items.length = 1;
+
+ for (const item of items) {
+ if (item.types.includes('image/png')) {
+ const blob = await item.getType('image/png');
+ const file = new File([blob], 'pasted-image.png', { type: 'image/png' });
+
+ this.processFile(file);
+ pasted = true;
+ } else if (item.types.includes('text/plain')) {
+ const blob = await item.getType('text/plain');
+ const text = await blob.text();
+
+ this.updateText(text);
+ this.editorDisplayed = true;
+ this.previewDisplayed = false;
+ pasted = true;
+ }
+ }
+
+ if (pasted) {
+ this.actionsDisplayed = false;
+ this.dispatchEvent('AttachmentPasted', { items });
+ } else {
+ alert('No image or text data found in the clipboard.');
+ }
+ } catch (error) {
+ alert(error.message);
}
+ }
+ /**
+ * Called whenever the Take a Screenshot button is clicked. Capture a screen, window or browser
+ * tab if the Screen Capture API is supported, then attach it as a PNG image.
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API
+ */
+ async captureButtonOnClick() {
const $video = document.createElement('video');
const $canvas = document.createElement('canvas');
+ /** @type {MediaStream | undefined} */
+ let stream;
+
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ video: { displaySurface: 'window' },
+ });
- navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: 'window' } }).then(stream => {
// Render a captured screenshot on `