001package apps.util.issuereporter.swing;
002
003import java.awt.*;
004import java.awt.datatransfer.*;
005import java.awt.event.ActionEvent;
006import java.awt.event.WindowEvent;
007import java.io.File;
008import java.io.IOException;
009import java.net.URI;
010import java.net.URISyntaxException;
011import java.util.*;
012import java.util.List;
013
014import javax.annotation.Nonnull;
015
016import apps.util.issuereporter.*;
017
018import javax.swing.*;
019import javax.swing.GroupLayout.Alignment;
020import static javax.swing.GroupLayout.DEFAULT_SIZE;
021import static javax.swing.GroupLayout.PREFERRED_SIZE;
022import javax.swing.event.DocumentEvent;
023import javax.swing.event.DocumentListener;
024
025import jmri.Application;
026
027import org.apiguardian.api.API;
028
029/**
030 * User interface for generating an issue report on the JMRI GitHub project.
031 * To allow international support, only the UI is localized.
032 * The user is requested to supply the report contents in English.
033 *
034 * @author Randall Wood Copyright 2020
035 */
036@API(status = API.Status.INTERNAL)
037public class IssueReporter extends JFrame implements ClipboardOwner, DocumentListener {
038
039    private static final int BUG = 0; // index in type combo box
040    private static final int RFE = 1; // index in type combo box
041    private JComboBox<String> typeCB;
042    private JComboBox<GitHubRepository> repoCB;
043    private JTextArea bodyTA;
044    private JToggleButton submitBtn;
045    private JTextField titleText;
046    private JLabel descriptionLabel;
047    private JLabel instructionsLabel;
048    private JPanel typeOptionsPanel;
049    private JPanel bugReportPanel;
050    private JCheckBox profileCB;
051    private JCheckBox sysInfoCB;
052    private JCheckBox logsCB;
053    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(IssueReporter.class);
054
055    /**
056     * Creates new form IssueReporterUI
057     */
058    public IssueReporter() {
059        initComponents();
060    }
061
062    private void initComponents() {
063
064        titleText = new JTextField();
065        bodyTA = new JTextArea();
066        submitBtn = new JToggleButton();
067        typeCB = new JComboBox<>();
068        repoCB = new JComboBox<>();
069        typeOptionsPanel = new JPanel();
070        bugReportPanel = new JPanel();
071        descriptionLabel = new JLabel();
072        instructionsLabel = new JLabel();
073        JLabel titleLabel = new JLabel();
074        JScrollPane bodySP = new JScrollPane();
075        JLabel typeLabel = new JLabel();
076        JLabel repoLabel = new JLabel();
077        profileCB = new JCheckBox();
078        sysInfoCB = new JCheckBox();
079        logsCB = new JCheckBox();
080
081        setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
082        setTitle(Bundle.getMessage("IssueReporterAction.title", ""));
083        setPreferredSize(new java.awt.Dimension(400, 600));
084
085        titleLabel.setFont(titleLabel.getFont().deriveFont(titleLabel.getFont().getStyle()));
086        titleLabel.setText(Bundle.getMessage("IssueReporter.titleLabel.text"));
087
088        bodyTA.setColumns(20);
089        bodyTA.setLineWrap(true);
090        bodyTA.setRows(5);
091        bodyTA.setWrapStyleWord(true);
092        bodySP.setViewportView(bodyTA);
093
094        submitBtn.setText(Bundle.getMessage("IssueReporter.submitBtn.text"));
095        submitBtn.setEnabled(false);
096        submitBtn.addActionListener(this::submitBtnActionListener);
097
098        descriptionLabel.setFont(descriptionLabel.getFont().deriveFont(descriptionLabel.getFont().getStyle() | java.awt.Font.BOLD));
099        descriptionLabel.setText(Bundle.getMessage("IssueReporter.descriptionLabel.bug"));
100
101        instructionsLabel.setText(Bundle.getMessage("IssueReporter.instructionsLabel.bug"));
102
103        typeLabel.setFont(typeLabel.getFont().deriveFont(typeLabel.getFont().getStyle()));
104        typeLabel.setText(Bundle.getMessage("IssueReporter.typeLabel.text"));
105
106        typeCB.setModel(new DefaultComboBoxModel<>(new String[]{Bundle.getMessage("IssueReporterType.bug"), Bundle.getMessage("IssueReporterType.feature")}));
107        typeCB.addActionListener(this::typeCBActionListener);
108
109        repoLabel.setFont(repoLabel.getFont().deriveFont(repoLabel.getFont().getStyle()));
110        repoLabel.setText(Bundle.getMessage("IssueReporter.repoLabel.text"));
111
112        repoCB.setModel(new GitHubRepositoryComboBoxModel());
113        repoCB.setRenderer(new GitHubRepositoryListCellRenderer());
114
115        profileCB.setText(Bundle.getMessage("IssueReporter.profileCB.text"));
116
117        sysInfoCB.setText(Bundle.getMessage("IssueReporter.sysInfoCB.text"));
118
119        logsCB.setText(Bundle.getMessage("IssueReporter.logsCB.text"));
120
121        titleText.getDocument().addDocumentListener(this);
122
123        bodyTA.getDocument().addDocumentListener(this);
124
125        GroupLayout bugReportPanelLayout = new GroupLayout(bugReportPanel);
126        bugReportPanel.setLayout(bugReportPanelLayout);
127        bugReportPanelLayout.setHorizontalGroup(
128                bugReportPanelLayout.createParallelGroup(Alignment.LEADING)
129                        .addGroup(bugReportPanelLayout.createSequentialGroup()
130                                .addContainerGap()
131                                .addGroup(bugReportPanelLayout.createParallelGroup(Alignment.LEADING)
132                                        .addComponent(sysInfoCB)
133                                        .addComponent(logsCB)
134                                        .addComponent(profileCB))
135                                .addContainerGap(DEFAULT_SIZE, Short.MAX_VALUE))
136        );
137        bugReportPanelLayout.setVerticalGroup(
138                bugReportPanelLayout.createParallelGroup(Alignment.LEADING)
139                        .addGroup(bugReportPanelLayout.createSequentialGroup()
140                                .addContainerGap()
141                                .addComponent(sysInfoCB)
142                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
143                                .addComponent(logsCB)
144                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
145                                .addComponent(profileCB)
146                                .addContainerGap(DEFAULT_SIZE, Short.MAX_VALUE))
147        );
148
149        GroupLayout typeOptionsPanelLayout = new GroupLayout(typeOptionsPanel);
150        typeOptionsPanel.setLayout(typeOptionsPanelLayout);
151        typeOptionsPanelLayout.setHorizontalGroup(
152                typeOptionsPanelLayout.createParallelGroup(Alignment.LEADING)
153                        .addComponent(bugReportPanel, Alignment.TRAILING, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
154        );
155        typeOptionsPanelLayout.setVerticalGroup(
156                typeOptionsPanelLayout.createParallelGroup(Alignment.LEADING)
157                        .addComponent(bugReportPanel, PREFERRED_SIZE, DEFAULT_SIZE, PREFERRED_SIZE)
158        );
159
160        GroupLayout layout = new GroupLayout(getContentPane());
161        getContentPane().setLayout(layout);
162        layout.setHorizontalGroup(
163                layout.createParallelGroup(Alignment.LEADING)
164                        .addGroup(layout.createSequentialGroup()
165                                .addContainerGap()
166                                .addGroup(layout.createParallelGroup(Alignment.LEADING)
167                                        .addComponent(bodySP, DEFAULT_SIZE, 376, Short.MAX_VALUE)
168                                        .addComponent(instructionsLabel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
169                                        .addComponent(descriptionLabel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE)
170                                        .addGroup(Alignment.TRAILING, layout.createSequentialGroup()
171                                                .addGap(0, 0, Short.MAX_VALUE)
172                                                .addComponent(submitBtn))
173                                        .addGroup(layout.createSequentialGroup()
174                                                .addGroup(layout.createParallelGroup(Alignment.LEADING, false)
175                                                        .addComponent(typeLabel, PREFERRED_SIZE, 70, Short.MAX_VALUE)
176                                                        .addComponent(repoLabel, PREFERRED_SIZE, 70, Short.MAX_VALUE)
177                                                        .addComponent(titleLabel, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE))
178                                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
179                                                .addGroup(layout.createParallelGroup(Alignment.LEADING)
180                                                        .addComponent(typeCB, 0, DEFAULT_SIZE, Short.MAX_VALUE)
181                                                        .addComponent(repoCB, 0, DEFAULT_SIZE, Short.MAX_VALUE)
182                                                        .addComponent(titleText)))
183                                        .addComponent(typeOptionsPanel, Alignment.TRAILING, DEFAULT_SIZE, DEFAULT_SIZE, Short.MAX_VALUE))
184                                .addContainerGap())
185        );
186
187        layout.linkSize(SwingConstants.HORIZONTAL, titleLabel, typeLabel, repoLabel);
188
189        layout.setVerticalGroup(
190                layout.createParallelGroup(Alignment.LEADING)
191                        .addGroup(layout.createSequentialGroup()
192                                .addContainerGap()
193                                .addGroup(layout.createParallelGroup(Alignment.BASELINE)
194                                        .addComponent(typeCB, PREFERRED_SIZE, DEFAULT_SIZE, PREFERRED_SIZE)
195                                        .addComponent(typeLabel))
196                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
197                                .addGroup(layout.createParallelGroup(Alignment.BASELINE)
198                                        .addComponent(repoCB, PREFERRED_SIZE, DEFAULT_SIZE, PREFERRED_SIZE)
199                                        .addComponent(repoLabel))
200                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
201                                .addGroup(layout.createParallelGroup(Alignment.BASELINE)
202                                        .addComponent(titleLabel)
203                                        .addComponent(titleText, PREFERRED_SIZE, DEFAULT_SIZE, PREFERRED_SIZE))
204                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
205                                .addComponent(descriptionLabel)
206                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
207                                .addComponent(instructionsLabel)
208                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
209                                .addComponent(bodySP, DEFAULT_SIZE, 109, Short.MAX_VALUE)
210                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
211                                .addComponent(typeOptionsPanel, PREFERRED_SIZE, DEFAULT_SIZE, PREFERRED_SIZE)
212                                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
213                                .addComponent(submitBtn)
214                                .addContainerGap())
215        );
216
217        pack();
218    }
219
220    @Override
221    public void insertUpdate(DocumentEvent e) {
222        changedUpdate(e);
223    }
224
225    @Override
226    public void removeUpdate(DocumentEvent e) {
227        changedUpdate(e);
228    }
229
230    @Override
231    public void changedUpdate(DocumentEvent e) {
232        submitBtn.setEnabled(!bodyTA.getText().isEmpty() && !titleText.getText().isEmpty());
233    }
234
235    private void typeCBActionListener(ActionEvent e) {
236        switch (typeCB.getSelectedIndex()) {
237            case BUG:
238                descriptionLabel.setText(Bundle.getMessage("IssueReporter.descriptionLabel.bug"));
239                instructionsLabel.setText(Bundle.getMessage("IssueReporter.instructionsLabel.bug"));
240                if (!typeOptionsPanel.equals(bugReportPanel.getParent())) {
241                    typeOptionsPanel.add(bugReportPanel);
242                    typeOptionsPanel.setPreferredSize(bugReportPanel.getPreferredSize());
243                    bugReportPanel.revalidate();
244                    bugReportPanel.repaint();
245                }
246                break;
247            case RFE:
248                descriptionLabel.setText(Bundle.getMessage("IssueReporter.descriptionLabel.feature"));
249                instructionsLabel.setText(Bundle.getMessage("IssueReporter.instructionsLabel.feature"));
250                typeOptionsPanel.remove(bugReportPanel);
251                break;
252            default:
253                log.error("Unexpected selected index {} for issue type", typeCB.getSelectedIndex(), new IllegalArgumentException());
254        }
255    }
256
257    private void submitBtnActionListener(ActionEvent e) {
258        IssueReport report = null;
259        switch (typeCB.getSelectedIndex()) {
260            case BUG:
261                report = new BugReport(titleText.getText(), bodyTA.getText(), profileCB.isSelected(), sysInfoCB.isSelected(), logsCB.isSelected());
262                break;
263            case RFE:
264                report = new EnhancementRequest(titleText.getText(), bodyTA.getText());
265                break;
266            default:
267                log.error("Unexpected selected index {} for issue type", typeCB.getSelectedIndex(), new IllegalArgumentException());
268        }
269        if (report != null) {
270            submitReport(report);
271        }
272    }
273
274    // package private
275    private void submitReport(IssueReport report) {
276        try {
277            URI uri = report.submit(repoCB.getItemAt(repoCB.getSelectedIndex()));
278            List<File> attachments = report.getAttachments();
279            if (!attachments.isEmpty()) {
280                JOptionPane.showMessageDialog(this,
281                        Bundle.getMessage("IssueReporter.attachments.message"),
282                        Bundle.getMessage("IssueReporter.attachments.title"),
283                        JOptionPane.INFORMATION_MESSAGE);
284                Desktop.getDesktop().open(attachments.get(0).getParentFile());
285            }
286            if ( Desktop.getDesktop().isSupported( Desktop.Action.BROWSE) ) {
287                // Open browser to URL with draft report
288                Desktop.getDesktop().browse(uri);
289                this.dispatchEvent(new WindowEvent(this, WindowEvent.WINDOW_CLOSING)); // close report window
290            } else {
291                // Can't open browser, ask the user to instead
292                Object[] options = {Bundle.getMessage("IssueReporter.browser.copy"), Bundle.getMessage("IssueReporter.browser.skip")};
293                int choice = JOptionPane.showOptionDialog(this,
294                    Bundle.getMessage("IssueReporter.browser.message"), // message
295                    Bundle.getMessage("IssueReporter.browser.title"), // window title
296                    JOptionPane.YES_NO_OPTION,
297                    JOptionPane.INFORMATION_MESSAGE,
298                    null, // icon
299                    options,
300                    Bundle.getMessage("IssueReporter.browser.copy")
301                );
302                
303                if (choice == 0 ) {
304                    Toolkit.getDefaultToolkit()
305                        .getSystemClipboard()
306                        .setContents(
307                            new StringSelection(uri.toString()),
308                            null
309                        );
310                    this.dispatchEvent(new WindowEvent(this, WindowEvent.WINDOW_CLOSING)); // close report window
311                }
312            }
313            
314        } catch (IOException | URISyntaxException ex) {
315            log.error("Unable to report issue", ex);
316            JOptionPane.showMessageDialog(this,
317                    Bundle.getMessage("IssueReporter.error.message", ex.getLocalizedMessage()),
318                    Bundle.getMessage("IssueReporter.error.title"),
319                    JOptionPane.ERROR_MESSAGE);
320        } catch (IssueReport414Exception ex) {
321            BodyTransferable bt = new BodyTransferable(report.getBody());
322            Toolkit.getDefaultToolkit().getSystemClipboard().setContents(bt, this);
323            JOptionPane.showMessageDialog(this,
324                    Bundle.getMessage("IssueReporter.414.message"),
325                    Bundle.getMessage("IssueReporter.414.title"),
326                    JOptionPane.INFORMATION_MESSAGE);
327            submitReport(report);
328        }
329    }
330
331    @Override
332    public void lostOwnership(Clipboard clipboard, Transferable contents
333    ) {
334        // ignore -- merely means something else was put on clipboard
335    }
336
337    private static class GitHubRepositoryComboBoxModel extends DefaultComboBoxModel<GitHubRepository> {
338
339        public GitHubRepositoryComboBoxModel() {
340            super();
341            ServiceLoader<GitHubRepository> loader = ServiceLoader.load(GitHubRepository.class);
342            Set<GitHubRepository> set = new TreeSet<>();
343            loader.forEach(set::add);
344            loader.reload();
345            set.forEach(r -> {
346                addElement(r);
347                if (r.getTitle().equals(Application.getApplicationName())) {
348                    setSelectedItem(r);
349                }
350            });
351        }
352
353    }
354
355    private static class GitHubRepositoryListCellRenderer extends DefaultListCellRenderer {
356
357        @Override
358        public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
359            return super.getListCellRendererComponent(list,
360                    (value instanceof GitHubRepository) ? ((GitHubRepository) value).getTitle() : value,
361                    index,
362                    isSelected,
363                    cellHasFocus);
364        }
365    }
366
367    public static class FileTransferable implements Transferable {
368
369        private final List<File> files;
370
371        public FileTransferable(@Nonnull List<File> files) {
372            this.files = files;
373        }
374
375        @Override
376        public DataFlavor[] getTransferDataFlavors() {
377            return new DataFlavor[]{DataFlavor.javaFileListFlavor};
378        }
379
380        @Override
381        public boolean isDataFlavorSupported(DataFlavor flavor) {
382            return DataFlavor.javaFileListFlavor.equals(flavor);
383        }
384
385        @Override
386        @Nonnull
387        public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException {
388            return files;
389        }
390
391    }
392
393    public static class BodyTransferable implements Transferable {
394
395        private final String body;
396
397        public BodyTransferable(@Nonnull String body) {
398            this.body = body;
399        }
400
401        @Override
402        public DataFlavor[] getTransferDataFlavors() {
403            return new DataFlavor[]{DataFlavor.stringFlavor};
404        }
405
406        @Override
407        public boolean isDataFlavorSupported(DataFlavor flavor) {
408            return DataFlavor.stringFlavor.equals(flavor);
409        }
410
411        @Override
412        @Nonnull
413        public Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException {
414            return body;
415        }
416
417    }
418
419}