Feel


The visual appearance of the interface is important, but more important is its "feel." The feel of an application is what a user experiences as he or she interacts with it. Examples of elements relevant to the feel of an application include:

  • ability to effect all behaviors using either the keyboard or the mouse

  • tabbing sequence can the user visit all text fields in the proper order and are irrelevant items omitted from the tab sequence?

  • field limits is the user restricted from entering too many characters?

  • field constraints is the user restricted from entering inappropriate data into a field?

  • button activation are buttons deactivated when their use is inappropriate?

It is possible to address both the look and feel at the same time. In the last section, you decorated the list of courses with a scroll pane. This improved the look of the interface and at the same time provided the "feel" to allow a user to scroll the list of courses when necessary.

Keyboard Support

One of the primary rules of GUIs is that a user must be able to completely control the application using either the keyboard or the mouse. Exceptions exist. Using the keyboard to draw a polygon is ineffective, as is entering characters using the mouse. (Both are of course possible.) In such cases, the application developer often chooses to bypass the rule. But in most cases it is extremely inconsiderate to ignore the needs of a keyboard only or mouse only user.

By default, Java supplies most of the necessary support for dual keyboard and mouse control. For example, you can activate a button by either clicking on it with the mouse or tabbing to it and pressing the space bar.

Button Mnemonics

