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