Browse Source

Add a date range selector. Fixes #120.

Neal Wilson 10 years ago
parent
commit
9d84ef62b7
4 changed files with 449 additions and 2 deletions
  1. 4
    2
      src/Typica.pro
  2. 438
    0
      src/daterangeselector.w
  3. 4
    0
      src/resources.qrc
  4. 3
    0
      src/typica.w

+ 4
- 2
src/Typica.pro View File

@@ -23,7 +23,8 @@ HEADERS += moc_typica.cpp \
23 23
     webview.h \
24 24
     webelement.h \
25 25
     scale.h \
26
-    draglabel.h
26
+    draglabel.h \
27
+    daterangeselector.h
27 28
 SOURCES += typica.cpp \
28 29
     helpmenu.cpp \
29 30
     abouttypica.cpp \
@@ -31,7 +32,8 @@ SOURCES += typica.cpp \
31 32
     webview.cpp \
32 33
     webelement.cpp \
33 34
     scale.cpp \
34
-    draglabel.cpp
35
+    draglabel.cpp \
36
+    daterangeselector.cpp
35 37
 
36 38
 RESOURCES += \
37 39
     resources.qrc

+ 438
- 0
src/daterangeselector.w View File

@@ -0,0 +1,438 @@
1
+@** A Widget for Selecting Date Ranges.
2
+
3
+\noindent Many of the reports in Typica operate over a range of dates. In these
4
+cases it should generally be possible to set that range to any arbitrary start
5
+or end, however there are some ranges that are commonly useful where it may be
6
+convenient to provide easy access to that range. While Qt provides a widget
7
+for selecting a single date, it does not provide a widget that allows two dates
8
+to be conveniently selected. One approach which Typica has previously taken is
9
+to simply use two |QDateEdit| widgets. This works, however validation that the
10
+range is valid must then be performed in every report that uses such an
11
+approach. Another down side to this is that changing either side of the date
12
+range is either going to result in a database query to obtain results in the
13
+new range or another button must be introduced to make setting a new range
14
+explicit. One typically wants to adjust both sides of the range at the same
15
+time and only have one trip to the database for the new data and increasing the
16
+number of controls required for each filter quickly creates a mess.
17
+
18
+The solution to this is the introduction of a new composite widget for
19
+selecting date ranges. The main widget consists of two parts. First there is a
20
+|QComboBox| which contains many common date ranges. A |QToolButton| is also
21
+provided for convenient one click access to the Custom range. Whether selected
22
+from the |QComboBox| or the |QToolButton|, selecting Custom creates a new pop
23
+up widget containing two |QCalendarWidget|s and a button to explicitly set the
24
+range. This button will not be available unless the selected ending date is not
25
+before the selected starting date.
26
+
27
+As the common use for the selected date is database operations, convenient
28
+access to the ISO 8601 string representation of these dates is provided.
29
+
30
+@(daterangeselector.h@>=
31
+
32
+#include <QComboBox>
33
+
34
+#ifndef TypicaDateRangeSelectorHeader
35
+#define TypicaDateRangeSelectorHeader
36
+
37
+class CustomDateRangePopup;
38
+
39
+class DateRangeSelector : public QWidget
40
+{
41
+	@[Q_OBJECT@]@;
42
+	public:@/
43
+		DateRangeSelector(QWidget *parent = NULL);
44
+		void setCustomRange(QVariant range);
45
+		Q_INVOKABLE QVariant currentRange();@/
46
+	@[signals@]:@/
47
+		void rangeUpdated(QVariant);
48
+	@[private slots@]:@/
49
+		void toggleCustom();
50
+		void popupHidden();
51
+		void updateRange(int index);@/
52
+	private:@/
53
+		QComboBox *quickSelector;
54
+		CustomDateRangePopup *customRangeSelector;
55
+		int lastIndex;
56
+};
57
+
58
+#endif
59
+
60
+@ Implementation details are in a different file.
61
+
62
+@(daterangeselector.cpp@>=
63
+#include <QCalendarWidget>
64
+#include <QPushButton>
65
+#include <QBoxLayout>
66
+#include <QLabel>
67
+#include <QToolButton>
68
+#include <QApplication>
69
+#include <QDesktopWidget>
70
+
71
+#include "daterangeselector.h"
72
+
73
+@<CustomDateRangePopup declaration@>
74
+@<CustomDateRangePopup implementation@>
75
+@<DateRangeSelector implementation@>
76
+
77
+#include "moc_daterangeselector.cpp"
78
+
79
+@ The custom range pop up is represented as a separate class which is not to be
80
+instantiated except by |DateRangeSelector|.
81
+
82
+@<CustomDateRangePopup declaration@>=
83
+class CustomDateRangePopup : public QWidget
84
+{
85
+	@[Q_OBJECT@]@;
86
+	public:@/
87
+		CustomDateRangePopup(QWidget *parent = NULL);@/
88
+	@[public slots@]:@/
89
+		void applyRange();@/
90
+	@[signals@]:@/
91
+		void hidingPopup();@/
92
+	protected:@/
93
+		virtual void hideEvent(QHideEvent *event);@/
94
+	@[private slots@]:@/
95
+		void validateRange();@/
96
+	private:@/
97
+		QCalendarWidget *startDateSelector;
98
+		QCalendarWidget *endDateSelector;
99
+		QPushButton *applyButton;
100
+};
101
+
102
+@ The pop up constructor is responsible for laying out the component widgets,
103
+setting the dates selected in each calendar to match the currently selected
104
+range, and connecting the appropriate signal handlers.
105
+
106
+@<CustomDateRangePopup implementation@>=
107
+CustomDateRangePopup::CustomDateRangePopup(QWidget *parent) :
108
+	QWidget(parent, Qt::Popup), startDateSelector(new QCalendarWidget),
109
+	endDateSelector(new QCalendarWidget), applyButton(new QPushButton(tr("Apply")))
110
+{
111
+	setAttribute(Qt::WA_WindowPropagation);
112
+
113
+	QVBoxLayout *outerLayout = new QVBoxLayout;
114
+	QHBoxLayout *calendarsLayout = new QHBoxLayout;
115
+	QVBoxLayout *startDateLayout = new QVBoxLayout;
116
+	QVBoxLayout *endDateLayout = new QVBoxLayout;
117
+	QHBoxLayout *buttonLayout = new QHBoxLayout;
118
+	QLabel *startDateLabel = new QLabel(tr("From"));
119
+	QLabel *endDateLabel = new QLabel(tr("To"));
120
+	startDateSelector->setVerticalHeaderFormat(QCalendarWidget::NoVerticalHeader);
121
+	endDateSelector->setVerticalHeaderFormat(QCalendarWidget::NoVerticalHeader);
122
+	DateRangeSelector *selector = qobject_cast<DateRangeSelector *>(parent);
123
+	if(parent) {
124
+		QStringList range = selector->currentRange().toStringList();
125
+		startDateSelector->setSelectedDate(QDate::fromString(range.first(), Qt::ISODate));
126
+		endDateSelector->setSelectedDate(QDate::fromString(range.last(), Qt::ISODate));
127
+	}
128
+	connect(startDateSelector, SIGNAL(selectionChanged()), this, SLOT(validateRange()));
129
+	connect(endDateSelector, SIGNAL(selectionChanged()), this, SLOT(validateRange()));
130
+
131
+	startDateLayout->addWidget(startDateLabel);
132
+	startDateLayout->addWidget(startDateSelector);
133
+	endDateLayout->addWidget(endDateLabel);
134
+	endDateLayout->addWidget(endDateSelector);
135
+
136
+	connect(applyButton, SIGNAL(clicked()), this, SLOT(applyRange()));
137
+
138
+	buttonLayout->addStretch();
139
+	buttonLayout->addWidget(applyButton);
140
+
141
+	calendarsLayout->addLayout(startDateLayout);
142
+	calendarsLayout->addLayout(endDateLayout);
143
+	outerLayout->addLayout(calendarsLayout);
144
+	outerLayout->addLayout(buttonLayout);
145
+	setLayout(outerLayout);
146
+}
147
+
148
+@ The pop up can be hidden in two ways. Clicking anywhere outside of the widget
149
+will hide the pop up. Clicking the Apply button will also hide the pop up. In
150
+the former case, we must inform the parent widget that it is fine to destroy
151
+the pop up widget, which we do by emitting a signal. Note that clicking outside
152
+of the widget will cause the |QHideEvent| to be posted automatically.
153
+
154
+@<CustomDateRangePopup implementation@>=
155
+void CustomDateRangePopup::hideEvent(QHideEvent *)
156
+{
157
+	emit hidingPopup();
158
+}
159
+
160
+@ Clicking the Apply button requires setting the Custom date range to the
161
+currently selected range and then hiding the pop up manually.
162
+
163
+@<CustomDateRangePopup implementation@>=
164
+void CustomDateRangePopup::applyRange()
165
+{
166
+	DateRangeSelector *selector = qobject_cast<DateRangeSelector *>(parentWidget());
167
+	if(selector)
168
+	{
169
+		selector->setCustomRange(QVariant(QStringList() <<
170
+			startDateSelector->selectedDate().toString(Qt::ISODate) <<
171
+			endDateSelector->selectedDate().toString(Qt::ISODate)));
172
+	}
173
+	hide();
174
+}
175
+
176
+@ The Apply button is enabled or disabled depending on if the currently
177
+selected dates form a valid range in which the end date does not occur before
178
+the start date.
179
+
180
+@<CustomDateRangePopup implementation@>=
181
+void CustomDateRangePopup::validateRange()
182
+{
183
+	if(startDateSelector->selectedDate() > endDateSelector->selectedDate())
184
+	{
185
+		applyButton->setEnabled(false);
186
+	}
187
+	else
188
+	{
189
+		applyButton->setEnabled(true);
190
+	}
191
+}
192
+
193
+@ The |DateRangeSelector| constructor is responsible for setting up the layout
194
+of the |QComboBox| and the |QToolButton|, adding appropriate items to the
195
+|QComboBox|, and connecting the signals required to handle the pop up
196
+correctly.
197
+
198
+@<DateRangeSelector implementation@>=
199
+DateRangeSelector::DateRangeSelector(QWidget *parent) :
200
+	QWidget(parent), quickSelector(new QComboBox(this)),
201
+	customRangeSelector(NULL), lastIndex(0)
202
+{
203
+	connect(quickSelector, SIGNAL(currentIndexChanged(int)), this, SLOT(updateRange(int)));
204
+
205
+	QDate currentDate = QDate::currentDate();
206
+
207
+	QHBoxLayout *layout = new QHBoxLayout;
208
+	@<Set common date ranges to quick selector@>@;
209
+	QToolButton *customButton = new QToolButton;
210
+	customButton->setIcon(QIcon::fromTheme("office-calendar",
211
+		QIcon(":/resources/icons/tango/scalable/apps/office-calendar.svg")));
212
+	layout->addWidget(quickSelector);
213
+	layout->addWidget(customButton);
214
+	setLayout(layout);
215
+
216
+	connect(customButton, SIGNAL(clicked()), this, SLOT(toggleCustom()));
217
+}
218
+
219
+@ The |QComboBox| provides a mechanism for associating additional data with an
220
+item. Several possible representations were considered, but what was ultimately
221
+selected was a |QVariant| containing a |QStringList| in which the first entry
222
+in the list is the starting date of the range and the last entry in the list is
223
+the ending date of the range. Note that the list may contain only one item in
224
+cases where the range only covers a single date, however one should not assume
225
+that a range covering a single date will only have a single list entry.
226
+
227
+@<Set common date ranges to quick selector@>=
228
+quickSelector->addItem("Yesterday", QVariant(QStringList() <<
229
+	currentDate.addDays(-1).toString(Qt::ISODate)));
230
+quickSelector->addItem("Today", QVariant(QStringList() <<
231
+	currentDate.toString(Qt::ISODate)));
232
+quickSelector->insertSeparator(quickSelector->count());
233
+quickSelector->addItem("This Week", QVariant(QStringList() <<
234
+	(currentDate.dayOfWeek() % 7 ?
235
+		currentDate.addDays(-currentDate.dayOfWeek()).toString(Qt::ISODate) :
236
+		currentDate.toString(Qt::ISODate)) <<
237
+			currentDate.addDays(6 - (currentDate.dayOfWeek() % 7)).toString(Qt::ISODate)));
238
+quickSelector->addItem("This Week to Date", currentDate.dayOfWeek() % 7 ?
239
+	QVariant(QStringList() <<
240
+	currentDate.addDays(-currentDate.dayOfWeek()).toString(Qt::ISODate) <<
241
+	currentDate.toString(Qt::ISODate)) :
242
+	QVariant(QStringList() << currentDate.toString(Qt::ISODate)));
243
+quickSelector->addItem("Last Week", QVariant(QStringList() <<
244
+	currentDate.addDays(-(currentDate.dayOfWeek() % 7) - 7).toString(Qt::ISODate) <<
245
+	currentDate.addDays(-(currentDate.dayOfWeek() % 7) - 1).toString(Qt::ISODate)));
246
+quickSelector->addItem("Last 7 Days", QVariant(QStringList() <<
247
+	currentDate.addDays(-6).toString(Qt::ISODate) <<
248
+	currentDate.toString(Qt::ISODate)));
249
+quickSelector->insertSeparator(quickSelector->count());
250
+quickSelector->addItem("This Month", QVariant(QStringList() <<
251
+	QDate(currentDate.year(), currentDate.month(), 1).toString(Qt::ISODate) <<
252
+	QDate(currentDate.year(), currentDate.month(),
253
+		currentDate.daysInMonth()).toString(Qt::ISODate)));
254
+quickSelector->addItem("This Month to Date", (currentDate.day() == 1 ?
255
+	(QVariant(QStringList() << currentDate.toString(Qt::ISODate))) :
256
+	(QVariant(QStringList() <<
257
+	QDate(currentDate.year(), currentDate.month(), 1).toString(Qt::ISODate) <<
258
+	currentDate.toString(Qt::ISODate)))));
259
+quickSelector->addItem("Last Four Weeks", QVariant(QStringList() <<
260
+	currentDate.addDays(-27).toString(Qt::ISODate) <<
261
+	currentDate.toString(Qt::ISODate)));
262
+quickSelector->addItem("Last 30 Days", QVariant(QStringList() <<
263
+	currentDate.addDays(-29).toString(Qt::ISODate) <<
264
+	currentDate.toString(Qt::ISODate)));
265
+quickSelector->insertSeparator(quickSelector->count());
266
+quickSelector->addItem("This Quarter", QVariant(QStringList() <<
267
+	QDate(currentDate.year(), currentDate.month() - ((currentDate.month() - 1) % 3), 1).toString(Qt::ISODate) <<
268
+	(currentDate.month() > 9 ?
269
+		QDate(currentDate.year(), 12, 31).toString(Qt::ISODate) :
270
+		QDate(currentDate.year(), currentDate.month() - ((currentDate.month() - 1) % 3) + 3, 1).addDays(-1).toString(Qt::ISODate))));
271
+quickSelector->addItem("This Quarter to Date",
272
+	(currentDate.day() == 1 && (currentDate.month() - 1) % 3 == 0) ?
273
+		QVariant(QStringList() << currentDate.toString(Qt::ISODate)) :
274
+		QVariant(QStringList() <<
275
+		QDate(currentDate.year(), currentDate.month() - ((currentDate.month() - 1) % 3), 1).toString(Qt::ISODate) <<
276
+		currentDate.toString(Qt::ISODate)));
277
+quickSelector->addItem("Last Quarter", currentDate.month() < 4 ?
278
+	QVariant(QStringList() <<
279
+		QDate(currentDate.year() - 1, 10, 1).toString(Qt::ISODate) <<
280
+		QDate(currentDate.year() - 1, 12, 31).toString(Qt::ISODate)) :
281
+	QVariant(QStringList() <<
282
+		QDate(currentDate.year(), currentDate.month() - ((currentDate.month() - 1) % 3) - 3, 1).toString(Qt::ISODate) <<
283
+		QDate(currentDate.year(), currentDate.month() - ((currentDate.month() - 1) % 3), 1).addDays(-1).toString(Qt::ISODate)));
284
+quickSelector->addItem("Last 90 Days", QVariant(QStringList() <<
285
+	currentDate.addDays(-89).toString(Qt::ISODate) <<
286
+	currentDate.toString(Qt::ISODate)));
287
+quickSelector->insertSeparator(quickSelector->count());
288
+quickSelector->addItem("This Year", QVariant(QStringList() <<
289
+	QDate(currentDate.year(), 1, 1).toString(Qt::ISODate) <<
290
+	QDate(currentDate.year(), 12, 31).toString(Qt::ISODate)));
291
+quickSelector->addItem("This Year to Date", (currentDate.dayOfYear() == 1) ?
292
+	QVariant(QStringList() << currentDate.toString(Qt::ISODate)) :
293
+	QVariant(QStringList() << QDate(currentDate.year(), 1, 1).toString(Qt::ISODate) <<
294
+	currentDate.toString(Qt::ISODate)));
295
+quickSelector->addItem("Last Year", QVariant(QStringList() <<
296
+	QDate(currentDate.year() - 1, 1, 1).toString(Qt::ISODate) <<
297
+	QDate(currentDate.year() - 1, 12, 31).toString(Qt::ISODate)));
298
+quickSelector->addItem("Last 365 Days", QVariant(QStringList() <<
299
+	currentDate.addDays(-364).toString(Qt::ISODate) <<
300
+	currentDate.toString(Qt::ISODate)));
301
+quickSelector->insertSeparator(quickSelector->count());
302
+quickSelector->addItem("Custom");
303
+
304
+@ Special handling of the Custom range is required because it is possible to
305
+select this from the |QComboBox| and then not set a range. This should result
306
+in the selection changing back to the most recent valid selection. Creating the
307
+pop up in this way is handled in |updateRange()|.
308
+
309
+@<DateRangeSelector implementation@>=
310
+void DateRangeSelector::updateRange(int index)
311
+{
312
+	if(index != lastIndex && index == quickSelector->count() - 1)
313
+	{
314
+		toggleCustom();
315
+	}
316
+	else
317
+	{
318
+		lastIndex = index;
319
+		emit rangeUpdated(quickSelector->itemData(quickSelector->currentIndex()));
320
+	}
321
+}
322
+
323
+@ Resetting the range to the most recent valid selection is handled in
324
+|popupHidden()|.
325
+
326
+@<DateRangeSelector implementation@>=
327
+void DateRangeSelector::popupHidden()
328
+{
329
+	customRangeSelector->deleteLater();
330
+	customRangeSelector = NULL;
331
+	quickSelector->setCurrentIndex(lastIndex);
332
+}
333
+
334
+@ If Custom is set to a new valid range, |lastIndex| will have been set to
335
+point to the appropriate item by a call to |setCustomRange()|.
336
+
337
+@<DateRangeSelector implementation@>=
338
+void DateRangeSelector::setCustomRange(QVariant range)
339
+{
340
+	quickSelector->setItemData(quickSelector->count() - 1, range);
341
+	emit rangeUpdated(range);
342
+	lastIndex = quickSelector->count() - 1;
343
+	quickSelector->setCurrentIndex(lastIndex);
344
+}
345
+
346
+@ When creating the pop up, it should ideally be placed such that the left of
347
+the pop up is aligned with the left of the widget that is normally shown and
348
+immediately under it, however if this would result in part of the pop up not
349
+fitting on the same screen, it should be moved to make a best effort at full
350
+visibility.
351
+
352
+@<DateRangeSelector implementation@>=
353
+void DateRangeSelector::toggleCustom()
354
+{
355
+	if(!customRangeSelector) {
356
+		customRangeSelector = new CustomDateRangePopup(this);
357
+		QPoint pos = rect().bottomLeft();
358
+		QPoint pos2 = rect().topLeft();
359
+		pos = mapToGlobal(pos);
360
+		pos2 = mapToGlobal(pos2);
361
+		QSize size = customRangeSelector->sizeHint();
362
+		QRect screen = QApplication::desktop()->availableGeometry(pos);
363
+		if(pos.x()+size.width() > screen.right()) {
364
+			pos.setX(screen.right()-size.width());
365
+		}
366
+		pos.setX(qMax(pos.x(), screen.left()));
367
+		if(pos.y() + size.height() > screen.bottom()) {
368
+			pos.setY(pos2.y() - size.height());
369
+		} else if (pos.y() < screen.top()){
370
+			pos.setY(screen.top());
371
+		}
372
+		if(pos.y() < screen.top()) {
373
+			pos.setY(screen.top());
374
+		}
375
+		if(pos.y()+size.height() > screen.bottom()) {
376
+			pos.setY(screen.bottom()-size.height());
377
+		}
378
+		customRangeSelector->move(pos);
379
+		customRangeSelector->show();
380
+		connect(customRangeSelector, SIGNAL(hidingPopup()),
381
+				this, SLOT(popupHidden()));
382
+    }
383
+	else
384
+	{
385
+		customRangeSelector->close();
386
+		customRangeSelector->deleteLater();
387
+		customRangeSelector = NULL;
388
+	}
389
+}
390
+
391
+@ While a signal is emitted when the selected range changes, it is frequently
392
+convenient to have a way to request the currently selected range at any time.
393
+
394
+@<DateRangeSelector implementation@>=
395
+QVariant DateRangeSelector::currentRange()
396
+{
397
+	return quickSelector->itemData(lastIndex);
398
+}
399
+
400
+@ To use this new control in Typica, we should provide a way to create it from
401
+the XML description of a window.
402
+
403
+@<Additional box layout elements@>=
404
+else if(currentElement.tagName() == "daterange")
405
+{
406
+	addDateRangeToLayout(currentElement, widgetStack, layoutStack);
407
+}
408
+
409
+@ The method for adding a date range selector to a layout is currently trivial.
410
+Additional features may be added to this in the future to provide better
411
+support for specifying an initial default selection. At present the only
412
+supported attribute is the |"id"| attribute which is used to make the widget
413
+accessible to scripts.
414
+
415
+@<Functions for scripting@>=
416
+void addDateRangeToLayout(QDomElement element, QStack<QWidget *> *,@|
417
+                          QStack<QLayout *> *layoutStack)
418
+{
419
+	DateRangeSelector *widget = new DateRangeSelector;
420
+	if(element.hasAttribute("id"))
421
+	{
422
+		widget->setObjectName(element.attribute("id"));
423
+	}
424
+	QBoxLayout *layout = qobject_cast<QBoxLayout *>(layoutStack->top());
425
+	layout->addWidget(widget);
426
+}
427
+
428
+@ The prototype needs to be specified.
429
+
430
+@<Function prototypes for scripting@>=
431
+void addDateRangeToLayout(QDomElement element,
432
+                          QStack<QWidget *> *widgetStack,
433
+                          QStack<QLayout *> *layoutStack);
434
+
435
+@ Our header is also required.
436
+
437
+@<Header files to include@>=
438
+#include "daterangeselector.h"

+ 4
- 0
src/resources.qrc View File

@@ -23,5 +23,9 @@
23 23
         <file>resources/icons/tango/22x22/categories/applications-graphics.png</file>
24 24
         <file>resources/icons/tango/32x32/categories/applications-graphics.png</file>
25 25
         <file>resources/icons/tango/scalable/categories/applications-graphics.svg</file>
26
+        <file>resources/icons/tango/16x16/apps/office-calendar.png</file>
27
+        <file>resources/icons/tango/22x22/apps/office-calendar.png</file>
28
+        <file>resources/icons/tango/32x32/apps/office-calendar.png</file>
29
+        <file>resources/icons/tango/scalable/apps/office-calendar.svg</file>
26 30
     </qresource>
27 31
 </RCC>

+ 3
- 0
src/typica.w View File

@@ -4263,6 +4263,7 @@ void populateBoxLayout(QDomElement element, QStack<QWidget *> *widgetStack,
4263 4263
 				QBoxLayout *layout = qobject_cast<QBoxLayout *>(layoutStack->top());
4264 4264
 				layout->addStretch();
4265 4265
 			}
4266
+			@<Additional box layout elements@>@;
4266 4267
 		}
4267 4268
 	}
4268 4269
 }
@@ -13268,6 +13269,8 @@ void setQTextEditProperties(QScriptValue value, QScriptEngine *engine)
13268 13269
 	value.setProperty("print", engine->newFunction(QTextEdit_print));
13269 13270
 }
13270 13271
 
13272
+@i daterangeselector.w
13273
+
13271 13274
 @** An area for repeated user interface elements.
13272 13275
 
13273 13276
 \noindent There are multiple use cases in which it is useful to specify a

Loading…
Cancel
Save