Permalink
Browse files

Automated pagination of JavaScript menus

Fixes #438
  • Loading branch information...
RussellLVP committed Jan 11, 2017
1 parent 508adc5 commit ff5366c2cdc0798f50de10166d587610f24b2ae0
@@ -15,13 +15,19 @@ const Dialog = require('components/dialogs/dialog.js');
// automatically split a dialog up in multiple dialogs when it doesn't fit in a single box. However,
// keep in mind that this does not provide a great user experience.
class Menu {
- constructor(title, columns = []) {
+ constructor(title, columns = [], { pageSize = 50 } = {}) {
if (!Array.isArray(columns) || columns.length > Menu.MAX_COLUMN_COUNT)
throw new Error('Menus cannot have more than ' + Menu.MAX_COLUMN_COUNT + ' columns.');
+ if (pageSize < 1 || pageSize > Menu.MAX_ROW_COUNT)
+ throw new Error('Menu pages must have between 1 and ' + Menu.MAX_ROW_COUNT + ' rows.');
+
this.title_ = title;
this.columns_ = columns;
this.items_ = [];
+
+ this.pageSize_ = pageSize;
+ this.pageCount_ = 1;
}
// ---------------------------------------------------------------------------------------------
@@ -42,43 +48,82 @@ class Menu {
labels: Array.prototype.slice.call(arguments, 0, columnCount),
listener: listener
});
+
+ // Recompute the number of pages that this menu exists of.
+ this.pageCount_ = Math.ceil(this.items_.length / this.pageSize_);
}
// Displays the menu to |player|. A promise will be returned that will resolve when the dialog
// has dismissed from their screen, even when they didn't make a selection. The promise will be
- // resolved with NULL when the player disconnects before submitting a response.
- async displayForPlayer(player) {
- const isList = this.columns_.length == 0;
- const result = await Dialog.displayMenu(
- player, isList, this.title_, this.buildContent(), 'Select', 'Cancel');
+ // resolved with NULL when the player disconnects before submitting a response. The |page|
+ // argument is one-based -- optimised for display as opposed to array indices.
+ async displayForPlayer(player, page = 1) {
+ for (; page <= this.pageCount_; ++page) {
+ const title = this.buildTitle(page);
+ const label = this.buildButtonLabel(page);
+ const content = this.buildContent(page);
+
+ const result = await Dialog.displayMenu(
+ player, !this.includeHeader(), title, content, 'Select', label);
+
+ if (!result)
+ return null; // the player has disconnected
+
+ if (result.response != Dialog.PRIMARY_BUTTON)
+ continue; // proceed to the next page
+
+ if (result.item < 0 || result.item >= this.pageSize_)
+ throw new Error('An out-of-bounds menu item has been selected by the player.');
+
+ const selectedItem = this.items_[((page - 1) * this.pageSize_) + result.item];
+ if (selectedItem.listener)
+ await selectedItem.listener(player);
- if (!result || result.response != Dialog.PRIMARY_BUTTON)
- return null;
+ return { player, item: selectedItem.labels };
+ }
- if (result.item < 0 || result.item >= this.items_.length)
- throw new Error('An out-of-bounds menu item has been selected by the player.');
+ // We're out of pages that can be displayed to the player.
+ return null;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Methods private to the Menu class.
+
+ // Returns whether the menu should be displayed with a header indicating the columns.
+ includeHeader() {
+ return this.columns_.length > 0;
+ }
- const selectedItem = this.items_[result.item];
- if (selectedItem.listener)
- await selectedItem.listener(player);
+ // Builds the title for the menu at the given |page|. Menus having multiple pages will have an
+ // indicator appended to the title to identify where the player is.
+ buildTitle(page) {
+ if (this.pageCount_ == 1)
+ return this.title_;
- return { player: player, item: selectedItem.labels };
+ return this.title_ + ' (page ' + page + ' of ' + this.pageCount_ + ')';
}
- // ----------------------------------------------------------------------------------------------
+ // Builds the label for the right-side button. For menus that do not utilize pagination this
+ // will simply be "Cancel", for menus with pagination it may be ">>>" (next) instead.
+ buildButtonLabel(page) {
+ if (this.pageCount_ == page)
+ return 'Cancel';
+
+ return '>>>';
+ }
- // Builds the content string for the menu in accordance with the syntax required for SA-MP's
- // DIALOG_STYLE_{LIST, TABLIST_HEADERS} dialog display styles.
+ // Builds the content for the menu at the given |page|. Headers will be repeated on every page,
+ // whereas a page only displays the relevant items. The string accords to the following syntax:
// http://wiki.sa-mp.com/wiki/Dialog_Styles#5_-_DIALOG_STYLE_TABLIST_HEADERS
- buildContent() {
+ buildContent(page) {
+ const offset = (page - 1) * this.pageSize_;
const rows = [];
- if (this.columns_.length > 0)
+ if (this.includeHeader())
rows.push(this.columns_.join('\t'));
- // Append each of the items to the rows to print.
- this.items_.forEach(item =>
- rows.push(item.labels.join('\t')));
+ for (const item of this.items_.slice(offset, offset + this.pageSize_))
+ rows.push(item.labels.join('\t'));
return rows.join('\n');
}
@@ -87,4 +132,7 @@ class Menu {
// Maximum number of columns that can be added to a menu.
Menu.MAX_COLUMN_COUNT = 4;
+// Maximum number of rows that can be displayed on a single menu page.
+Menu.MAX_ROW_COUNT = 100;
+
exports = Menu;
@@ -19,6 +19,20 @@ describe('Menu', it => {
});
});
+ it('should support page sizes between 1 and 100 rows', assert => {
+ assert.doesNotThrow(() => new Menu('My Menu', [], { pageSize: 1 }));
+ assert.doesNotThrow(() => new Menu('My Menu', [], { pageSize: 50 }));
+ assert.doesNotThrow(() => new Menu('My Menu', [], { pageSize: 100 }));
+
+ assert.throws(() => {
+ new Menu('My Menu', [], { pageSize: 0 });
+ });
+
+ assert.throws(() => {
+ new Menu('My Menu', [], { pageSize: 101 });
+ });
+ });
+
it('should throw for invalid user input', async(assert) => {
const gunther = server.playerManager.getById(0 /* Gunther */);
@@ -115,4 +129,58 @@ describe('Menu', it => {
assert.deepEqual(result, { player: gunther, item: ['Bar', '$20'] });
assert.strictEqual(listenerResult, 1);
});
+
+ it('should automatically paginate menus with more items than the page size', async(assert) => {
+ const gunther = server.playerManager.getById(0 /* Gunther */);
+
+ // To test the functionality of the listener as well.
+ let listenerResult = null;
+
+ const menu = new Menu('My Menu', ['Item', 'Price'], { pageSize: 2 });
+ menu.addItem('Foo', '$10', player => listenerResult = 0);
+ menu.addItem('Bar', '$20', player => listenerResult = 1);
+
+ menu.addItem('Baz', '$30', player => listenerResult = 2);
+ menu.addItem('Qux', '$40', player => listenerResult = 3);
+
+ menu.addItem('Corge', '$50', player => listenerResult = 4);
+ menu.addItem('Grault', '$60', player => listenerResult = 5);
+
+ const resultPromise = menu.displayForPlayer(gunther);
+
+ // Page 1 (Foo, Bar)
+ {
+ assert.equal(gunther.lastDialogTitle, 'My Menu (page 1 of 3)');
+ assert.equal(gunther.lastDialogStyle, DIALOG_STYLE_TABLIST_HEADERS);
+ assert.equal(gunther.lastDialogLabel, '>>>');
+ assert.equal(gunther.lastDialog, 'Item\tPrice\nFoo\t$10\nBar\t$20');
+
+ await gunther.respondToDialog({ response: 0 /* next page */, listitem: 0 });
+ }
+
+ // Page 2 (Baz, Qux)
+ {
+ assert.equal(gunther.lastDialogTitle, 'My Menu (page 2 of 3)');
+ assert.equal(gunther.lastDialogStyle, DIALOG_STYLE_TABLIST_HEADERS);
+ assert.equal(gunther.lastDialogLabel, '>>>');
+ assert.equal(gunther.lastDialog, 'Item\tPrice\nBaz\t$30\nQux\t$40');
+
+ await gunther.respondToDialog({ response: 0 /* next page */, listitem: 0 });
+ }
+
+ // Page 3 (Corge, Grault)
+ {
+ assert.equal(gunther.lastDialogTitle, 'My Menu (page 3 of 3)');
+ assert.equal(gunther.lastDialogStyle, DIALOG_STYLE_TABLIST_HEADERS);
+ assert.equal(gunther.lastDialogLabel, 'Cancel');
+ assert.equal(gunther.lastDialog, 'Item\tPrice\nCorge\t$50\nGrault\t$60');
+
+ gunther.respondToDialog({ response: 1, listitem: 1 /* Grault */ });
+ }
+
+ const result = await resultPromise;
+
+ assert.deepEqual(result, { player: gunther, item: ['Grault', '$60'] });
+ assert.strictEqual(listenerResult, 5);
+ });
});
@@ -37,6 +37,7 @@ class MockPlayer {
this.lastDialogId_ = null;
this.lastDialogTitle_ = null;
this.lastDialogStyle_ = null;
+ this.lastDialogLabel_ = null;
this.lastDialogMessage_ = null;
this.lastPlayedSound_ = null;
@@ -204,6 +205,7 @@ class MockPlayer {
this.lastDialogId_ = dialogId;
this.lastDialogTitle_ = caption;
this.lastDialogStyle_ = style;
+ this.lastDialogLabel_ = rightButton;
this.lastDialogMessage_ = message;
this.dialogPromiseResolve_();
@@ -213,6 +215,7 @@ class MockPlayer {
get lastDialog() { return this.lastDialogMessage_; }
get lastDialogTitle() { return this.lastDialogTitle_; }
get lastDialogStyle() { return this.lastDialogStyle_; }
+ get lastDialogLabel() { return this.lastDialogLabel_; }
// Sends |message| to the player. It will be stored in the local messages array and can be
// retrieved through the |messages| getter.

0 comments on commit ff5366c

Please sign in to comment.