An additional way you can activate a button is using an Alt-key combination. You combine pressing the Alt key with another key. The other key is typically a letter or number that appears in the button text. You refer to this key as a mnemonic (technically, a device to help you remember something; in Java, it's simply a single-letter shortcut). An appropriate mnemonic for the Add button is the letter A.

The mnemonic is a view class element. The specifications for the mnemonic remain constant with the view's appearance. Therefore the more appropriate place to test and manage the mnemonic is in the view class.

In CoursesPanelTest:

 public void testCreate() {    assertEmptyList(COURSES_LIST_NAME);    assertButtonText(ADD_BUTTON_NAME, ADD_BUTTON_TEXT);    assertLabelText(DEPARTMENT_LABEL_NAME, DEPARTMENT_LABEL_TEXT);    assertEmptyField(DEPARTMENT_FIELD_NAME);    assertLabelText(NUMBER_LABEL_NAME, NUMBER_LABEL_TEXT);    assertEmptyField(NUMBER_FIELD_NAME);    JButton button = panel.getButton(ADD_BUTTON_NAME);    assertEquals(ADD_BUTTON_MNEMONIC, button.getMnemonic()); } 

In CoursesPanel:

 public class CoursesPanel extends JPanel {    ...    static final char ADD_BUTTON_MNEMONIC = 'A';    ...    JPanel createBottomPanel() {       addButton = createButton(ADD_BUTTON_NAME, ADD_BUTTON_TEXT);      addButton.setMnemonic(ADD_BUTTON_MNEMONIC);       ...       return panel;    }    ... } 

After seeing your tests pass, execute sis.ui.Sis as an application. Enter a course department and number, then press Alt-A to demonstrate use of the mnemonic.

Required Fields

A valid course requires both a department and course number. However, sis.ui.Sis allows you to omit either or both, yet still press the Add button. You want to modify the application to disallow this circumstance.

One solution would be to wait until the user presses Add, then ensure that both department and course number contain a nonempty string. If not, then present a message pop-up that explains the requirement. While this solution will work, it creates a more annoying user experience. Users don't want continual interruption from message pop-ups. A better solution is to proactively disable the Add button until the user has entered information in both fields.

You can monitor both fields and track when a user enters information in them. Each time a user presses a character, you can test the field contents and enable or disable the Add button as appropriate.

Managing enabling/disabling of the Add button is an application characteristic, not a view characteristic. It involves business-related logic, as it is based on the business need for specific data. As such, the test and related code belongs not in the panel class, but elsewhere.

Another indicator that the code does not belong in the view class is that you have interaction between two components. You want the controller to notify other classes of an event (keys being pressed) and you want those other classes to tell the view what to present (a disabled or enabled button) under certain circumstances. These are two separate concerns. You don't want logic in the view trying to mediate things.

To solve the bigger problem, however, it is easier to provide tests against CoursesPanel that prove each of the two smaller occurences. A first test ensures that a listener is notified when keys are pressed. A second test ensures that CoursesPanel can enable and disable buttons.

Start with a test (in CoursesPanelTest) for enabling and disabling buttons:

 public void testEnableDisable() {    panel.setEnabled(ADD_BUTTON_NAME, true);    JButton button = panel.getButton(ADD_BUTTON_NAME);    assertTrue(button.isEnabled());    panel.setEnabled(ADD_BUTTON_NAME, false);    assertFalse(button.isEnabled()); } 

The code in CoursesPanel is a one-line reactive methodno logic:

 void setEnabled(String name, boolean state) {    getButton(name).setEnabled(state); } 

The second test shows how to attach a keystroke listener to a field:

 public void testAddListener() throws Exception {    KeyListener listener = new KeyAdapter() {};    panel.addFieldListener(DEPARTMENT_FIELD_NAME, listener);    JTextField field = panel.getField(DEPARTMENT_FIELD_NAME);    KeyListener[] listeners = field.getKeyListeners();    assertEquals(1, listeners.length);    assertSame(listener, listeners[0]); } 

A KeyAdapter is an abstract implementation of the KeyListener interface that does nothing. The first line in the test creates a concrete subclass of KeyAdapter that overrides nothing. After adding the listener (using addFieldListener) to the panel, the test ensures that the panel properly sets the listener into the text field. The code in CoursesPanel is again trivial:

 void addFieldListener(String name, KeyListener listener) {    getField(name).addKeyListener(listener); } 

The harder test is at the application level. A listener in the Sis object should receive messages when a user types into the department and number fields. You must prove that the listener's receipt of these messages triggers logic to enable/disable the Add button. You must also prove that various combinations of text/no text in the fields results in the appropriate state for the Add button.

A bit of programming by intention in SisTest will provide you with a test skeleton:

 public void testKeyListeners() throws Exception {    sis.show();    JButton button = panel.getButton(CoursesPanel.ADD_BUTTON_NAME);    assertFalse(button.isEnabled());    selectField(CoursesPanel.DEPARTMENT_FIELD_NAME);    type('A');    selectField(CoursesPanel.NUMBER_FIELD_NAME);    type('1');    assertTrue(button.isEnabled()); } 

The test ensures that the button is disabled by default. After typing values into the department and number fields, it verifies that the button is enabled. The trick, of course, is how to select a field and emulate typing into it. Swing provides a few solutions. Unfortunately, each requires you to render (make visible) the actual screen. Thus the first line in the test is a call to the show method of Sis.

The solution I'll present involves use of the class java.awt.Robot. The Robot class emulates end-user interaction using the keyboard and/or mouse. Another solution requires you to create keyboard event objects and pass them to the fields using a method on java.awt.Component named dispatchEvent.

You can construct a Robot object in the SisTest setUp method. (After building this example, I noted persistent use of the CoursesPanel object, so I also refactored its extraction to setUp.)

 public class SisTest extends TestCase {    ...    private CoursesPanel panel;    private Robot robot;    protected void setUp() throws Exception {       ...       panel = (CoursesPanel)Util.getComponent(frame, CoursesPanel.NAME);       robot = new Robot();    } 

The selectField method isn't that tough:

 private void selectField(String name) throws Exception {    JTextField field = panel.getField(name);    Point point = field.getLocationOnScreen();    robot.mouseMove(point.x, point.y);    robot.mousePress(InputEvent.BUTTON1_MASK);    robot.mouseRelease(InputEvent.BUTTON1_MASK); } 

After obtaining a field object, you can obtain its absolute position on the screen by sending it the message getLocationOnScreen. This returns a Point objecta coordinate in Cartesian space represented by an x and y offset.[3] You can send this coordinate as an argument to Robot's mouseMove method. Subsequently, sending a mousePress and mouseRelease message to the Robot results in a virtual mouse-click at that location.

[3] The upper left corner of your screen has an (x, y) coordinate of (0, 0). The value of y increases as you move down the screen. For example, you would express two pixels to the right and one pixel down as (2, 1).

The type method is equally straightforward:

 private void type(int key) throws Exception {    robot.keyPress(key);    robot.keyRelease(key); } 

The code in Sis adds a single listener to each text field. This listener waits on keyReleased events. When it receives one, the listener calls the method -setAddButtonState. The code in setAddButtonState looks at the contents of the two fields to determine whether or not to enable the Add button.

 public class Sis {    ...    private void initialize() {       createCoursesPanel();       createKeyListeners();       ...    }    ...    void createKeyListeners() {       KeyListener listener = new KeyAdapter() {          public void keyReleased(KeyEvent e) {             setAddButtonState();          }       };       panel.addFieldListener(CoursesPanel.DEPARTMENT_FIELD_NAME,                              listener);       panel.addFieldListener(CoursesPanel.NUMBER_FIELD_NAME, listener);       setAddButtonState();    }    void setAddButtonState() {       panel.setEnabled(CoursesPanel.ADD_BUTTON_NAME,          !isEmpty(CoursesPanel.DEPARTMENT_FIELD_NAME) &&          !isEmpty(CoursesPanel.NUMBER_FIELD_NAME));    }    private boolean isEmpty(String field) {       String value = panel.getText(field);       return value.equals("");    } } 

Note that the last line in createKeyListeners calls setAddButtonState in order to set the Add button to its default (initial) state.

The code in testKeyListeners doesn't represent all possible scenarios. What if the user enters nothing but space characters? Is the button properly disabled if one of the two fields has data but the other does not?

You could enhance testKeyListeners with these scenarios. A second test shows a different approach, one that directly interacts with setAddButtonState. This test covers a more complete set of circumstances.

 public void testSetAddButtonState() throws Exception {    JButton button = panel.getButton(CoursesPanel.ADD_BUTTON_NAME);    assertFalse(button.isEnabled());    panel.setText(CoursesPanel.DEPARTMENT_FIELD_NAME, "a");    sis.setAddButtonState();    assertFalse(button.isEnabled());    panel.setText(CoursesPanel.NUMBER_FIELD_NAME, "1");    sis.setAddButtonState();    assertTrue(button.isEnabled());    panel.setText(CoursesPanel.DEPARTMENT_FIELD_NAME, " ");    sis.setAddButtonState();    assertFalse(button.isEnabled());    panel.setText(CoursesPanel.DEPARTMENT_FIELD_NAME, "a");    panel.setText(CoursesPanel.NUMBER_FIELD_NAME, "  ");    sis.setAddButtonState();    assertFalse(button.isEnabled()); } 

The test fails. A small change to isEmpty fixes things.

 private boolean isEmpty(String field) {    String value = panel.getText(field);    return value.trim().equals(""); } 

Field Edits

When you provide an effective user interface, you want to make it as difficult as possible for users to enter invalid data. You learned that you don't want to interrupt users with pop-ups when requiring fields. Similarly, you want to avoid presenting pop-ups to tell users they have entered invalid data.

A preferred solution involves verifying and even modifying data in a text field as the user enters it. As an example, a course department must contain only uppercase letters. "CMSC" is a valid department, but "Cmsc" and "cmsc" are not. To make life easier for your users, you can make the department text field automatically convert each lowercase letter to an uppercase letter as the user types it.

The evolution of Java has included several attempts at solutions for dynamically editing fields. Currently, there are at least a half dozen ways to go about it. You will learn two of the preferred techniques: using JFormattedTextField and creating custom DocumentFilter classes.

You create a custom filter by subclassing javax.swing.text.DocumentFilter. In the subclass, you override definitions for any of three methods: insertString, remove, and replace. You use these methods to restrict invalid input and/or transform invalid input into valid input.

As a user enters or pastes characters into the text field, the insertString method is indirectly called. The replace method gets invoked when a user first selects existing characters in a text field before typing or pasting new characters. The remove method is invoked when the user deletes characters from the text field. You will almost always need to define behavior for insertString and replace, but you will need to do so only occasionally for remove.

Once you have defined the behavior for the custom filter, you attach it to a text field's document. The document is the underlying data model for the text field; it is an implementation of the interface javax.swing.text.Document. You obtain the Document object associated with a JTextField by sending it the message geTDocument. You can then attach the custom filter to the Document using setDocumentFilter.

Testing the Filter

How will you test the filter? You could code a test in CoursesPanel that uses the Swing robot (as described in the Required Fields section). But for the purpose of testing units, the robot is a last-resort technique that you should use only when you must. In this case, a DocumentFilter subclass is a stand-alone class that you can test directly.

In some cases, you'll find that Swing design lends itself to easy testing. For custom filters, you'll have to do a bit of legwork first to get around a few barriers.

UpcaseFilterTest appears directly below. The individual test method testInsert is straightforward and easy to read, the result of some refactoring. In testInsert, you send the message insertString directly to an UpcaseFilter instance. The second argument to insertString is the column at which to begin inserting. The third argument is the text to insert. (For now, the fourth argument is irrelevant, and I'll discuss the first argument shortly).

Inserting the text "abc" at column 0 should generate the text "ABC". Inserting "def" at position 1 (i.e., before the second column) should generate the text "ADEFBC".

 package sis.ui; import javax.swing.*; import javax.swing.text.*; import junit.framework.*; public class UpcaseFilterTest extends TestCase {    private DocumentFilter filter;    protected DocumentFilter.FilterBypass bypass;    protected AbstractDocument document;    protected void setUp() {       bypass = createBypass();       document = (AbstractDocument)bypass.getDocument();       filter = new UpcaseFilter();    }    public void testInsert() throws BadLocationException {       filter.insertString(bypass, 0, "abc", null);       assertEquals("ABC", documentText());       filter.insertString(bypass, 1, "def", null);       assertEquals("ADEFBC", documentText());    }    protected String documentText() throws BadLocationException {       return document.getText(0, document.getLength());    }    protected DocumentFilter.FilterBypass createBypass() {       return new DocumentFilter.FilterBypass() {          private AbstractDocument document = new PlainDocument();          public Document getDocument() {             return document;          }          public void insertString(                int offset, String string, AttributeSet attr)  {             try {                document.insertString(offset, string, attr);             }             catch (BadLocationException e) {}          }          public void remove(int offset, int length) {}          public void replace(int offset,                 int length, String string, AttributeSet attrs) {}       };    } } 

The setup is considerably more involved than the test itself.

If you look at the javadoc for insertString, you'll see that it takes a reference of type DocumentFilter.FilterBypass as its first argument. A filter bypass is essentially a reference to the document that ignores any filters. After you transform data in insertString, you must call insertString on the filter bypass. Otherwise, you will create an infinite loop!

The difficulty with respect to testing is that Swing provides no direct way to obtain a filter bypass object. You need the bypass in order to test the filter.

The solution presented above is to provide a new implementation of DocumentFilter.FilterBypass. This implementation stores a concrete instance of an AbstractDocument (which implements the Document interface) known as a PlainDocument. To flesh out the bypass, you must supply implementations for the three methods insertString, remove, and replace. For now, the test only requires you to implement insertString.

The insertString method doesn't need to take a bypass object as its first parameter, since it is defined in the filter itself. Its job is to call the document's insertString method directly (i.e., without calling back into the DocumentFilter). Note that this method can throw a BadLocationException if the start position is out of range.

Once you have a DocumentFilter.FilterBypass instance, the remainder of the setup and test is easy. From the bypass object, you can obtain and store a document reference. You assert that the contents of this document were appropriately updated.

The test (UpcaseFilterTest) contains a lot of code. You might think that the robot-based test would have been easier to write. In fact, it would have. However, robots have their problems. Since they take control of the mouse and keyboard, you have to be careful not to do anything else while the tests execute. Otherwise you can cause the robot tests to fail. This alone is reason to avoid them at all costs. If you must use robot tests, find a way to isolate them and perhaps execute them at the beginning of your unit-test suite.

Also, the test code for the second filter you write will be as easy to code as the corresponding robot test code. Both filter tests would require the documentText and createBypass methods as well as most of the setUp method.

Coding the Filter

You're more than halfway done with building a filter. You've completed the hard partwriting a test for it. Coding the filter itself is trivial.

 package sis.ui; import javax.swing.text.*; public class UpcaseFilter extends DocumentFilter {    public void insertString(          DocumentFilter.FilterBypass bypass,          int offset,          String text,          AttributeSet attr) throws BadLocationException {       bypass.insertString(offset, text.toUpperCase(), attr);    } } 

When the filter receives the insertString message, its job in this case is to convert the text argument to uppercase, and pass this transformed data off to the bypass.

Once you've demonstrated that your tests all still pass, you can now code the replace method. The test modifications:

 ... public class UpcaseFilterTest extends TestCase {    ...    public void testReplace() throws BadLocationException {       filter.insertString(bypass, 0, "XYZ", null);       filter.replace(bypass, 1, 2, "tc", null);       assertEquals("XTC", documentText());       filter.replace(bypass, 0, 3, "p8A", null);       assertEquals("P8A", documentText());    }    ...    protected DocumentFilter.FilterBypass createBypass() {       return new DocumentFilter.FilterBypass() {          ...          public void replace(int offset,                 int length, String string, AttributeSet attrs) {             try {                document.replace(offset, length, string, attrs);             }             catch (BadLocationException e) {}          }       };    } } 

The test shows that the replace method takes an additional argument. The third parameter represents the number of characters to be replaced, starting at the position represented by the second argument. The production code:

 package sis.ui; import javax.swing.text.*; public class UpcaseFilter extends DocumentFilter {    ...    public void replace(       DocumentFilter.FilterBypass bypass,       int offset,       int length,       String text,       AttributeSet attr) throws BadLocationException {       bypass.replace(offset, length, text.toUpperCase(), attr);    } } 

UpcaseFilter is complete. You need not concern yourself with filtering removal operations when uppercasing input.

Attaching the Filter

You have proved the functionality of UpcaseFilter as a standalone unit. To prove that the department field in CoursesPanel transforms its input into uppercase text, you need only demonstrate that the appropriate filter has been attached to the field.

Should the code in CoursesPanel attach the filters to its fields, or should code in Sis retrieve the fields and attach the filters? Does the test belong in SisTest or in CoursesPanelTest? A filter is a combination of business rule and view functionality. It enforces a business constraint (for example, "Department abbreviations are four uppercase characters"). A filter also enhances the feel of the application by making it easier for the user to enter only valid information.

Remember: Keep the view class simple. Put as much business-related logic in domain (Course) or application classes (Sis). The filter representation of the business logic is very dependent upon Swing. The filters are essentially plugins to the Swing framework. You don't want to make the domain class dependent upon such code. Thus, the only remaining choice is the application class.

The code in SisTest:

    public void testCreate() {       ...       CoursesPanel panel =          (CoursesPanel)Util.getComponent(frame, CoursesPanel.NAME);       assertNotNull(panel);       ...       verifyFilter(panel);    }    private void verifyFilter(CoursesPanel panel) {       DocumentFilter filter =           getFilter(panel, CoursesPanel.DEPARTMENT_FIELD_NAME);       assertTrue(filter.getClass() == UpcaseFilter.class);    }    private DocumentFilter getFilter( CoursesPanel panel, String fieldName) {       JTextField field = panel.getField(fieldName);       AbstractDocument document = (AbstractDocument)field.getDocument();       return document.getDocumentFilter();    }    ... } 

The code in Sis:

 private void initialize() {    createCoursesPanel();    createKeyListeners();    createInputFilters();    ... } ... private void createInputFilters() {    JTextField field =        panel.getField(CoursesPanel.DEPARTMENT_FIELD_NAME);    AbstractDocument document = (AbstractDocument)field.getDocument();    document.setDocumentFilter(new UpcaseFilter()); } 

A Second Filter

You also want to constrain the number of characters in both the department and course number field. In fact, in most applications that require field entry, you will want the ability to set field limits. You can create a second custom filter, LimitFilter. The following code listing shows only the production class. The test, LimitFilterTest (see the code athttp://www.LangrSoft.com/agileJava/code/) contains a lot of commonality with UpcaseFilterTest that you can factor out.

 package sis.ui; import javax.swing.text.*; public class LimitFilter extends DocumentFilter {    private int limit;    public LimitFilter(int limit) {       this.limit = limit;    }    public void insertString(          DocumentFilter.FilterBypass bypass,          int offset,          String str,          AttributeSet attrSet) throws BadLocationException {       replace(bypass, offset, 0, str, attrSet);    }    public void replace(          DocumentFilter.FilterBypass bypass,          int offset,          int length,          String str,          AttributeSet attrSet) throws BadLocationException {       int newLength =           bypass.getDocument().getLength() - length + str.length();       if (newLength > limit)          throw new BadLocationException(             "New characters exceeds max size of document", offset);       bypass.replace(offset, length, str, attrSet);    } } 

Note the technique of having insertString delegate to the replace method. The other significant bit of code involves throwing a BadLocationException if the replacement string is too large.

Building such a filter and attaching it to the course number field is easy enough. You construct a LimitFilter by passing it the character length. For example, the code snippet new LimitFilter(3) creates a filter that prevents more than three characters.

The problem is that you can set only a single filter on a document. You have a couple of choices. The first (bad) choice is to create a separate filter for each combination. For example, you might have filter combinations -Upcase-LimitFilter and NumericOnlyLimitFilter. A better solution involves some form of abstractiona ChainableFilter. The ChainableFilter class subclasses DocumentFilter. It contains a sequence of individual filter classes and manages calling each in turn. The code available at http://www.LangrSoft.com/agileJava/code/ for this lesson demonstrates how you might build such a -construct.[4]

[4] The listing does not appear here for space reasons.

JFormattedTextField

Another mechanism for managing field edits is to use the class javax.swing.JFormattedTextField, a subclass of JTextField. You can attach formatters to the field to ensure that the contents conform to your specification. Further, you can retrieve the contents of the field as appropriate object types other than text.

You want to provide an effective date field for the course. This date represents when the course is first made available in the system. Users must enter the date in the format mm/dd/yy. For example, 04/15/02 is a valid date.

The test extracts the field as a JFormattedTextField, then gets a formatter object from the JFormattedTextField. A formatter is a subclass of javax.swing.JFormattedTextField.AbstractFormatter. In verifyEffectiveDate, you expect that the formatter is a DateFormatter. The DateFormatter in turn wraps a java.text.SimpleDateFormat instance whose format pattern is MM/dd/yy.[5]

[5] The capital letter M is used for month, while the lowercase letter m is used for minutes.

The final part of the test ensures that the field holds on to a date instance. When the user clicks Add, code in sis.ui.Sis can extract the contents of the effective date field as a java.util.Date object.

 private void verifyEffectiveDate() {    assertLabelText(EFFECTIVE_DATE_LABEL_NAME,       EFFECTIVE_DATE_LABEL_TEXT);    JFormattedTextField dateField =       (JFormattedTextField)panel.getField(EFFECTIVE_DATE_FIELD_NAME);    DateFormatter formatter = (DateFormatter)dateField.getFormatter();    SimpleDateFormat format = (SimpleDateFormat)formatter.getFormat();    assertEquals("MM/dd/yy", format.toPattern());    assertEquals(Date.class, dateField.getValue().getClass()); } 

Code in CoursesPanel constructs the JFormattedTextField, passing a SimpleDateFormat to its constructor. The code sends the message setValue to dateField in order to supply the Date object in which to store the edited results.

 JPanel createFieldsPanel() {    GridBagLayout layout = new GridBagLayout();    JPanel panel = new JPanel(layout);    int columns = 20;    addField(panel, layout, 0,       DEPARTMENT_LABEL_NAME, DEPARTMENT_LABEL_TEXT,       createField(DEPARTMENT_FIELD_NAME, columns));    addField(panel, layout, 1,       NUMBER_LABEL_NAME, NUMBER_LABEL_TEXT,       createField(NUMBER_FIELD_NAME, columns));    Format format = new SimpleDateFormat("MM/dd/yy");    JFormattedTextField dateField = new JFormattedTextField(format);    dateField.setValue(new Date());    dateField.setColumns(columns);    dateField.setName(EFFECTIVE_DATE_FIELD_NAME);    addField(panel, layout, 2,       EFFECTIVE_DATE_LABEL_NAME, EFFECTIVE_DATE_LABEL_TEXT,       dateField);    return panel; } 

If you execute the application with these changes, you will note that the effective date field allows you to type invalid input. When you leave the field, it reverts to a valid value. You can override this default behavior; see the API documentation for JFormattedTextField for the alternatives.

A design issue now exists. The code to create the formatted text field is in CoursesPanel and the associated test is in CoursesPanelTest. This contrasts with the goal I previously stated to manage edits at the application level!

You want to completely separate the view and application concerns. A solution involves the single responsibility principle. It will also eliminate some of the duplication and code clutter that I've allowed to fester in CoursesPanel and Sis.

A Field object is a data object whose attributes describe the information necessary to be able to create Swing text fields. A field is implementation-neutral, however, and has no knowledge of Swing. A FieldCatalog contains the collection of available fields. It can return a Field object given its name.

The CoursesPanel class needs only contain a list of field names that it must render. The CoursesPanel code can iterate through this list, asking a FieldCatalog for the corresponding Field object. It can then send the Field object to a factory, TextFieldFactory, whose job is to return a JTextField. The factory will take information from the Field object and use it to add various constraints on the JTextField, such as formats, filters, and length limits.

The code for the new classes follows. I also show the code in CoursesPanel that constructs the text fields.

 // FieldCatalogTest.java package sis.ui; import junit.framework.*; import static sis.ui.FieldCatalog.*; public class FieldCatalogTest extends TestCase {    public void testAllFields() {       FieldCatalog catalog = new FieldCatalog();       assertEquals(3, catalog.size());       Field field = catalog.get(NUMBER_FIELD_NAME);       assertEquals(DEFAULT_COLUMNS, field.getColumns());       assertEquals(NUMBER_LABEL_TEXT, field.getLabel());       assertEquals(NUMBER_FIELD_LIMIT, field.getLimit());       field = catalog.get(DEPARTMENT_FIELD_NAME);       assertEquals(DEFAULT_COLUMNS, field.getColumns());       assertEquals(DEPARTMENT_LABEL_TEXT, field.getLabel());       assertEquals(DEPARTMENT_FIELD_LIMIT, field.getLimit());       assertTrue(field.isUpcaseOnly());       field = catalog.get(EFFECTIVE_DATE_FIELD_NAME);       assertEquals(DEFAULT_COLUMNS, field.getColumns());       assertEquals(EFFECTIVE_DATE_LABEL_TEXT, field.getLabel());       assertSame(DEFAULT_DATE_FORMAT, field.getFormat());    } } // FieldCatalog.java package sis.ui; import java.util.*; import java.text.*; public class FieldCatalog {    public static final DateFormat DEFAULT_DATE_FORMAT =       new SimpleDateFormat("MM/dd/yy");    static final String DEPARTMENT_FIELD_NAME = "deptField";    static final String DEPARTMENT_LABEL_TEXT = "Department";    static final int DEPARTMENT_FIELD_LIMIT = 4;    static final String NUMBER_FIELD_NAME = "numberField";    static final String NUMBER_LABEL_TEXT = "Number";    static final int NUMBER_FIELD_LIMIT = 3;    static final String EFFECTIVE_DATE_FIELD_NAME = "effectiveDateField";    static final String EFFECTIVE_DATE_LABEL_TEXT = "Effective Date";    static final int DEFAULT_COLUMNS = 20;    private Map<String,Field> fields;    public FieldCatalog() {       loadFields();    }    public int size() {       return fields.size();    }    private void loadFields() {       fields = new HashMap<String,Field>();       Field fieldSpec = new Field(DEPARTMENT_FIELD_NAME);       fieldSpec.setLabel(DEPARTMENT_LABEL_TEXT);       fieldSpec.setLimit(DEPARTMENT_FIELD_LIMIT);       fieldSpec.setColumns(DEFAULT_COLUMNS);       fieldSpec.setUpcaseOnly();       put(fieldSpec);       fieldSpec = new Field(NUMBER_FIELD_NAME);       fieldSpec.setLabel(NUMBER_LABEL_TEXT);       fieldSpec.setLimit(NUMBER_FIELD_LIMIT);       fieldSpec.setColumns(DEFAULT_COLUMNS);       put(fieldSpec);       fieldSpec = new Field(EFFECTIVE_DATE_FIELD_NAME);       fieldSpec.setLabel(EFFECTIVE_DATE_LABEL_TEXT);       fieldSpec.setFormat(DEFAULT_DATE_FORMAT);       fieldSpec.setInitialValue(new Date());       fieldSpec.setColumns(DEFAULT_COLUMNS);       put(fieldSpec);    }    private void put(Field fieldSpec) {       fields.put(fieldSpec.getName(), fieldSpec);    }    public Field get(String fieldName) {       return fields.get(fieldName);    } } // TextFieldFactoryTest.java package sis.ui; import javax.swing.*; import javax.swing.text.*; import java.util.*; import java.text.*; import junit.framework.*; import sis.util.*; public class TextFieldFactoryTest extends TestCase {    private Field fieldSpec;    private static final String FIELD_NAME = "fieldName";    private static final int COLUMNS = 1;    protected void setUp() {       fieldSpec = new Field(FIELD_NAME);       fieldSpec.setColumns(COLUMNS);    }    public void testCreateSimpleField() {       final String textValue = "value";       fieldSpec.setInitialValue(textValue);       JTextField field = TextFieldFactory.create(fieldSpec);       assertEquals(COLUMNS, field.getColumns());       assertEquals(FIELD_NAME, field.getName());       assertEquals(textValue, field.getText());    }    public void testLimit() {       final int limit = 3;       fieldSpec.setLimit(limit);       JTextField field = TextFieldFactory.create(fieldSpec);       AbstractDocument document = (AbstractDocument)field.getDocument();       ChainableFilter filter =           (ChainableFilter)document.getDocumentFilter();       assertEquals(limit, ((LimitFilter)filter).getLimit());    }    public void testUpcase() {       fieldSpec.setUpcaseOnly();       JTextField field = TextFieldFactory.create(fieldSpec);       AbstractDocument document = (AbstractDocument)field.getDocument();       ChainableFilter filter =           (ChainableFilter)document.getDocumentFilter();       assertEquals(UpcaseFilter.class, filter.getClass());    }    public void testMultipleFilters() {       fieldSpec.setLimit(3);       fieldSpec.setUpcaseOnly();       JTextField field = TextFieldFactory.create(fieldSpec);       AbstractDocument document = (AbstractDocument)field.getDocument();       ChainableFilter filter =           (ChainableFilter)document.getDocumentFilter();       Set<Class> filters = new HashSet<Class>();       filters.add(filter.getClass());       filters.add(filter.getNext().getClass());       assertTrue(filters.contains(LimitFilter.class));       assertTrue(filters.contains(UpcaseFilter.class));    }    public void testCreateFormattedField() {       final int year = 2006;       final int month = 3;       final int day = 17;       fieldSpec.setInitialValue(DateUtil.createDate(year, month, day));       final String pattern = "MM/dd/yy";       fieldSpec.setFormat(new SimpleDateFormat(pattern));       JFormattedTextField field =          (JFormattedTextField)TextFieldFactory.create(fieldSpec);       assertEquals(1, field.getColumns());       assertEquals(FIELD_NAME, field.getName());       DateFormatter formatter = (DateFormatter)field.getFormatter();       SimpleDateFormat format = (SimpleDateFormat)formatter.getFormat();       assertEquals(pattern, format.toPattern());       assertEquals(Date.class, field.getValue().getClass());       assertEquals("03/17/06", field.getText());       TestUtil.assertDateEquals(year, month, day,           (Date)field.getValue()); // a new utility method    } } // TextFieldFactory.java package sis.ui; import javax.swing.*; import javax.swing.text.*; public class TextFieldFactory {    public static JTextField create(Field fieldSpec) {       JTextField field = null;       if (fieldSpec.getFormat() != null)          field = createFormattedTextField(fieldSpec);       else {          field = new JTextField();          if (fieldSpec.getInitialValue() != null)             field.setText(fieldSpec.getInitialValue().toString());       }       if (fieldSpec.getLimit() > 0)          attachLimitFilter(field, fieldSpec.getLimit());       if (fieldSpec.isUpcaseOnly())          attachUpcaseFilter(field);       field.setColumns(fieldSpec.getColumns());       field.setName(fieldSpec.getName());       return field;    }    private static void attachLimitFilter(JTextField field, int limit) {       attachFilter(field, new LimitFilter(limit));    }    private static void attachUpcaseFilter(JTextField field) {       attachFilter(field, new UpcaseFilter());    }    private static void attachFilter(          JTextField field, ChainableFilter filter) {       AbstractDocument document = (AbstractDocument)field.getDocument();       ChainableFilter existingFilter =           (ChainableFilter)document.getDocumentFilter();       if (existingFilter == null)          document.setDocumentFilter(filter);       else          existingFilter.setNext(filter);    }    private static JTextField createFormattedTextField(Field fieldSpec) {       JFormattedTextField field =           new JFormattedTextField(fieldSpec.getFormat());       field.setValue(fieldSpec.getInitialValue());       return field;    } } // CoursesPanelTest.java ... public void testCreate() {    assertEmptyList(COURSES_LIST_NAME);    assertButtonText(ADD_BUTTON_NAME, ADD_BUTTON_TEXT);    String[] fields =       { FieldCatalog.DEPARTMENT_FIELD_NAME,         FieldCatalog.NUMBER_FIELD_NAME,         FieldCatalog.EFFECTIVE_DATE_FIELD_NAME };    assertFields(fields);    JButton button = panel.getButton(ADD_BUTTON_NAME);    assertEquals(ADD_BUTTON_MNEMONIC, button.getMnemonic()); } private void assertFields(String[] fieldNames) {    FieldCatalog catalog = new FieldCatalog();    for (String fieldName: fieldNames) {       assertNotNull(panel.getField(fieldName));       // can't compare two JTextField items for equality,       // so we must go on faith here that CoursesPanel       // creates them using TextFieldFactory       Field fieldSpec = catalog.get(fieldName);       assertLabelText(fieldSpec.getLabelName(), fieldSpec.getLabel());    } } ... // CoursesPanel.java ... JPanel createFieldsPanel() {    GridBagLayout layout = new GridBagLayout();    JPanel panel = new JPanel(layout);    int i = 0;    FieldCatalog catalog = new FieldCatalog();    for (String fieldName: getFieldNames()) {       Field fieldSpec = catalog.get(fieldName);       addField(panel, layout, i++,                createLabel(fieldSpec),                TextFieldFactory.create(fieldSpec));    }    return panel; } private String[] getFieldNames() {    return new String[]       { FieldCatalog.DEPARTMENT_FIELD_NAME,         FieldCatalog.NUMBER_FIELD_NAME,         FieldCatalog.EFFECTIVE_DATE_FIELD_NAME }; } private void addField(       JPanel panel, GridBagLayout layout, int row,       JLabel label, JTextField field) {    ...    panel.add(label);    panel.add(field); } ... 

Notes:

  • TestUtil.assertDateEquals is a new utility method whose implementation should be obvious.

  • I finally moved the DateUtil class from the sis.studentinfo package to the sis.util package. This change impacts a number of existing classes.

  • You must also edit Sis and CoursesPanel (and tests) to remove constants and code for constructing filters/formatters. See http://www.LangrSoft.com/agileJava/code/ for full code.

  • The Field class, which is omitted from these listings, is a simple data class with virtually no logic.

  • You'll want to update the Course class to contain the new attribute, effective date.



Agile Java. Crafting Code with Test-Driven Development
Agile Javaв„ў: Crafting Code with Test-Driven Development
ISBN: 0131482394
EAN: 2147483647
Year: 2003
Pages: 391
Authors: Jeff Langr

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net