From f656185c167b3306d408dc486970836efa1d70c5 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Mon, 14 Nov 2022 19:40:00 +0530 Subject: [PATCH] feat: GUI to create smart views (#1997) --- .../src/Domain/Runtime/Predicate/Interface.ts | 5 +- .../src/Domain/Runtime/Predicate/Operator.ts | 4 + .../Runtime/Predicate/Predicate.spec.ts | 30 ++++ .../Domain/Runtime/Predicate/Utils.spec.ts | 25 ++++ .../src/Domain/Runtime/Predicate/Utils.ts | 4 + .../src/Domain/Item/ItemsClientInterface.ts | 6 + .../snjs/lib/Services/Items/ItemManager.ts | 2 + .../Components/Button/IconButton.tsx | 6 +- .../Header/DisplayOptionsMenu.tsx | 40 ++---- .../Components/Icon/IconPicker.tsx | 62 +++++++-- .../NoSubscriptionBanner.tsx | 35 +++++ .../Preferences/Panes/General/General.tsx | 6 + .../General/SmartViews/EditSmartViewModal.tsx | 128 ++++++++++++++++++ .../General/SmartViews/SmartViewItem.tsx | 26 ++++ .../Panes/General/SmartViews/SmartViews.tsx | 85 ++++++++++++ .../Shared/ModalDialogDescription.tsx | 2 +- .../SmartViewBuilder/AddSmartViewModal.tsx | 107 +++++++++++++++ .../AddSmartViewModalController.ts | 70 ++++++++++ .../CompoundPredicateBuilder.tsx | 127 +++++++++++++++++ .../CompoundPredicateBuilderController.ts | 92 +++++++++++++ .../SmartViewBuilder/PredicateKeypaths.ts | 66 +++++++++ .../SmartViewBuilder/PredicateValue.tsx | 76 +++++++++++ .../Components/Tags/Navigation.tsx | 11 +- .../Components/Tags/SmartViewsList.tsx | 5 +- .../Components/Tags/SmartViewsListItem.tsx | 31 ++--- .../Components/Tags/SmartViewsSection.tsx | 56 +++++++- .../Components/Tags/TagContextMenu.tsx | 2 + packages/web/tailwind.config.js | 1 + 28 files changed, 1032 insertions(+), 78 deletions(-) create mode 100644 packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoSubscriptionBanner/NoSubscriptionBanner.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/EditSmartViewModal.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViewItem.tsx create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModal.tsx create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModalController.ts create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilder.tsx create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilderController.ts create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/PredicateKeypaths.ts create mode 100644 packages/web/src/javascripts/Components/SmartViewBuilder/PredicateValue.tsx diff --git a/packages/models/src/Domain/Runtime/Predicate/Interface.ts b/packages/models/src/Domain/Runtime/Predicate/Interface.ts index 85c3c616f..f80010371 100644 --- a/packages/models/src/Domain/Runtime/Predicate/Interface.ts +++ b/packages/models/src/Domain/Runtime/Predicate/Interface.ts @@ -15,8 +15,7 @@ export interface PredicateJsonForm { export const AllPredicateCompoundOperators = ['and', 'or'] as const export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number] -export const AllPredicateOperators = [ - ...AllPredicateCompoundOperators, +export const AllNonCompoundPredicateOperators = [ '!=', '=', '<', @@ -30,6 +29,8 @@ export const AllPredicateOperators = [ 'includes', ] as const +export const AllPredicateOperators = [...AllPredicateCompoundOperators, ...AllNonCompoundPredicateOperators] as const + export type PredicateOperator = typeof AllPredicateOperators[number] export type SureValue = number | number[] | string[] | string | Date | boolean | false | '' diff --git a/packages/models/src/Domain/Runtime/Predicate/Operator.ts b/packages/models/src/Domain/Runtime/Predicate/Operator.ts index a1af4db6a..be72146e8 100644 --- a/packages/models/src/Domain/Runtime/Predicate/Operator.ts +++ b/packages/models/src/Domain/Runtime/Predicate/Operator.ts @@ -23,6 +23,10 @@ export function valueMatchesTargetValue( value = value.toLowerCase() } + if (value instanceof Date && typeof targetValue === 'string') { + targetValue = new Date(targetValue) + } + if (operator === 'not') { return !valueMatchesTargetValue(value, '=', targetValue) } diff --git a/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts b/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts index 974d2d1f4..201c3484c 100644 --- a/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts +++ b/packages/models/src/Domain/Runtime/Predicate/Predicate.spec.ts @@ -429,6 +429,36 @@ describe('predicates', () => { it('hours ago value', () => { expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true) }) + + it('nonmatching hours ago value', () => { + expect(new Predicate('updated_at', '<', '1.hours.ago').matchesItem(item)).toEqual(false) + }) + + it('months ago value', () => { + expect(new Predicate('updated_at', '>', '1.months.ago').matchesItem(item)).toEqual(true) + }) + + it('nonmatching months ago value', () => { + expect(new Predicate('updated_at', '<', '1.months.ago').matchesItem(item)).toEqual(false) + }) + + it('years ago value', () => { + expect(new Predicate('updated_at', '>', '1.years.ago').matchesItem(item)).toEqual(true) + }) + + it('nonmatching years ago value', () => { + expect(new Predicate('updated_at', '<', '1.years.ago').matchesItem(item)).toEqual(false) + }) + + it('string date value', () => { + item = createItem({}, new Date('01/01/2022')) + expect(new Predicate('updated_at', '<', '01/02/2022').matchesItem(item)).toEqual(true) + }) + + it('nonmatching string date value', () => { + item = createItem({}, new Date('01/01/2022')) + expect(new Predicate('updated_at', '>', '01/02/2022').matchesItem(item)).toEqual(false) + }) }) describe('nonexistent properties', () => { diff --git a/packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts b/packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts new file mode 100644 index 000000000..99a318af6 --- /dev/null +++ b/packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts @@ -0,0 +1,25 @@ +import { dateFromDSLDateString } from './Utils' + +describe('Predicate Utils', () => { + describe('dateFromDSLDateString', () => { + it('should return a date object with the correct day', () => { + const date = dateFromDSLDateString('1.days.ago') + expect(date.getDate()).toEqual(new Date().getDate() - 1) + }) + + it('should return a date object with the correct hour', () => { + const date = dateFromDSLDateString('1.hours.ago') + expect(date.getHours()).toEqual(new Date().getHours() - 1) + }) + + it('should return a date object with the correct month', () => { + const date = dateFromDSLDateString('1.months.ago') + expect(date.getMonth()).toEqual(new Date().getMonth() - 1) + }) + + it('should return a date object with the correct year', () => { + const date = dateFromDSLDateString('1.years.ago') + expect(date.getFullYear()).toEqual(new Date().getFullYear() - 1) + }) + }) +}) diff --git a/packages/models/src/Domain/Runtime/Predicate/Utils.ts b/packages/models/src/Domain/Runtime/Predicate/Utils.ts index 5c6b737fb..499ec38e1 100644 --- a/packages/models/src/Domain/Runtime/Predicate/Utils.ts +++ b/packages/models/src/Domain/Runtime/Predicate/Utils.ts @@ -10,6 +10,10 @@ export function dateFromDSLDateString(string: string): Date { date.setDate(date.getDate() - offset) } else if (unit === 'hours') { date.setHours(date.getHours() - offset) + } else if (unit === 'months') { + date.setMonth(date.getMonth() - offset) + } else if (unit === 'years') { + date.setFullYear(date.getFullYear() - offset) } return date } diff --git a/packages/services/src/Domain/Item/ItemsClientInterface.ts b/packages/services/src/Domain/Item/ItemsClientInterface.ts index 0647912f3..c8025a790 100644 --- a/packages/services/src/Domain/Item/ItemsClientInterface.ts +++ b/packages/services/src/Domain/Item/ItemsClientInterface.ts @@ -148,4 +148,10 @@ export interface ItemsClientInterface { * @returns Whether the item is a template (unmanaged) */ isTemplateItem(item: DecryptedItemInterface): boolean + + createSmartView>( + title: string, + predicate: PredicateInterface, + iconString?: string, + ): Promise } diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index dcedf2185..dee97373f 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -1222,12 +1222,14 @@ export class ItemManager public async createSmartView( title: string, predicate: Models.PredicateInterface, + iconString?: string, ): Promise { return this.createItem( ContentType.SmartView, Models.FillItemContent({ title, predicate: predicate.toJson(), + iconString: iconString || 'restore', } as Models.SmartViewContent), true, ) as Promise diff --git a/packages/web/src/javascripts/Components/Button/IconButton.tsx b/packages/web/src/javascripts/Components/Button/IconButton.tsx index d002f6e9f..21cbd02e3 100644 --- a/packages/web/src/javascripts/Components/Button/IconButton.tsx +++ b/packages/web/src/javascripts/Components/Button/IconButton.tsx @@ -3,7 +3,7 @@ import Icon from '@/Components/Icon/Icon' import { IconType } from '@standardnotes/snjs' type Props = { - onClick: () => void + onClick: MouseEventHandler className?: string icon: IconType iconClassName?: string @@ -21,9 +21,9 @@ const IconButton: FunctionComponent = ({ iconClassName = '', disabled = false, }) => { - const click: MouseEventHandler = (e) => { + const click: MouseEventHandler = (e) => { e.preventDefault() - onClick() + onClick(e) } const focusableClass = focusable ? '' : 'focus:shadow-none' return ( diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx index 6e690eb76..94d3ba531 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx @@ -20,9 +20,8 @@ import { DisplayOptionsMenuProps } from './DisplayOptionsMenuProps' import { PrefDefaults } from '@/Constants/PrefDefaults' import NewNotePreferences from './NewNotePreferences' import { PreferenceMode } from './PreferenceMode' -import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon' -import Button from '@/Components/Button/Button' import { classNames } from '@/Utils/ConcatenateClassNames' +import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner' const DailyEntryModeEnabled = true @@ -200,30 +199,6 @@ const DisplayOptionsMenu: FunctionComponent = ({ ) } - const NoSubscriptionBanner = () => ( -
-
- -

Upgrade for per-tag preferences

-
-

- {DailyEntryModeEnabled && - 'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'} - {!DailyEntryModeEnabled && - 'Create powerful workflows and organizational layouts with per-tag display preferences.'} -

- - -
- ) - return (
Preferences for
@@ -239,7 +214,18 @@ const DisplayOptionsMenu: FunctionComponent = ({ )} - {controlsDisabled && } + {controlsDisabled && ( + + )} diff --git a/packages/web/src/javascripts/Components/Icon/IconPicker.tsx b/packages/web/src/javascripts/Components/Icon/IconPicker.tsx index 6d02e8b60..04beefc1d 100644 --- a/packages/web/src/javascripts/Components/Icon/IconPicker.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconPicker.tsx @@ -1,9 +1,10 @@ +import { classNames } from '@/Utils/ConcatenateClassNames' import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs' import { FunctionComponent, useMemo, useRef, useState } from 'react' import Dropdown from '../Dropdown/Dropdown' import { DropdownItem } from '../Dropdown/DropdownItem' import { getEmojiLength } from './EmojiLength' -import { isIconEmoji } from './Icon' +import Icon, { isIconEmoji } from './Icon' import { IconNameToSvgMapping } from './IconNameToSvgMapping' import { IconPickerType } from './IconPickerType' @@ -11,13 +12,26 @@ type Props = { selectedValue: VectorIconNameOrEmoji onIconChange: (value?: string) => void platform: Platform + useIconGrid?: boolean + iconGridClassName?: string + portalDropdown?: boolean className?: string } -const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props) => { +const IconPicker = ({ + selectedValue, + onIconChange, + platform, + className, + useIconGrid, + portalDropdown, + iconGridClassName, +}: Props) => { + const iconKeys = useMemo(() => Object.keys(IconNameToSvgMapping), []) + const iconOptions = useMemo( () => - [...Object.keys(IconNameToSvgMapping)].map( + iconKeys.map( (value) => ({ label: value, @@ -25,7 +39,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props) icon: value, } as DropdownItem), ), - [], + [iconKeys], ) const isSelectedEmoji = isIconEmoji(selectedValue) @@ -91,16 +105,36 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
- {currentType === 'icon' && ( - - )} + {currentType === 'icon' && + (useIconGrid ? ( +
+ {iconKeys.map((iconName) => ( + + ))} +
+ ) : ( + + ))} {currentType === 'emoji' && ( <>
diff --git a/packages/web/src/javascripts/Components/NoSubscriptionBanner/NoSubscriptionBanner.tsx b/packages/web/src/javascripts/Components/NoSubscriptionBanner/NoSubscriptionBanner.tsx new file mode 100644 index 000000000..cb1e0a0f2 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoSubscriptionBanner/NoSubscriptionBanner.tsx @@ -0,0 +1,35 @@ +import { WebApplication } from '@/Application/Application' +import { classNames } from '@/Utils/ConcatenateClassNames' +import Button from '../Button/Button' +import Icon from '../Icon/Icon' +import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' + +const NoSubscriptionBanner = ({ + application, + title, + message, + className, +}: { + application: WebApplication + title: string + message: string + className?: string +}) => ( +
+
+ +

{title}

+
+

{message}

+ +
+) + +export default NoSubscriptionBanner diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx index 2c377bad5..ec99f28f5 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/General.tsx @@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe import PreferencesPane from '../../PreferencesComponents/PreferencesPane' import PlaintextDefaults from './PlaintextDefaults' import Persistence from './Persistence' +import SmartViews from './SmartViews/SmartViews' type Props = { viewControllerManager: ViewControllerManager @@ -22,6 +23,11 @@ const General: FunctionComponent = ({ viewControllerManager, application, + void +} + +const EditSmartViewModal = ({ application, navigationController, view, closeDialog }: Props) => { + const [title, setTitle] = useState(view.title) + const titleInputRef = useRef(null) + + const [selectedIcon, setSelectedIcon] = useState(view.iconString) + + const [isSaving, setIsSaving] = useState(false) + + const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false) + const iconPickerButtonRef = useRef(null) + + const toggleIconPicker = useCallback(() => { + setShouldShowIconPicker((shouldShow) => !shouldShow) + }, []) + + const saveSmartView = useCallback(async () => { + if (!title.length) { + titleInputRef.current?.focus() + return + } + + setIsSaving(true) + + await application.mutator.changeAndSaveItem(view, (mutator) => { + mutator.title = title + mutator.iconString = selectedIcon || 'restore' + }) + + setIsSaving(false) + closeDialog() + }, [application.mutator, closeDialog, selectedIcon, title, view]) + + const deleteSmartView = useCallback(async () => { + void navigationController.remove(view, true) + closeDialog() + }, [closeDialog, navigationController, view]) + + const close = useCallback(() => { + closeDialog() + }, [closeDialog]) + + return ( + + Edit Smart View "{view.title}" + +
+
+
Title:
+ { + setTitle(event.target.value) + }} + ref={titleInputRef} + /> +
+
+
Icon:
+ + +
+ { + setSelectedIcon(value) + toggleIconPicker() + }} + platform={application.platform} + useIconGrid={true} + portalDropdown={false} + /> +
+
+
+
+
+ + + + + +
+ ) +} + +export default observer(EditSmartViewModal) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViewItem.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViewItem.tsx new file mode 100644 index 000000000..dbfa18370 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViewItem.tsx @@ -0,0 +1,26 @@ +import Button from '@/Components/Button/Button' +import Icon from '@/Components/Icon/Icon' +import { SmartView } from '@standardnotes/snjs' + +type Props = { + view: SmartView + onEdit: () => void + onDelete: () => void +} + +const SmartViewItem = ({ view, onEdit, onDelete }: Props) => { + return ( +
+ + {view.title} + + +
+ ) +} + +export default SmartViewItem diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx new file mode 100644 index 000000000..3e12eed4d --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/SmartViews/SmartViews.tsx @@ -0,0 +1,85 @@ +import { WebApplication } from '@/Application/Application' +import Button from '@/Components/Button/Button' +import { NavigationController } from '@/Controllers/Navigation/NavigationController' +import { isSystemView, SmartView } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { useMemo, useState } from 'react' +import { Title } from '../../../PreferencesComponents/Content' +import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment' +import AddSmartViewModal from '@/Components/SmartViewBuilder/AddSmartViewModal' +import { AddSmartViewModalController } from '@/Components/SmartViewBuilder/AddSmartViewModalController' +import EditSmartViewModal from './EditSmartViewModal' +import SmartViewItem from './SmartViewItem' +import { FeaturesController } from '@/Controllers/FeaturesController' +import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner' + +type NewType = { + application: WebApplication + navigationController: NavigationController + featuresController: FeaturesController +} + +type Props = NewType + +const SmartViews = ({ application, navigationController, featuresController }: Props) => { + const [editingSmartView, setEditingSmartView] = useState(undefined) + + const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application]) + + const nonSystemSmartViews = navigationController.smartViews.filter((view) => !isSystemView(view)) + + return ( + <> + + + Smart Views + {!featuresController.hasSmartViews && ( + + )} + {featuresController.hasSmartViews && ( + <> +
+ {nonSystemSmartViews.map((view) => ( + setEditingSmartView(view)} + onDelete={() => navigationController.remove(view, true)} + /> + ))} +
+ + + )} +
+
+ {!!editingSmartView && ( + { + setEditingSmartView(undefined) + }} + /> + )} + {addSmartViewModalController.isAddingSmartView && ( + + )} + + ) +} + +export default observer(SmartViews) diff --git a/packages/web/src/javascripts/Components/Shared/ModalDialogDescription.tsx b/packages/web/src/javascripts/Components/Shared/ModalDialogDescription.tsx index 144eeee71..825a70ad0 100644 --- a/packages/web/src/javascripts/Components/Shared/ModalDialogDescription.tsx +++ b/packages/web/src/javascripts/Components/Shared/ModalDialogDescription.tsx @@ -7,7 +7,7 @@ type Props = { } const ModalDialogDescription: FunctionComponent = ({ children, className = '' }) => ( - {children} + {children} ) export default ModalDialogDescription diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModal.tsx b/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModal.tsx new file mode 100644 index 000000000..fe9607219 --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModal.tsx @@ -0,0 +1,107 @@ +import Button from '@/Components/Button/Button' +import CompoundPredicateBuilder from '@/Components/SmartViewBuilder/CompoundPredicateBuilder' +import Icon from '@/Components/Icon/Icon' +import IconPicker from '@/Components/Icon/IconPicker' +import Popover from '@/Components/Popover/Popover' +import ModalDialog from '@/Components/Shared/ModalDialog' +import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons' +import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription' +import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel' +import Spinner from '@/Components/Spinner/Spinner' +import { Platform } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import { useRef, useState } from 'react' +import { AddSmartViewModalController } from './AddSmartViewModalController' + +type Props = { + controller: AddSmartViewModalController + platform: Platform +} + +const AddSmartViewModal = ({ controller, platform }: Props) => { + const { isSaving, title, setTitle, icon, setIcon, closeModal, saveCurrentSmartView, predicateController } = controller + + const titleInputRef = useRef(null) + + const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false) + const iconPickerButtonRef = useRef(null) + + const toggleIconPicker = () => { + setShouldShowIconPicker((shouldShow) => !shouldShow) + } + + const save = () => { + if (!title.length) { + titleInputRef.current?.focus() + return + } + + void saveCurrentSmartView() + } + + return ( + + Add Smart View + +
+
+
Title:
+ { + setTitle(event.target.value) + }} + ref={titleInputRef} + /> +
+
+
Icon:
+ + +
+ { + setIcon(value ?? 'restore') + toggleIconPicker() + }} + platform={platform} + useIconGrid={true} + portalDropdown={false} + /> +
+
+
+
+
Predicate:
+ +
+
+
+ + + + +
+ ) +} + +export default observer(AddSmartViewModal) diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModalController.ts b/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModalController.ts new file mode 100644 index 000000000..c7cdd11f4 --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/AddSmartViewModalController.ts @@ -0,0 +1,70 @@ +import { WebApplication } from '@/Application/Application' +import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController' +import { predicateFromJson } from '@standardnotes/snjs' +import { action, makeObservable, observable } from 'mobx' + +export class AddSmartViewModalController { + isAddingSmartView = false + isSaving = false + + title = '' + + icon = 'restore' + + predicateController = new CompoundPredicateBuilderController() + + constructor(private application: WebApplication) { + makeObservable(this, { + isAddingSmartView: observable, + setIsAddingSmartView: action, + + isSaving: observable, + setIsSaving: action, + + title: observable, + setTitle: action, + + icon: observable, + setIcon: action, + }) + } + + setIsAddingSmartView = (isAddingSmartView: boolean) => { + this.isAddingSmartView = isAddingSmartView + } + + setIsSaving = (isSaving: boolean) => { + this.isSaving = isSaving + } + + setTitle = (title: string) => { + this.title = title + } + + setIcon = (icon: string) => { + this.icon = icon + } + + closeModal = () => { + this.setIsAddingSmartView(false) + this.setTitle('') + this.setIcon('') + this.setIsSaving(false) + this.predicateController.resetState() + } + + saveCurrentSmartView = async () => { + this.setIsSaving(true) + + if (!this.title) { + this.setIsSaving(false) + return + } + + const predicate = predicateFromJson(this.predicateController.toJson()) + await this.application.items.createSmartView(this.title, predicate, this.icon) + + this.setIsSaving(false) + this.closeModal() + } +} diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilder.tsx b/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilder.tsx new file mode 100644 index 000000000..a53031f58 --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilder.tsx @@ -0,0 +1,127 @@ +import { AllNonCompoundPredicateOperators, PredicateCompoundOperator, PredicateOperator } from '@standardnotes/snjs' +import { observer } from 'mobx-react-lite' +import Button from '../Button/Button' +import Icon from '../Icon/Icon' +import { CompoundPredicateBuilderController } from './CompoundPredicateBuilderController' +import { PredicateKeypath, PredicateKeypathLabels, PredicateKeypathTypes } from './PredicateKeypaths' +import PredicateValue from './PredicateValue' + +type Props = { + controller: CompoundPredicateBuilderController +} + +const CompoundPredicateBuilder = ({ controller }: Props) => { + const { operator, setOperator, predicates, setPredicate, changePredicateKeypath, addPredicate, removePredicate } = + controller + + return ( + <> +
+ + +
+ {predicates.map((predicate, index) => ( +
+
+ {index !== 0 &&
{operator === 'and' ? 'AND' : 'OR'}
} + + + {predicate.keypath && ( + { + setPredicate(index, { value }) + }} + /> + )} + {index !== 0 && ( + + )} +
+ {index === predicates.length - 1 && ( + + )} +
+ ))} + {predicates.some((predicate) => PredicateKeypathTypes[predicate.keypath as PredicateKeypath] === 'date') && ( +
+
Date Examples:
+
    +
  • + To get all the items modified within the last 7 days, you can use User Modified Date{' '} + > 7.days.ago +
  • +
  • + To get all the items created before June 2022, you can use Created At <{' '} + 06/01/2022 +
  • +
+
+ )} + + ) +} + +export default observer(CompoundPredicateBuilder) diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilderController.ts b/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilderController.ts new file mode 100644 index 000000000..4f9d78299 --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/CompoundPredicateBuilderController.ts @@ -0,0 +1,92 @@ +import { PlainEditorType } from '@/Utils/DropdownItemsForEditors' +import { NoteType, PredicateCompoundOperator, PredicateJsonForm } from '@standardnotes/snjs' +import { makeObservable, observable, action } from 'mobx' +import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths' + +const getEmptyPredicate = (): PredicateJsonForm => { + return { + keypath: 'title', + operator: '!=', + value: '', + } +} + +export class CompoundPredicateBuilderController { + operator: PredicateCompoundOperator = 'and' + predicates: PredicateJsonForm[] = [getEmptyPredicate()] + + constructor() { + makeObservable(this, { + operator: observable, + setOperator: action, + + predicates: observable, + setPredicate: action, + addPredicate: action, + removePredicate: action, + }) + } + + setOperator = (operator: PredicateCompoundOperator) => { + this.operator = operator + } + + setPredicate = (index: number, predicate: Partial) => { + const predicateAtIndex = this.predicates[index] + this.predicates[index] = { + ...predicateAtIndex, + ...predicate, + } + } + + changePredicateKeypath = (index: number, keypath: string) => { + const currentKeyPath = this.predicates[index].keypath as PredicateKeypath + const currentKeyPathType = PredicateKeypathTypes[currentKeyPath] + const newKeyPathType = PredicateKeypathTypes[keypath as PredicateKeypath] + + if (currentKeyPathType !== newKeyPathType) { + switch (newKeyPathType) { + case 'string': + this.setPredicate(index, { value: '' }) + break + case 'boolean': + this.setPredicate(index, { value: true }) + break + case 'number': + this.setPredicate(index, { value: 0 }) + break + case 'noteType': + this.setPredicate(index, { value: Object.values(NoteType)[0] }) + break + case 'editorIdentifier': + this.setPredicate(index, { value: PlainEditorType }) + break + case 'date': + this.setPredicate(index, { value: '1.days.ago' }) + break + } + } + + this.setPredicate(index, { keypath }) + } + + addPredicate = () => { + this.predicates.push(getEmptyPredicate()) + } + + removePredicate = (index: number) => { + this.predicates.splice(index, 1) + } + + toJson(): PredicateJsonForm { + return { + operator: this.operator, + value: this.predicates, + } + } + + resetState() { + this.operator = 'and' + this.predicates = [getEmptyPredicate()] + } +} diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateKeypaths.ts b/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateKeypaths.ts new file mode 100644 index 000000000..1c22bfb3d --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateKeypaths.ts @@ -0,0 +1,66 @@ +export type PredicateKeypath = + | 'title' + | 'title.length' + | 'text' + | 'text.length' + | 'noteType' + | 'authorizedForListed' + | 'editorIdentifier' + | 'userModifiedDate' + | 'serverUpdatedAt' + | 'created_at' + | 'conflict_of' + | 'protected' + | 'trashed' + | 'pinned' + | 'archived' + | 'locked' + | 'starred' + | 'hidePreview' + | 'spellcheck' + +export const PredicateKeypathLabels: { [k in PredicateKeypath]: string } = { + title: 'Title', + 'title.length': 'Title Length', + text: 'Text', + 'text.length': 'Text Length', + noteType: 'Note Type', + authorizedForListed: 'Authorized For Listed', + editorIdentifier: 'Editor Identifier', + userModifiedDate: 'User Modified Date', + serverUpdatedAt: 'Server Updated At', + created_at: 'Created At', + conflict_of: 'Conflict Of', + protected: 'Protected', + trashed: 'Trashed', + pinned: 'Pinned', + archived: 'Archived', + locked: 'Locked', + starred: 'Starred', + hidePreview: 'Hide Preview', + spellcheck: 'Spellcheck', +} as const + +export const PredicateKeypathTypes: { + [k in PredicateKeypath]: 'string' | 'noteType' | 'editorIdentifier' | 'number' | 'boolean' | 'date' +} = { + title: 'string', + 'title.length': 'number', + text: 'string', + 'text.length': 'number', + noteType: 'noteType', + authorizedForListed: 'boolean', + editorIdentifier: 'editorIdentifier', + userModifiedDate: 'date', + serverUpdatedAt: 'date', + created_at: 'date', + conflict_of: 'string', + protected: 'boolean', + trashed: 'boolean', + pinned: 'boolean', + archived: 'boolean', + locked: 'boolean', + starred: 'boolean', + hidePreview: 'boolean', + spellcheck: 'boolean', +} as const diff --git a/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateValue.tsx b/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateValue.tsx new file mode 100644 index 000000000..1adf2678a --- /dev/null +++ b/packages/web/src/javascripts/Components/SmartViewBuilder/PredicateValue.tsx @@ -0,0 +1,76 @@ +import { getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors' +import { NoteType } from '@standardnotes/snjs' +import { useApplication } from '../ApplicationView/ApplicationProvider' +import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths' + +type Props = { + keypath: PredicateKeypath + value: string + setValue: (value: string) => void +} + +const PredicateValue = ({ keypath, value, setValue }: Props) => { + const application = useApplication() + const type = PredicateKeypathTypes[keypath] + const editorItems = getDropdownItemsForAllEditors(application) + + return type === 'noteType' ? ( + + ) : type === 'editorIdentifier' ? ( + + ) : type === 'string' || type === 'date' ? ( + { + setValue(event.target.value) + }} + /> + ) : type === 'boolean' ? ( + + ) : type === 'number' ? ( + { + setValue(event.target.value) + }} + /> + ) : null +} + +export default PredicateValue diff --git a/packages/web/src/javascripts/Components/Tags/Navigation.tsx b/packages/web/src/javascripts/Components/Tags/Navigation.tsx index 4688c3635..ddb2f5105 100644 --- a/packages/web/src/javascripts/Components/Tags/Navigation.tsx +++ b/packages/web/src/javascripts/Components/Tags/Navigation.tsx @@ -57,7 +57,7 @@ const Navigation: FunctionComponent = ({ application }) => { return (
@@ -135,14 +135,7 @@ const Navigation: FunctionComponent = ({ application }) => { 'md:hover:[overflow-y:_overlay]', )} > -
-
-
- Views -
-
-
- +
{NavigationFooter} diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsList.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsList.tsx index 71454537f..57a2a1fcb 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsList.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsList.tsx @@ -1,13 +1,15 @@ import { ViewControllerManager } from '@/Controllers/ViewControllerManager' +import { SmartView } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' import { FunctionComponent } from 'react' import SmartViewsListItem from './SmartViewsListItem' type Props = { viewControllerManager: ViewControllerManager + setEditingSmartView: (smartView: SmartView) => void } -const SmartViewsList: FunctionComponent = ({ viewControllerManager }: Props) => { +const SmartViewsList: FunctionComponent = ({ viewControllerManager, setEditingSmartView }: Props) => { const allViews = viewControllerManager.navigationController.smartViews return ( @@ -19,6 +21,7 @@ const SmartViewsList: FunctionComponent = ({ viewControllerManager }: Pro view={view} tagsState={viewControllerManager.navigationController} features={viewControllerManager.featuresController} + setEditingSmartView={setEditingSmartView} /> ) })} diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx index d6eebb0d1..9cd86125b 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx @@ -22,6 +22,7 @@ type Props = { view: SmartView tagsState: NavigationController features: FeaturesController + setEditingSmartView: (smartView: SmartView) => void } const PADDING_BASE_PX = 14 @@ -35,7 +36,7 @@ const getIconClass = (view: SmartView, isSelected: boolean): string => { return mapping[view.uuid as SystemViewId] || (isSelected ? 'text-info' : 'text-neutral') } -const SmartViewsListItem: FunctionComponent = ({ view, tagsState }) => { +const SmartViewsListItem: FunctionComponent = ({ view, tagsState, setEditingSmartView }) => { const { toggleAppPane } = useResponsiveAppPane() const [title, setTitle] = useState(view.title || '') @@ -85,13 +86,9 @@ const SmartViewsListItem: FunctionComponent = ({ view, tagsState }) => { } }, [inputRef, isEditing]) - const onClickRename = useCallback(() => { - tagsState.setEditingTag(view) - }, [tagsState, view]) - - const onClickSave = useCallback(() => { - inputRef.current?.blur() - }, [inputRef]) + const onClickEdit = useCallback(() => { + setEditingSmartView(view) + }, [setEditingSmartView, view]) const onClickDelete = useCallback(() => { tagsState.remove(view, true).catch(console.error) @@ -107,6 +104,11 @@ const SmartViewsListItem: FunctionComponent = ({ view, tagsState }) => { tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} className={classNames('tag px-3.5', isSelected && 'selected', isFaded && 'opacity-50')} onClick={selectCurrentTag} + onContextMenu={(event) => { + event.preventDefault() + event.stopPropagation() + onClickEdit() + }} style={{ paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, }} @@ -147,16 +149,9 @@ const SmartViewsListItem: FunctionComponent = ({ view, tagsState }) => { {isSelected && (
- {!isEditing && ( - - Rename - - )} - {isEditing && ( - - Save - - )} + + Edit + Delete diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx index f2f48399b..4c75e9d40 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsSection.tsx @@ -1,16 +1,66 @@ +import { WebApplication } from '@/Application/Application' +import { SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants' import { ViewControllerManager } from '@/Controllers/ViewControllerManager' +import { usePremiumModal } from '@/Hooks/usePremiumModal' +import { SmartView } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' -import { FunctionComponent } from 'react' +import { FunctionComponent, useCallback, useMemo, useState } from 'react' +import IconButton from '../Button/IconButton' +import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal' +import AddSmartViewModal from '../SmartViewBuilder/AddSmartViewModal' +import { AddSmartViewModalController } from '../SmartViewBuilder/AddSmartViewModalController' import SmartViewsList from './SmartViewsList' type Props = { + application: WebApplication viewControllerManager: ViewControllerManager } -const SmartViewsSection: FunctionComponent = ({ viewControllerManager }) => { +const SmartViewsSection: FunctionComponent = ({ application, viewControllerManager }) => { + const premiumModal = usePremiumModal() + const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application]) + + const [editingSmartView, setEditingSmartView] = useState(undefined) + + const createNewSmartView = useCallback(() => { + if (!viewControllerManager.featuresController.hasSmartViews) { + premiumModal.activate(SMART_TAGS_FEATURE_NAME) + return + } + + addSmartViewModalController.setIsAddingSmartView(true) + }, [addSmartViewModalController, premiumModal, viewControllerManager.featuresController.hasSmartViews]) + return (
- +
+
+
+ Views +
+ +
+
+ + {!!editingSmartView && ( + { + setEditingSmartView(undefined) + }} + /> + )} + {addSmartViewModalController.isAddingSmartView && ( + + )}
) } diff --git a/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx b/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx index db8be9295..ef7f884f1 100644 --- a/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagContextMenu.tsx @@ -78,6 +78,8 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag selectedValue={selectedTag.iconString} platform={application.platform} className={'px-3 py-1.5'} + useIconGrid={true} + iconGridClassName="max-h-30" /> diff --git a/packages/web/tailwind.config.js b/packages/web/tailwind.config.js index 06e26f3f1..17c07fd8b 100644 --- a/packages/web/tailwind.config.js +++ b/packages/web/tailwind.config.js @@ -8,6 +8,7 @@ module.exports = { extend: { spacing: { 4.5: '1.125rem', + 5.5: '1.325rem', 8.5: '2.125rem', 13: '3.25rem', 15: '3.75rem',