From 8a96cf3434d4f4fce7be7fdffee07cef0c3a2d09 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 22 Feb 2018 18:58:15 +0000 Subject: [PATCH] All: Allow sorting notes by various fields --- ElectronClient/app/app.js | 39 +++++++++ ElectronClient/app/gui/NoteText.jsx | 2 +- ReactNativeClient/lib/BaseApplication.js | 39 +++++---- .../lib/components/ModalDialog.js | 81 +++++++++++++++++++ .../lib/components/screen-header.js | 12 +++ .../lib/components/screens/note.js | 6 +- .../lib/components/screens/notes.js | 46 ++++++++++- ReactNativeClient/lib/dialogs.js | 8 +- ReactNativeClient/lib/models/Note.js | 10 +++ ReactNativeClient/lib/models/Setting.js | 26 +++--- ReactNativeClient/lib/reducer.js | 26 +++--- ReactNativeClient/lib/string-utils.js | 7 +- joplin.sublime-project | 1 + 13 files changed, 252 insertions(+), 51 deletions(-) create mode 100644 ReactNativeClient/lib/components/ModalDialog.js diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index 85a03797e..85554e118 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -175,6 +175,22 @@ class Application extends BaseApplication { updateMenu(screen) { if (this.lastMenuScreen_ === screen) return; + const sortNoteItems = []; + const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field'); + for (let field in sortNoteOptions) { + if (!sortNoteOptions.hasOwnProperty(field)) continue; + sortNoteItems.push({ + label: sortNoteOptions[field], + screens: ['Main'], + type: 'checkbox', + checked: Setting.value('notes.sortOrder.field') === field, + click: () => { + Setting.setValue('notes.sortOrder.field', field); + this.refreshMenu(); + } + }); + } + const template = [ { label: _('File'), @@ -287,6 +303,29 @@ class Application extends BaseApplication { name: 'toggleVisiblePanes', }); } + }, { + type: 'separator', + screens: ['Main'], + }, { + label: Setting.settingMetadata('notes.sortOrder.field').label(), + screens: ['Main'], + submenu: sortNoteItems, + }, { + label: Setting.settingMetadata('notes.sortOrder.reverse').label(), + type: 'checkbox', + checked: Setting.setValue('notes.sortOrder.reverse'), + screens: ['Main'], + click: () => { + Setting.setValue('notes.sortOrder.reverse', !Setting.value('notes.sortOrder.reverse')); + }, + }, { + label: Setting.settingMetadata('uncompletedTodosOnTop').label(), + type: 'checkbox', + checked: Setting.setValue('uncompletedTodosOnTop'), + screens: ['Main'], + click: () => { + Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop')); + }, }], }, { label: _('Tools'), diff --git a/ElectronClient/app/gui/NoteText.jsx b/ElectronClient/app/gui/NoteText.jsx index b10b73f1a..2e052951c 100644 --- a/ElectronClient/app/gui/NoteText.jsx +++ b/ElectronClient/app/gui/NoteText.jsx @@ -604,7 +604,7 @@ class NoteTextComponent extends React.Component { let bodyToRender = body; if (!bodyToRender.trim() && visiblePanes.indexOf('viewer') >= 0 && visiblePanes.indexOf('editor') < 0) { // Fixes https://github.com/laurent22/joplin/issues/217 - bodyToRender = '*' + _('This not has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*'; + bodyToRender = '*' + _('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*'; } const html = this.mdToHtml().render(bodyToRender, theme, mdOptions); diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index 839c38816..ed4b16a24 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -1,5 +1,5 @@ const { createStore, applyMiddleware } = require('redux'); -const { reducer, defaultState } = require('lib/reducer.js'); +const { reducer, defaultState, stateUtils } = require('lib/reducer.js'); const { JoplinDatabase } = require('lib/joplin-database.js'); const { Database } = require('lib/database.js'); const { FoldersScreenUtils } = require('lib/folders-screen-utils.js'); @@ -184,8 +184,9 @@ class BaseApplication { this.logger().debug('Refreshing notes:', parentType, parentId); let options = { - order: state.notesOrder, + order: stateUtils.notesOrder(state.settings), uncompletedTodosOnTop: Setting.value('uncompletedTodosOnTop'), + caseInsensitive: true, }; const source = JSON.stringify({ @@ -255,14 +256,31 @@ class BaseApplication { const result = next(action); const newState = store.getState(); + let refreshNotes = false; if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE') { Setting.setValue('activeFolderId', newState.selectedFolderId); this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null; - await this.refreshNotes(newState); + refreshNotes = true; } - if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop' || action.type == 'SETTING_UPDATE_ALL') { + if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) { + refreshNotes = true; + } + + if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('notes.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) { + refreshNotes = true; + } + + if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') { + refreshNotes = true; + } + + if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') { + refreshNotes = true; + } + + if (refreshNotes) { await this.refreshNotes(newState); } @@ -288,14 +306,6 @@ class BaseApplication { } } - if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') { - await this.refreshNotes(newState); - } - - if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') { - await this.refreshNotes(newState); - } - if (action.type === 'NOTE_UPDATE_ONE') { // If there is a conflict, we refresh the folders so as to display "Conflicts" folder if (action.note && action.note.is_conflict) { @@ -303,11 +313,6 @@ class BaseApplication { } } - // if (action.type === 'NOTE_DELETE') { - // // Update folders if a note is deleted in case the deleted note was a conflict - // await FoldersScreenUtils.refreshFolders(); - // } - if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTING_UPDATE_ALL') { reg.setupRecurrentSync(); } diff --git a/ReactNativeClient/lib/components/ModalDialog.js b/ReactNativeClient/lib/components/ModalDialog.js new file mode 100644 index 000000000..2ecc2c3e3 --- /dev/null +++ b/ReactNativeClient/lib/components/ModalDialog.js @@ -0,0 +1,81 @@ +const React = require('react'); +const { Text, Modal, View, StyleSheet, Button } = require('react-native'); +const { themeStyle } = require('lib/components/global-style.js'); +const { _ } = require('lib/locale'); + +class ModalDialog extends React.Component { + + constructor() { + super(); + this.styles_ = {}; + } + + styles() { + const themeId = this.props.theme; + const theme = themeStyle(themeId); + + if (this.styles_[themeId]) return this.styles_[themeId]; + this.styles_ = {}; + + let styles = { + modalWrapper: { + flex: 1, + justifyContent: 'center', + }, + modalContentWrapper: { + flex:1, + flexDirection: 'column', + backgroundColor: theme.backgroundColor, + borderWidth: 1, + borderColor:theme.dividerColor, + margin: 20, + padding: 10, + }, + modalContentWrapper2: { + paddingTop: 10, + flex:1, + }, + title: { + borderBottomWidth: 1, + borderBottomColor: theme.dividerColor, + paddingBottom: 10, + }, + buttonRow: { + flexDirection: 'row', + borderTopWidth: 1, + borderTopColor: theme.dividerColor, + paddingTop: 10, + }, + }; + + this.styles_[themeId] = StyleSheet.create(styles); + return this.styles_[themeId]; + } + + render() { + const ContentComponent = this.props.ContentComponent; + + return ( + + { }} > + + Title + + {ContentComponent} + + + + + + + + + + + + + ); + } +} + +module.exports = ModalDialog; \ No newline at end of file diff --git a/ReactNativeClient/lib/components/screen-header.js b/ReactNativeClient/lib/components/screen-header.js index 2b9ec0a40..af0d123a7 100644 --- a/ReactNativeClient/lib/components/screen-header.js +++ b/ReactNativeClient/lib/components/screen-header.js @@ -283,6 +283,16 @@ class ScreenHeaderComponent extends Component { ); } + function sortButton(styles, onPress) { + return ( + + + + + + ); + } + let key = 0; let menuOptionComponents = []; @@ -424,6 +434,7 @@ class ScreenHeaderComponent extends Component { const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack); const searchButtonComp = this.props.noteSelectionEnabled ? null : searchButton(this.styles(), () => this.searchButton_press()); const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press()) : null; + const sortButtonComp = this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null; const windowHeight = Dimensions.get('window').height - 50; const menuComp = ( @@ -448,6 +459,7 @@ class ScreenHeaderComponent extends Component { { titleComp } { searchButtonComp } { deleteButtonComp } + { sortButtonComp } { menuComp } { warningComp } diff --git a/ReactNativeClient/lib/components/screens/note.js b/ReactNativeClient/lib/components/screens/note.js index cd6f0e138..42c8626dc 100644 --- a/ReactNativeClient/lib/components/screens/note.js +++ b/ReactNativeClient/lib/components/screens/note.js @@ -65,9 +65,9 @@ class NoteScreenComponent extends BaseScreenComponent { const saveDialog = async () => { if (this.isModified()) { let buttonId = await dialogs.pop(this, _('This note has been modified:'), [ - { title: _('Save changes'), id: 'save' }, - { title: _('Discard changes'), id: 'discard' }, - { title: _('Cancel'), id: 'cancel' }, + { text: _('Save changes'), id: 'save' }, + { text: _('Discard changes'), id: 'discard' }, + { text: _('Cancel'), id: 'cancel' }, ]); if (buttonId == 'cancel') return true; diff --git a/ReactNativeClient/lib/components/screens/notes.js b/ReactNativeClient/lib/components/screens/notes.js index 4254f7325..3aa856c25 100644 --- a/ReactNativeClient/lib/components/screens/notes.js +++ b/ReactNativeClient/lib/components/screens/notes.js @@ -1,5 +1,6 @@ const React = require('react'); const Component = React.Component; -const { View, Button } = require('react-native'); +const { View, Button, Text } = require('react-native'); +const { stateUtils } = require('lib/reducer.js'); const { connect } = require('react-redux'); const { reg } = require('lib/registry.js'); const { Log } = require('lib/log.js'); @@ -10,7 +11,7 @@ const Note = require('lib/models/Note.js'); const Setting = require('lib/models/Setting.js'); const { themeStyle } = require('lib/components/global-style.js'); const { ScreenHeader } = require('lib/components/screen-header.js'); -const { MenuOption, Text } = require('react-native-popup-menu'); +const { MenuOption } = require('react-native-popup-menu'); const { _ } = require('lib/locale.js'); const { ActionButton } = require('lib/components/action-button.js'); const { dialogs } = require('lib/dialogs.js'); @@ -23,6 +24,43 @@ class NotesScreenComponent extends BaseScreenComponent { return { header: null }; } + constructor() { + super(); + + this.sortButton_press = async () => { + const buttons = []; + const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field'); + + const makeCheckboxText = function(selected, sign, label) { + const s = sign === 'tick' ? '✓' : '⬤' + return (selected ? (s + ' ') : '') + label; + } + + for (let field in sortNoteOptions) { + if (!sortNoteOptions.hasOwnProperty(field)) continue; + buttons.push({ + text: makeCheckboxText(Setting.value('notes.sortOrder.field') === field, 'bullet', sortNoteOptions[field]), + id: { name: 'notes.sortOrder.field', value: field }, + }); + } + + buttons.push({ + text: makeCheckboxText(Setting.value('notes.sortOrder.reverse'), 'tick', '[ ' + Setting.settingMetadata('notes.sortOrder.reverse').label() + ' ]'), + id: { name: 'notes.sortOrder.reverse', value: !Setting.value('notes.sortOrder.reverse') }, + }); + + buttons.push({ + text: makeCheckboxText(Setting.value('uncompletedTodosOnTop'), 'tick', '[ ' + Setting.settingMetadata('uncompletedTodosOnTop').label() + ' ]'), + id: { name: 'uncompletedTodosOnTop', value: !Setting.value('uncompletedTodosOnTop') }, + }); + + const r = await dialogs.pop(this, Setting.settingMetadata('notes.sortOrder.field').label(), buttons); + if (!r) return; + + Setting.setValue(r.name, r.value); + } + } + async componentDidMount() { await this.refreshNotes(); } @@ -42,6 +80,7 @@ class NotesScreenComponent extends BaseScreenComponent { let options = { order: props.notesOrder, uncompletedTodosOnTop: props.uncompletedTodosOnTop, + caseInsensitive: true, }; const parent = this.parentItem(props); @@ -155,6 +194,7 @@ class NotesScreenComponent extends BaseScreenComponent { title={title} menuOptions={this.menuOptions()} parentComponent={thisComp} + sortButton_press={this.sortButton_press} folderPickerOptions={{ enabled: this.props.noteSelectionEnabled, mustSelect: true, @@ -178,11 +218,11 @@ const NotesScreen = connect( selectedTagId: state.selectedTagId, notesParentType: state.notesParentType, notes: state.notes, - notesOrder: state.notesOrder, notesSource: state.notesSource, uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, theme: state.settings.theme, noteSelectionEnabled: state.noteSelectionEnabled, + notesOrder: stateUtils.notesOrder(state.settings), }; } )(NotesScreenComponent) diff --git a/ReactNativeClient/lib/dialogs.js b/ReactNativeClient/lib/dialogs.js index 4a8402a6c..f9ea0fca5 100644 --- a/ReactNativeClient/lib/dialogs.js +++ b/ReactNativeClient/lib/dialogs.js @@ -33,17 +33,20 @@ dialogs.confirm = (parentComponent, message) => { }); }; -dialogs.pop = (parentComponent, message, buttons) => { +dialogs.pop = (parentComponent, message, buttons, options = null) => { if (!parentComponent) throw new Error('parentComponent is required'); if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!'); + if (!options) options = {}; + if (!('buttonFlow' in options)) options.buttonFlow = 'auto'; + return new Promise((resolve, reject) => { Keyboard.dismiss(); let btns = []; for (let i = 0; i < buttons.length; i++) { btns.push({ - text: buttons[i].title, + text: buttons[i].text, callback: () => { parentComponent.dialogbox.close(); resolve(buttons[i].id); @@ -54,6 +57,7 @@ dialogs.pop = (parentComponent, message, buttons) => { parentComponent.dialogbox.pop({ content: message, btns: btns, + buttonFlow: options.buttonFlow, }); }); } diff --git a/ReactNativeClient/lib/models/Note.js b/ReactNativeClient/lib/models/Note.js index 687f6b9c3..7318a0f2c 100644 --- a/ReactNativeClient/lib/models/Note.js +++ b/ReactNativeClient/lib/models/Note.js @@ -15,6 +15,16 @@ class Note extends BaseItem { return 'notes'; } + static fieldToLabel(field) { + const fieldsToLabels = { + title: 'title', + user_updated_time: 'updated date', + user_created_time: 'created date', + }; + + return field in fieldsToLabels ? fieldsToLabels[field] : field; + } + static async serialize(note, type = null, shownKeys = null) { let fieldNames = this.fieldNames(); fieldNames.push('type_'); diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js index ba1df8ddd..ac81267a1 100644 --- a/ReactNativeClient/lib/models/Setting.js +++ b/ReactNativeClient/lib/models/Setting.js @@ -5,6 +5,7 @@ const SyncTargetRegistry = require('lib/SyncTargetRegistry.js'); const { time } = require('lib/time-utils.js'); const { sprintf } = require('sprintf-js'); const ObjectUtils = require('lib/ObjectUtils'); +const { toTitleCase } = require('lib/string-utils.js'); const { _, supportedLocalesToLanguages, defaultLocale } = require('lib/locale.js'); class Setting extends BaseModel { @@ -20,6 +21,10 @@ class Setting extends BaseModel { static metadata() { if (this.metadata_) return this.metadata_; + // A "public" setting means that it will show up in the various config screens (or config command for the CLI tool), however + // if if private a setting might still be handled and modified by the app. For instance, the settings related to sorting notes are not + // public for the mobile and desktop apps because they are handled separately in menus. + this.metadata_ = { 'activeFolderId': { value: '', type: Setting.TYPE_STRING, public: false }, 'firstStart': { value: true, type: Setting.TYPE_BOOL, public: false }, @@ -50,16 +55,17 @@ class Setting extends BaseModel { output[Setting.THEME_DARK] = _('Dark'); return output; }}, - // 'logLevel': { value: Logger.LEVEL_INFO, type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Log level'), options: () => { - // return Logger.levelEnum(); - // }}, - // Not used for now: - // 'todoFilter': { value: 'all', type: Setting.TYPE_STRING, isEnum: true, public: false, appTypes: ['mobile'], label: () => _('Todo filter'), options: () => ({ - // all: _('Show all'), - // recent: _('Non-completed and recently completed ones'), - // nonCompleted: _('Non-completed ones only'), - // })}, - 'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Show uncompleted to-dos on top of the lists') }, + 'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') }, + 'notes.sortOrder.field': { value: 'user_updated_time', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['cli'], label: () => _('Sort notes by'), options: () => { + const Note = require('lib/models/Note'); + const noteSortFields = ['user_updated_time', 'user_created_time', 'title']; + const options = {}; + for (let i = 0; i < noteSortFields.length; i++) { + options[noteSortFields[i]] = toTitleCase(Note.fieldToLabel(noteSortFields[i])); + } + return options; + }}, + 'notes.sortOrder.reverse': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] }, 'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save geo-location with notes') }, 'newTodoFocus': { value: 'title', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('When creating a new to-do:'), options: () => { return { diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js index 4e121ec0f..743b3f034 100644 --- a/ReactNativeClient/lib/reducer.js +++ b/ReactNativeClient/lib/reducer.js @@ -19,19 +19,24 @@ const defaultState = { showSideMenu: false, screens: {}, historyCanGoBack: false, - notesOrder: [ - { by: 'user_updated_time', dir: 'DESC' }, - ], syncStarted: false, syncReport: {}, searchQuery: '', settings: {}, appState: 'starting', - //windowContentSize: { width: 0, height: 0 }, hasDisabledSyncItems: false, newNote: null, }; +const stateUtils = {}; + +stateUtils.notesOrder = function(stateSettings) { + return [{ + by: stateSettings['notes.sortOrder.field'], + dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC', + }]; +} + function arrayHasEncryptedItems(array) { for (let i = 0; i < array.length; i++) { if (!!array[i].encryption_applied) return true; @@ -90,8 +95,6 @@ function handleItemDelete(state, action) { } function updateOneItem(state, action) { - // let newItems = action.type === 'TAG_UPDATE_ONE' ? state.tags.splice(0) : state.folders.splice(0); - // let item = action.type === 'TAG_UPDATE_ONE' ? action.tag : action.folder; let itemsKey = null; if (action.type === 'TAG_UPDATE_ONE') itemsKey = 'tags'; if (action.type === 'FOLDER_UPDATE_ONE') itemsKey = 'folders'; @@ -116,12 +119,6 @@ function updateOneItem(state, action) { newState[itemsKey] = newItems; - // if (action.type === 'TAG_UPDATE_ONE') { - // newState.tags = newItems; - // } else { - // newState.folders = newItems; - // } - return newState; } @@ -316,7 +313,8 @@ const reducer = (state = defaultState, action) => { } } - newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop); + //newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop); + newNotes = Note.sortNotes(newNotes, stateUtils.notesOrder(state.settings), newState.settings.uncompletedTodosOnTop); newState = Object.assign({}, state); newState.notes = newNotes; @@ -481,4 +479,4 @@ const reducer = (state = defaultState, action) => { return newState; } -module.exports = { reducer, defaultState }; \ No newline at end of file +module.exports = { reducer, defaultState, stateUtils }; \ No newline at end of file diff --git a/ReactNativeClient/lib/string-utils.js b/ReactNativeClient/lib/string-utils.js index 94e5ff3ff..13256d901 100644 --- a/ReactNativeClient/lib/string-utils.js +++ b/ReactNativeClient/lib/string-utils.js @@ -201,4 +201,9 @@ function padLeft(string, length, padString) { return string; } -module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft }; \ No newline at end of file +function toTitleCase(string) { + if (!string) return string; + return string.charAt(0).toUpperCase() + string.slice(1); +} + +module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase }; \ No newline at end of file diff --git a/joplin.sublime-project b/joplin.sublime-project index 9ccfd432c..6d8b1cfaf 100755 --- a/joplin.sublime-project +++ b/joplin.sublime-project @@ -21,6 +21,7 @@ "docs/*.html", "docs/*.svg", "ReactNativeClient/lib/mime-utils.js", + "_mydocs/EnexSamples/*.enex", ], "folder_exclude_patterns": [