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}