From f495db139171165246c696883fec0ad8e8084b31 Mon Sep 17 00:00:00 2001 From: Kenichi Kobayashi Date: Fri, 12 Nov 2021 00:33:37 +0900 Subject: [PATCH] Desktop: Sort Order Buttons and Per-Notebook Sort Order (#5437) --- .eslintignore | 21 ++ .gitignore | 21 ++ packages/app-desktop/app.ts | 3 + packages/app-desktop/gui/Button/Button.tsx | 16 +- .../gui/MainScreen/commands/index.ts | 6 + .../commands/toggleNotesSortOrderField.ts | 26 +++ .../commands/toggleNotesSortOrderReverse.ts | 19 ++ .../commands/togglePerFolderSortOrder.ts | 18 ++ packages/app-desktop/gui/MenuBar.tsx | 34 ++- .../gui/NoteListControls/NoteListControls.tsx | 87 +++++++- packages/app-desktop/gui/Sidebar/Sidebar.tsx | 8 + packages/app-desktop/gui/menuCommandNames.ts | 3 + .../PerFolderSortOrderService.test.ts | 45 ++++ .../sortOrder/PerFolderSortOrderService.ts | 198 ++++++++++++++++++ .../sortOrder/notesSortOrderUtils.test.ts | 85 ++++++++ .../services/sortOrder/notesSortOrderUtils.ts | 66 ++++++ packages/lib/BaseApplication.ts | 2 +- packages/lib/models/Setting.ts | 59 ++++++ 18 files changed, 695 insertions(+), 22 deletions(-) create mode 100644 packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts create mode 100644 packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts create mode 100644 packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts create mode 100644 packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.ts create mode 100644 packages/app-desktop/services/sortOrder/PerFolderSortOrderService.ts create mode 100644 packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.ts create mode 100644 packages/app-desktop/services/sortOrder/notesSortOrderUtils.ts diff --git a/.eslintignore b/.eslintignore index d30d31a55..a4167dc8f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -333,6 +333,15 @@ packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js.map packages/app-desktop/gui/MainScreen/commands/toggleNoteList.d.ts packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js.map +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.d.ts +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js.map +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.d.ts +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js.map +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.d.ts +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js.map packages/app-desktop/gui/MainScreen/commands/toggleSideBar.d.ts packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js.map @@ -735,6 +744,18 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.d.ts +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js.map +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.d.ts +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js.map +packages/app-desktop/services/sortOrder/notesSortOrderUtils.d.ts +packages/app-desktop/services/sortOrder/notesSortOrderUtils.js +packages/app-desktop/services/sortOrder/notesSortOrderUtils.js.map +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.d.ts +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js.map packages/app-desktop/services/share/invitationRespond.d.ts packages/app-desktop/services/share/invitationRespond.js packages/app-desktop/services/share/invitationRespond.js.map diff --git a/.gitignore b/.gitignore index feea1468e..dc0876e49 100644 --- a/.gitignore +++ b/.gitignore @@ -316,6 +316,15 @@ packages/app-desktop/gui/MainScreen/commands/toggleLayoutMoveMode.js.map packages/app-desktop/gui/MainScreen/commands/toggleNoteList.d.ts packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js packages/app-desktop/gui/MainScreen/commands/toggleNoteList.js.map +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.d.ts +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.js.map +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.d.ts +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js +packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.js.map +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.d.ts +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js +packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.js.map packages/app-desktop/gui/MainScreen/commands/toggleSideBar.d.ts packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js.map @@ -718,6 +727,18 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.d.ts +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js.map +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.d.ts +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js +packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js.map +packages/app-desktop/services/sortOrder/notesSortOrderUtils.d.ts +packages/app-desktop/services/sortOrder/notesSortOrderUtils.js +packages/app-desktop/services/sortOrder/notesSortOrderUtils.js.map +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.d.ts +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js +packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js.map packages/app-desktop/services/share/invitationRespond.d.ts packages/app-desktop/services/share/invitationRespond.js packages/app-desktop/services/share/invitationRespond.js.map diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index 055710f10..7da195e86 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -58,6 +58,7 @@ const commands = mainScreenCommands const globalCommands = appCommands.concat(libCommands); import editorCommandDeclarations from './gui/NoteEditor/editorCommandDeclarations'; +import PerFolderSortOrderService from './services/sortOrder/PerFolderSortOrderService'; import ShareService from '@joplin/lib/services/share/ShareService'; import checkForUpdates from './checkForUpdates'; import { AppState } from './app.reducer'; @@ -388,6 +389,8 @@ class Application extends BaseApplication { this.initRedux(); + PerFolderSortOrderService.initialize(); + CommandService.instance().initialize(this.store(), Setting.value('env') == 'dev', stateToWhenClauseContext); for (const command of commands) { diff --git a/packages/app-desktop/gui/Button/Button.tsx b/packages/app-desktop/gui/Button/Button.tsx index 3d16cff4a..e1e026690 100644 --- a/packages/app-desktop/gui/Button/Button.tsx +++ b/packages/app-desktop/gui/Button/Button.tsx @@ -33,9 +33,9 @@ const StyledTitle = styled.span` `; -// const buttonHeight = 32; +// const buttonSizePx = 32; -const buttonHeight = (props: Props) => { +export const buttonSizePx = (props: Props) => { if (!props.size || props.size === ButtonSize.Normal) return 32; if (props.size === ButtonSize.Small) return 26; throw new Error(`Unknown size: ${props.size}`); @@ -45,13 +45,13 @@ const StyledButtonBase = styled.button` display: flex; align-items: center; flex-direction: row; - height: ${(props: Props) => buttonHeight(props)}px; - min-height: ${(props: Props) => buttonHeight(props)}px; - max-height: ${(props: Props) => buttonHeight(props)}px; - width: ${(props: any) => props.iconOnly ? `${buttonHeight}px` : 'auto'}; - ${(props: any) => props.iconOnly ? `min-width: ${buttonHeight}px;` : ''} + height: ${(props: Props) => buttonSizePx(props)}px; + min-height: ${(props: Props) => buttonSizePx(props)}px; + max-height: ${(props: Props) => buttonSizePx(props)}px; + width: ${(props: any) => props.iconOnly ? `${buttonSizePx}px` : 'auto'}; + ${(props: any) => props.iconOnly ? `min-width: ${buttonSizePx}px;` : ''} ${(props: any) => !props.iconOnly ? 'min-width: 100px;' : ''} - ${(props: any) => props.iconOnly ? `max-width: ${buttonHeight}px;` : ''} + ${(props: any) => props.iconOnly ? `max-width: ${buttonSizePx}px;` : ''} box-sizing: border-box; border-radius: 3px; border-style: solid; diff --git a/packages/app-desktop/gui/MainScreen/commands/index.ts b/packages/app-desktop/gui/MainScreen/commands/index.ts index 19d9abd9e..3730cfba4 100644 --- a/packages/app-desktop/gui/MainScreen/commands/index.ts +++ b/packages/app-desktop/gui/MainScreen/commands/index.ts @@ -28,6 +28,9 @@ import * as showSpellCheckerMenu from './showSpellCheckerMenu'; import * as toggleEditors from './toggleEditors'; import * as toggleLayoutMoveMode from './toggleLayoutMoveMode'; import * as toggleNoteList from './toggleNoteList'; +import * as toggleNotesSortOrderField from './toggleNotesSortOrderField'; +import * as toggleNotesSortOrderReverse from './toggleNotesSortOrderReverse'; +import * as togglePerFolderSortOrder from './togglePerFolderSortOrder'; import * as toggleSideBar from './toggleSideBar'; import * as toggleVisiblePanes from './toggleVisiblePanes'; @@ -61,6 +64,9 @@ const index:any[] = [ toggleEditors, toggleLayoutMoveMode, toggleNoteList, + toggleNotesSortOrderField, + toggleNotesSortOrderReverse, + togglePerFolderSortOrder, toggleSideBar, toggleVisiblePanes, ]; diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts b/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts new file mode 100644 index 000000000..b698958e4 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderField.ts @@ -0,0 +1,26 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils'; +import { _ } from '@joplin/lib/locale'; + +export const declaration: CommandDeclaration = { + name: 'toggleNotesSortOrderField', + label: () => _('Toggle sort order field'), + parentLabel: () => _('Notes'), +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (_context: CommandContext, field?: string | Array, reverse?: boolean) => { + // field: Sort order's field. undefined means switching a field. + // reverse: whether the sort order is reversed or not. undefined means toggling. + // + // To support CommandService.scheduleExecute(), field accepts an size-two Array, + // which means [field, reverse]. + if (typeof field !== 'object') { + setNotesSortOrder(field, reverse); + } else { + setNotesSortOrder(field[0], field[1]); + } + }, + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts b/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts new file mode 100644 index 000000000..bcd1efe5d --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/toggleNotesSortOrderReverse.ts @@ -0,0 +1,19 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import Setting from '@joplin/lib/models/Setting'; +import { _ } from '@joplin/lib/locale'; +import { setNotesSortOrder } from '../../../services/sortOrder/notesSortOrderUtils'; + +export const declaration: CommandDeclaration = { + name: 'toggleNotesSortOrderReverse', + label: () => _('Reverse sort order'), + parentLabel: () => _('Notes'), +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (_context: CommandContext) => { + const reverse = Setting.value('notes.sortOrder.reverse'); + setNotesSortOrder(undefined, !reverse); + }, + }; +}; diff --git a/packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts b/packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts new file mode 100644 index 000000000..507091e51 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/togglePerFolderSortOrder.ts @@ -0,0 +1,18 @@ +import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSortOrderService'; + +export const declaration: CommandDeclaration = { + name: 'togglePerFolderSortOrder', + label: () => _('Toggle own sort order'), +}; + +export const runtime = (): CommandRuntime => { + return { + enabledCondition: 'oneFolderSelected', + + execute: async (_context: CommandContext, folderId?: string, own?: boolean) => { + PerFolderSortOrderService.set(folderId, own); + }, + }; +}; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 2987afbc9..9e16259a1 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -136,7 +136,8 @@ function useMenuStates(menu: any, props: Props) { menuItemSetChecked(`sort:${type}:${field}`, (props as any)[`${type}.sortOrder.field`] === field); } - menuItemSetChecked(`sort:${type}:reverse`, (props as any)[`${type}.sortOrder.reverse`]); + const id = type == 'notes' ? 'toggleNotesSortOrderReverse' : `sort:${type}:reverse`; + menuItemSetChecked(id, (props as any)[`${type}.sortOrder.reverse`]); } applySortItemCheckState('notes'); @@ -267,22 +268,33 @@ function useMenu(props: Props) { type: 'checkbox', // checked: Setting.value(`${type}.sortOrder.field`) === field, click: () => { - Setting.setValue(`${type}.sortOrder.field`, field); + if (type === 'notes') { + void CommandService.instance().execute('toggleNotesSortOrderField', field); + } else { + Setting.setValue(`${type}.sortOrder.field`, field); + } }, }); } sortItems.push({ type: 'separator' }); - sortItems.push({ - id: `sort:${type}:reverse`, - label: Setting.settingMetadata(`${type}.sortOrder.reverse`).label(), - type: 'checkbox', - // checked: Setting.value(`${type}.sortOrder.reverse`), - click: () => { - Setting.setValue(`${type}.sortOrder.reverse`, !Setting.value(`${type}.sortOrder.reverse`)); - }, - }); + if (type == 'notes') { + sortItems.push( + { ...menuItemDic.toggleNotesSortOrderReverse, type: 'checkbox' }, + { ...menuItemDic.toggleNotesSortOrderField, visible: false } + ); + } else { + sortItems.push({ + id: `sort:${type}:reverse`, + label: Setting.settingMetadata(`${type}.sortOrder.reverse`).label(), + type: 'checkbox', + // checked: Setting.value(`${type}.sortOrder.reverse`), + click: () => { + Setting.setValue(`${type}.sortOrder.reverse`, !Setting.value(`${type}.sortOrder.reverse`)); + }, + }); + } return sortItems; }; diff --git a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx index 427e2e213..0dc246b78 100644 --- a/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx +++ b/packages/app-desktop/gui/NoteListControls/NoteListControls.tsx @@ -1,13 +1,19 @@ +import { AppState } from '../../app.reducer'; import * as React from 'react'; import { useEffect, useRef } from 'react'; import SearchBar from '../SearchBar/SearchBar'; -import Button, { ButtonLevel } from '../Button/Button'; +import Button, { ButtonLevel, ButtonSize, buttonSizePx } from '../Button/Button'; import CommandService from '@joplin/lib/services/CommandService'; import { runtime as focusSearchRuntime } from './commands/focusSearch'; +const { connect } = require('react-redux'); const styled = require('styled-components').default; interface Props { showNewNoteButtons: boolean; + sortOrderButtonsVisible: boolean; + sortOrderField: string; + sortOrderReverse: boolean; + notesParentType: string; height: number; } @@ -28,12 +34,27 @@ const StyledButton = styled(Button)` min-height: 26px; `; +const StyledPairButtonL = styled(Button)` + margin-left: 8px; + border-radius: 5px 0 0 5px; + min-width: ${(props: any) => buttonSizePx(props)}px; + max-width: ${(props: any) => buttonSizePx(props)}px; +`; + +const StyledPairButtonR = styled(Button)` + min-width: 8px; + margin-left: 0px; + border-radius: 0 5px 5px 0; + border-width: 1px 1px 1px 0; + width: auto; +`; + const ButtonContainer = styled.div` display: flex; flex-direction: row; `; -export default function NoteListControls(props: Props) { +function NoteListControls(props: Props) { const searchBarRef = useRef(null); useEffect(function() { @@ -52,16 +73,66 @@ export default function NoteListControls(props: Props) { void CommandService.instance().execute('newNote'); } + function onSortOrderFieldButtonClick() { + void CommandService.instance().execute('toggleNotesSortOrderField'); + } + + function onSortOrderReverseButtonClick() { + void CommandService.instance().execute('toggleNotesSortOrderReverse'); + } + + function sortOrderFieldIcon() { + const field = props.sortOrderField; + const iconMap: any = { + user_updated_time: 'far fa-calendar-alt', + user_created_time: 'far fa-calendar-plus', + title: 'fas fa-font', + order: 'fas fa-wrench', + }; + return `${iconMap[field] || iconMap['title']} ${field}`; + } + + function sortOrderReverseIcon() { + return props.sortOrderReverse ? 'fas fa-long-arrow-alt-up' : 'fas fa-long-arrow-alt-down'; + } + + function showsSortOrderButtons() { + let visible = props.sortOrderButtonsVisible; + if (props.notesParentType === 'Search') visible = false; + return visible; + } + function renderNewNoteButtons() { if (!props.showNewNoteButtons) return null; return ( + {showsSortOrderButtons() && + + } + {showsSortOrderButtons() && + + } @@ -82,3 +154,14 @@ export default function NoteListControls(props: Props) { ); } + +const mapStateToProps = (state: AppState) => { + return { + sortOrderButtonsVisible: state.settings['notes.sortOrder.buttonsVisible'], + sortOrderField: state.settings['notes.sortOrder.field'], + sortOrderReverse: state.settings['notes.sortOrder.reverse'], + notesParentType: state.notesParentType, + }; +}; + +export default connect(mapStateToProps)(NoteListControls); diff --git a/packages/app-desktop/gui/Sidebar/Sidebar.tsx b/packages/app-desktop/gui/Sidebar/Sidebar.tsx index f50091450..063ca38ac 100644 --- a/packages/app-desktop/gui/Sidebar/Sidebar.tsx +++ b/packages/app-desktop/gui/Sidebar/Sidebar.tsx @@ -20,6 +20,7 @@ import Logger from '@joplin/lib/Logger'; import { FolderEntity } from '@joplin/lib/services/database/types'; import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext'; import { store } from '@joplin/lib/reducer'; +import PerFolderSortOrderService from '../../services/sortOrder/PerFolderSortOrderService'; import { getFolderCallbackUrl, getTagCallbackUrl } from '@joplin/lib/callbackUrlUtils'; const { connect } = require('react-redux'); const shared = require('@joplin/lib/components/shared/side-menu-shared.js'); @@ -332,6 +333,13 @@ class SidebarComponent extends React.Component { submenu: exportMenu, }) ); + if (Setting.value('notes.perFolderSortOrderEnabled')) { + menu.append(new MenuItem({ + ...menuUtils.commandToStatefulMenuItem('togglePerFolderSortOrder', itemId), + type: 'checkbox', + checked: PerFolderSortOrderService.isSet(itemId), + })); + } } if (itemType === BaseModel.TYPE_FOLDER) { diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index 5e6d3d4ad..18e55c00e 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -32,6 +32,9 @@ export default function() { 'toggleExternalEditing', 'toggleLayoutMoveMode', 'toggleNoteList', + 'toggleNotesSortOrderField', + 'toggleNotesSortOrderReverse', + 'togglePerFolderSortOrder', 'toggleSideBar', 'toggleVisiblePanes', 'editor.deleteLine', diff --git a/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.ts b/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.ts new file mode 100644 index 000000000..021eabcc8 --- /dev/null +++ b/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.ts @@ -0,0 +1,45 @@ +import PerFolderSortOrderService from './PerFolderSortOrderService'; +import { setNotesSortOrder } from './notesSortOrderUtils'; +import Setting from '@joplin/lib/models/Setting'; +const { shimInit } = require('@joplin/lib/shim-init-node.js'); + +const folderId1 = 'aa012345678901234567890123456789'; +const folderId2 = 'bb012345678901234567890123456789'; + +beforeAll(async (done) => { + shimInit(); + Setting.autoSaveEnabled = false; + PerFolderSortOrderService.initialize(); + Setting.setValue('notes.perFolderSortOrderEnabled', true); + done(); +}); + +describe('PerFolderSortOrderService', () => { + + test('get(), isSet() and set()', async (done) => { + // Clear all per-folder sort order + expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false); + expect(PerFolderSortOrderService.isSet(folderId2)).toBe(false); + + // Set shared sort order + setNotesSortOrder('user_created_time', false); + expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + + // Manipulate per-folder sort order + PerFolderSortOrderService.set(folderId1, true); + expect(PerFolderSortOrderService.isSet(folderId1)).toBe(true); + PerFolderSortOrderService.set(folderId1, false); + expect(PerFolderSortOrderService.isSet(folderId1)).toBe(false); + PerFolderSortOrderService.set(folderId1); + expect(PerFolderSortOrderService.isSet(folderId1)).toBe(true); + + // Get per-folder sort order from a folder with per-folder sort order + expect(PerFolderSortOrderService.get(folderId1)).toBeDefined(); + + // Folder without per-folder sort order has no per-folder sort order + expect(PerFolderSortOrderService.get(folderId2)).toBeUndefined(); + + done(); + }); +}); diff --git a/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.ts b/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.ts new file mode 100644 index 000000000..05689e0e6 --- /dev/null +++ b/packages/app-desktop/services/sortOrder/PerFolderSortOrderService.ts @@ -0,0 +1,198 @@ +import Setting from '@joplin/lib/models/Setting'; +import eventManager from '@joplin/lib/eventManager'; +import { notesSortOrderFieldArray, setNotesSortOrder } from './notesSortOrderUtils'; + +const SUFFIX_FIELD = '$field'; +const SUFFIX_REVERSE = '$reverse'; + +export interface SortOrder { + field: string; + reverse: boolean; +} + +interface FolderState { + notesParentType: string; + selectedFolderId: string; +} + +interface SortOrderPool { + [key: string]: string | boolean; +} + +export default class PerFolderSortOrderService { + + private static previousFolderId: string = null; + private static folderState: FolderState = { notesParentType: '', selectedFolderId: '' }; + // Since perFolderSortOrders and sharedSortOrder is persisted using Setting, + // their structures are not nested. + private static perFolderSortOrders: SortOrderPool = null; + private static sharedSortOrder: SortOrder & SortOrderPool = { + field: 'user_updated_time', + reverse: true, + user_updated_time: true, + user_created_time: true, + title: false, + order: false, + }; + + public static initialize() { + this.loadPerFolderSortOrders(); + this.loadSharedSortOrder(); + eventManager.appStateOn('notesParentType', this.onFolderSelectionMayChange.bind(this, 'notesParentType')); + eventManager.appStateOn('selectedFolderId', this.onFolderSelectionMayChange.bind(this, 'selectedFolderId')); + } + + public static isSet(folderId: string): boolean { + return folderId && this.perFolderSortOrders && this.perFolderSortOrders.hasOwnProperty(folderId + SUFFIX_FIELD); + } + + public static get(folderId: string): SortOrder { + if (folderId && this.perFolderSortOrders) { + const field = this.perFolderSortOrders[folderId + SUFFIX_FIELD] as string; + const reverse = this.perFolderSortOrders[folderId + SUFFIX_REVERSE] as boolean; + if (field) return { field, reverse }; + } + return undefined; + } + + public static set(folderId?: string, own?: boolean) { + let targetId = folderId; + const selectedId = this.getSelectedFolderId(); + if (!targetId) { + targetId = selectedId; // default: selected folder + if (!targetId) return; + } + const targetOwn = this.isSet(targetId); + let newOwn; + if (typeof own === 'undefined') { + newOwn = !targetOwn; // default: toggling + } else { + newOwn = !!own; + if (newOwn === targetOwn) return; + } + if (newOwn) { + let field: string, reverse: boolean; + if (!this.isSet(selectedId)) { + field = Setting.value('notes.sortOrder.field'); + reverse = Setting.value('notes.sortOrder.reverse'); + } else { + field = this.sharedSortOrder.field; + if (Setting.value('notes.perFieldReversalEnabled')) { + reverse = this.sharedSortOrder[field] as boolean; + } else { + reverse = this.sharedSortOrder.reverse; + } + } + PerFolderSortOrderService.setPerFolderSortOrder(targetId, field, reverse); + } else { + PerFolderSortOrderService.deletePerFolderSortOrder(targetId); + } + } + + + private static onFolderSelectionMayChange(cause: string, event: any) { + if (cause !== 'notesParentType' && cause !== 'selectedFolderId') return; + this.folderState[cause] = event.value; + const selectedId = this.getSelectedFolderId(); + if (this.previousFolderId === selectedId) return; + const field: string = Setting.value('notes.sortOrder.field'); + const reverse: boolean = Setting.value('notes.sortOrder.reverse'); + let previousFolderHasPerFolderSortOrder = false; + if (this.previousFolderId !== null) { + previousFolderHasPerFolderSortOrder = this.isSet(this.previousFolderId); + if (previousFolderHasPerFolderSortOrder) { + this.setPerFolderSortOrder(this.previousFolderId, field, reverse); + } else { + this.setSharedSortOrder(field, reverse); + } + } + this.previousFolderId = selectedId; + let next: SortOrder; + if (this.isSet(selectedId)) { + next = this.get(selectedId); + } else if (previousFolderHasPerFolderSortOrder) { + next = this.sharedSortOrder; + } else { + return; + } + if (Setting.value('notes.perFolderSortOrderEnabled')) { + if (next.field !== field || next.reverse !== reverse) { + setNotesSortOrder(next.field, next.reverse); + } + } + } + + private static getSelectedFolderId(): string { + if (this.folderState.notesParentType === 'Folder') { + return this.folderState.selectedFolderId; + } else { + return ''; + } + } + + private static loadPerFolderSortOrders() { + this.perFolderSortOrders = { ...Setting.value('notes.perFolderSortOrders') }; + } + + private static loadSharedSortOrder() { + const validFields = notesSortOrderFieldArray(); + const value = Setting.value('notes.sharedSortOrder'); + for (const key in this.sharedSortOrder) { + if (value.hasOwnProperty(key)) { + if (key !== 'field' || validFields.includes(value.field)) { + this.sharedSortOrder[key] = value[key]; + } + } + } + } + + private static setPerFolderSortOrder(folderId: string, field: string, reverse: boolean) { + const old = this.get(folderId); + let dirty = false; + if (!(old?.field === field)) { + this.perFolderSortOrders[folderId + SUFFIX_FIELD] = field; + dirty = true; + } + if (!(old?.reverse === reverse)) { + this.perFolderSortOrders[folderId + SUFFIX_REVERSE] = reverse; + dirty = true; + } + if (dirty) { + Setting.setValue('notes.perFolderSortOrders', { ...this.perFolderSortOrders }); + } + } + + private static deletePerFolderSortOrder(folderId: string) { + let dirty = false; + if (this.perFolderSortOrders.hasOwnProperty(folderId + SUFFIX_FIELD)) { + delete this.perFolderSortOrders[folderId + SUFFIX_FIELD]; + dirty = true; + } + if (this.perFolderSortOrders.hasOwnProperty(folderId + SUFFIX_REVERSE)) { + delete this.perFolderSortOrders[folderId + SUFFIX_REVERSE]; + dirty = true; + } + if (dirty) { + Setting.setValue('notes.perFolderSortOrders', { ...this.perFolderSortOrders }); + } + } + + private static setSharedSortOrder(field: string, reverse: boolean) { + let dirty = false; + if (this.sharedSortOrder.field !== field) { + this.sharedSortOrder.field = field; + dirty = true; + } + if (this.sharedSortOrder.reverse !== reverse) { + this.sharedSortOrder.reverse = reverse; + dirty = true; + } + if (this.sharedSortOrder[field] !== reverse) { + this.sharedSortOrder[field] = reverse; + dirty = true; + } + if (dirty) { + Setting.setValue('notes.sharedSortOrder', { ...this.sharedSortOrder }); + } + } +} diff --git a/packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.ts b/packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.ts new file mode 100644 index 000000000..bf161094b --- /dev/null +++ b/packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.ts @@ -0,0 +1,85 @@ +import { notesSortOrderFieldArray, notesSortOrderNextField, setNotesSortOrder } from './notesSortOrderUtils'; +import Setting from '@joplin/lib/models/Setting'; +const { shimInit } = require('@joplin/lib/shim-init-node.js'); + +beforeAll(() => { + shimInit(); + Setting.autoSaveEnabled = false; +}); + +describe('notesSortOrderUtils', () => { + + it('should always provide the same ordered fields', async () => { + const expected = ['user_updated_time', 'user_created_time', 'title', 'order']; + expect(notesSortOrderFieldArray()).toStrictEqual(expected); + expect(notesSortOrderFieldArray()).toStrictEqual(expected); + }); + + it('should provide the next field cyclicly', async () => { + expect(notesSortOrderNextField('user_updated_time')).toBe('user_created_time'); + expect(notesSortOrderNextField('order')).toBe('user_updated_time'); + }); + + test('setNoteSortOrder(), when perFieldReversalEnabled is false', async () => { + Setting.setValue('notes.perFieldReversalEnabled', false); + + // It should set field and reverse of sort order. + setNotesSortOrder('user_created_time', false); + expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + setNotesSortOrder('user_updated_time', true); + expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(true); + setNotesSortOrder('title', true); + expect(Setting.value('notes.sortOrder.field')).toBe('title'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(true); + + // It should affect the current field of sort order, if arg1 is undefined. + setNotesSortOrder(undefined, false); + expect(Setting.value('notes.sortOrder.field')).toBe('title'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + + // it should only set field of sort order, if arg2 is undefined. + setNotesSortOrder('user_updated_time'); + expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + + // It should select the next field, if arg1 and arg2 are undefined. + setNotesSortOrder(); + expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + }); + + test('setNoteSortOrder(), when perFieldReversalEnabled is true', async () => { + Setting.setValue('notes.perFieldReversalEnabled', true); + // It should set field and reverse of sort order. + setNotesSortOrder('user_created_time', false); + expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + setNotesSortOrder('user_updated_time', true); + expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(true); + setNotesSortOrder('title', true); + expect(Setting.value('notes.sortOrder.field')).toBe('title'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(true); + + // it should affect the current field of sort order, if arg1 is undefined. + setNotesSortOrder(undefined, false); + expect(Setting.value('notes.sortOrder.field')).toBe('title'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + + // It should remember a reverse state, if arg2 is undefined. + setNotesSortOrder('user_updated_time'); + expect(Setting.value('notes.sortOrder.field')).toBe('user_updated_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(true); + + // It should select the next field and remember a reverse state, if arg1 and arg2 are undefined. + setNotesSortOrder(); + expect(Setting.value('notes.sortOrder.field')).toBe('user_created_time'); + expect(Setting.value('notes.sortOrder.reverse')).toBe(false); + }); + + it('should not accept an invalid field name', async () => { + expect(() => setNotesSortOrder('hoge', true)).toThrow(); + }); +}); diff --git a/packages/app-desktop/services/sortOrder/notesSortOrderUtils.ts b/packages/app-desktop/services/sortOrder/notesSortOrderUtils.ts new file mode 100644 index 000000000..4b069ddcb --- /dev/null +++ b/packages/app-desktop/services/sortOrder/notesSortOrderUtils.ts @@ -0,0 +1,66 @@ +import Setting from '@joplin/lib/models/Setting'; + +let fields: string[] = null; +let perFieldReverse: { [field: string]: boolean } = null; + +export const notesSortOrderFieldArray = (): string[] => { + // The order of the fields is strictly determinate. + if (fields == null) { + fields = Setting.enumOptionValues('notes.sortOrder.field').sort().reverse(); + } + return fields; +}; + +export const notesSortOrderNextField = (currentField: string) => { + const fields = notesSortOrderFieldArray(); + const index = fields.indexOf(currentField); + if (index < 0) { + return currentField; + } else { + return fields[(index + 1) % fields.length]; + } +}; + +export const setNotesSortOrder = (field?: string, reverse?: boolean) => { + // field: Sort order's field. undefined means changing a field cyclicly. + // reverse: whether the sort order is reversed or not. undefined means toggling. + let nextField = field; + let nextReverse = reverse; + const currentField = Setting.value('notes.sortOrder.field'); + const currentReverse = Setting.value('notes.sortOrder.reverse'); + const enabled = Setting.value('notes.perFieldReversalEnabled'); + if (enabled) { + if (perFieldReverse === null) { + perFieldReverse = { ...Setting.value('notes.perFieldReverse') }; + } + } + if (typeof field === 'undefined') { + if (typeof reverse === 'undefined') { + // If both arguments are undefined, the next field is selected. + nextField = notesSortOrderNextField(currentField); + } else { + nextField = currentField; + } + } + if (typeof reverse === 'undefined') { + if (enabled && perFieldReverse.hasOwnProperty(nextField)) { + nextReverse = !!perFieldReverse[nextField]; + } else { + nextReverse = currentReverse; + } + } + if (currentField !== nextField) { + Setting.setValue('notes.sortOrder.field', nextField); + } + if (currentReverse !== nextReverse) { + Setting.setValue('notes.sortOrder.reverse', nextReverse); + } + if (enabled) { + // nextField is sane here. + nextReverse = !!nextReverse; + if (perFieldReverse[nextField] !== nextReverse) { + perFieldReverse[nextField] = nextReverse; + Setting.setValue('notes.perFieldReverse', { ...perFieldReverse }); + } + } +}; diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index f543b3d0b..59a1c89e0 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -488,7 +488,6 @@ export default class BaseApplication { // appLogger.debug('Reducer action', this.reducerActionToString(action)); const result = next(action); - const newState = store.getState(); let refreshNotes = false; let refreshFolders: boolean | string = false; // let refreshTags = false; @@ -496,6 +495,7 @@ export default class BaseApplication { let refreshNotesHash = ''; await reduxSharedMiddleware(store, next, action); + const newState = store.getState(); if (this.hasGui() && ['NOTE_UPDATE_ONE', 'NOTE_DELETE', 'FOLDER_UPDATE_ONE', 'FOLDER_DELETE'].indexOf(action.type) >= 0) { if (!(await reg.syncTarget().syncStarted())) void reg.scheduleSync(30 * 1000, { syncSteps: ['update_remote', 'delete_remote'] }); diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 23910e9be..7c815e65d 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -825,6 +825,63 @@ class Setting extends BaseModel { storage: SettingStorage.File, }, 'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, + // NOTE: A setting whose name starts with 'notes.sortOrder' is special, + // which implies changing the setting automatically triggers the reflesh of notes. + // See lib/BaseApplication.ts/generalMiddleware() for details. + 'notes.sortOrder.buttonsVisible': { + value: true, + type: SettingItemType.Bool, + storage: SettingStorage.File, + section: 'appearance', + public: true, + label: () => _('Show sort order buttons'), + description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'), + appTypes: [AppType.Desktop], + }, + 'notes.perFieldReversalEnabled': { + value: true, + type: SettingItemType.Bool, + storage: SettingStorage.File, + section: 'note', + public: false, + appTypes: [AppType.Cli, AppType.Desktop], + }, + 'notes.perFieldReverse': { + value: { + user_updated_time: true, + user_created_time: true, + title: false, + order: false, + }, + type: SettingItemType.Object, + storage: SettingStorage.File, + section: 'note', + public: false, + appTypes: [AppType.Cli, AppType.Desktop], + }, + 'notes.perFolderSortOrderEnabled': { + value: true, + type: SettingItemType.Bool, + storage: SettingStorage.File, + section: 'folder', + public: false, + appTypes: [AppType.Cli, AppType.Desktop], + }, + 'notes.perFolderSortOrders': { + value: {}, + type: SettingItemType.Object, + storage: SettingStorage.File, + section: 'folder', + public: false, + appTypes: [AppType.Cli, AppType.Desktop], + }, + 'notes.sharedSortOrder': { + value: {}, + type: SettingItemType.Object, + section: 'folder', + public: false, + appTypes: [AppType.Cli, AppType.Desktop], + }, 'folders.sortOrder.field': { value: 'title', type: SettingItemType.String, @@ -2044,6 +2101,7 @@ class Setting extends BaseModel { if (name === 'sync') return _('Synchronisation'); if (name === 'appearance') return _('Appearance'); if (name === 'note') return _('Note'); + if (name === 'folder') return _('Notebook'); if (name === 'markdownPlugins') return _('Markdown'); if (name === 'plugins') return _('Plugins'); if (name === 'application') return _('Application'); @@ -2071,6 +2129,7 @@ class Setting extends BaseModel { if (name === 'sync') return 'icon-sync'; if (name === 'appearance') return 'icon-appearance'; if (name === 'note') return 'icon-note'; + if (name === 'folder') return 'icon-notebooks'; if (name === 'plugins') return 'icon-plugins'; if (name === 'markdownPlugins') return 'fab fa-markdown'; if (name === 'application') return 'icon-application';