feat: GUI to create smart views (#1997)

This commit is contained in:
Aman Harwara
2022-11-14 19:40:00 +05:30
committed by GitHub
parent 1c23bc1747
commit f656185c16
28 changed files with 1032 additions and 78 deletions

View File

@@ -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 | ''

View File

@@ -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)
}

View File

@@ -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', () => {

View File

@@ -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)
})
})
})

View File

@@ -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
}

View File

@@ -148,4 +148,10 @@ export interface ItemsClientInterface {
* @returns Whether the item is a template (unmanaged)
*/
isTemplateItem(item: DecryptedItemInterface): boolean
createSmartView<T extends DecryptedItemInterface<ItemContent>>(
title: string,
predicate: PredicateInterface<T>,
iconString?: string,
): Promise<SmartView>
}

View File

@@ -1222,12 +1222,14 @@ export class ItemManager
public async createSmartView<T extends Models.DecryptedItemInterface>(
title: string,
predicate: Models.PredicateInterface<T>,
iconString?: string,
): Promise<Models.SmartView> {
return this.createItem(
ContentType.SmartView,
Models.FillItemContent({
title,
predicate: predicate.toJson(),
iconString: iconString || 'restore',
} as Models.SmartViewContent),
true,
) as Promise<Models.SmartView>

View File

@@ -3,7 +3,7 @@ import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs'
type Props = {
onClick: () => void
onClick: MouseEventHandler<HTMLButtonElement>
className?: string
icon: IconType
iconClassName?: string
@@ -21,9 +21,9 @@ const IconButton: FunctionComponent<Props> = ({
iconClassName = '',
disabled = false,
}) => {
const click: MouseEventHandler = (e) => {
const click: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
onClick()
onClick(e)
}
const focusableClass = focusable ? '' : 'focus:shadow-none'
return (

View File

@@ -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<DisplayOptionsMenuProps> = ({
)
}
const NoSubscriptionBanner = () => (
<div className="m-2 mt-2 mb-3 grid grid-cols-1 rounded-md border border-border p-4">
<div className="flex items-center">
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
<h1 className="sk-h3 m-0 text-sm font-semibold">Upgrade for per-tag preferences</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
{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.'}
</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => application.openPurchaseFlow()}
>
Upgrade Features
</Button>
</div>
)
return (
<Menu className="text-sm" a11yLabel="Notes list options menu" closeMenu={closeDisplayOptionsMenu} isOpen={isOpen}>
<div className="my-1 px-3 text-base font-semibold uppercase text-text lg:text-xs">Preferences for</div>
@@ -239,7 +214,18 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
)}
</div>
{controlsDisabled && <NoSubscriptionBanner />}
{controlsDisabled && (
<NoSubscriptionBanner
className="m-2 mt-2 mb-3"
application={application}
title="Upgrade for per-tag preferences"
message={
DailyEntryModeEnabled
? 'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'
: 'Create powerful workflows and organizational layouts with per-tag display preferences.'
}
/>
)}
<MenuItemSeparator />

View File

@@ -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)
<TabButton label="Reset" type={'reset'} />
</div>
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
{currentType === 'icon' && (
<Dropdown
fullWidth={true}
id="change-tag-icon-dropdown"
label="Change the icon for a tag"
items={iconOptions}
value={selectedValue}
onChange={handleIconChange}
/>
)}
{currentType === 'icon' &&
(useIconGrid ? (
<div
className={classNames(
'flex w-full flex-wrap items-center gap-6 p-1 md:max-h-24 md:gap-4 md:p-0',
iconGridClassName,
)}
>
{iconKeys.map((iconName) => (
<button
key={iconName}
onClick={() => {
handleIconChange(iconName)
}}
>
<Icon type={iconName} />
</button>
))}
</div>
) : (
<Dropdown
fullWidth={true}
id="change-tag-icon-dropdown"
label="Change the icon for a tag"
items={iconOptions}
value={selectedValue}
onChange={handleIconChange}
portal={portalDropdown}
/>
))}
{currentType === 'emoji' && (
<>
<div>

View File

@@ -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
}) => (
<div className={classNames('grid grid-cols-1 rounded-md border border-border p-4', className)}>
<div className="flex items-center">
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
<h1 className="sk-h3 m-0 text-sm font-semibold">{title}</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">{message}</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => application.openPurchaseFlow()}
>
Upgrade Features
</Button>
</div>
)
export default NoSubscriptionBanner

View File

@@ -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<Props> = ({ viewControllerManager, application,
<Persistence application={application} />
<PlaintextDefaults application={application} />
<Defaults application={application} />
<SmartViews
application={application}
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<Tools application={application} />
<LabsPane application={application} />
<Advanced

View File

@@ -0,0 +1,128 @@
import { WebApplication } from '@/Application/Application'
import Button from '@/Components/Button/Button'
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 { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { SmartView, TagMutator } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useRef, useState } from 'react'
type Props = {
application: WebApplication
navigationController: NavigationController
view: SmartView
closeDialog: () => void
}
const EditSmartViewModal = ({ application, navigationController, view, closeDialog }: Props) => {
const [title, setTitle] = useState(view.title)
const titleInputRef = useRef<HTMLInputElement>(null)
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(view.iconString)
const [isSaving, setIsSaving] = useState(false)
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
const toggleIconPicker = useCallback(() => {
setShouldShowIconPicker((shouldShow) => !shouldShow)
}, [])
const saveSmartView = useCallback(async () => {
if (!title.length) {
titleInputRef.current?.focus()
return
}
setIsSaving(true)
await application.mutator.changeAndSaveItem<TagMutator>(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 (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Edit Smart View "{view.title}"</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
className="rounded border border-border bg-default py-1 px-2"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
ref={titleInputRef}
/>
</div>
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Icon:</div>
<button
className="rounded border border-border p-2"
aria-label="Change icon"
onClick={toggleIconPicker}
ref={iconPickerButtonRef}
>
<Icon type={selectedIcon || 'restore'} />
</button>
<Popover
open={shouldShowIconPicker}
anchorElement={iconPickerButtonRef.current}
togglePopover={toggleIconPicker}
align="start"
overrideZIndex="z-modal"
>
<div className="p-2">
<IconPicker
selectedValue={selectedIcon || 'restore'}
onIconChange={(value?: string | undefined) => {
setSelectedIcon(value)
toggleIconPicker()
}}
platform={application.platform}
useIconGrid={true}
portalDropdown={false}
/>
</div>
</Popover>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="mr-auto" disabled={isSaving} onClick={deleteSmartView} colorStyle="danger">
Delete
</Button>
<Button disabled={isSaving} onClick={saveSmartView}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
</Button>
<Button disabled={isSaving} onClick={close}>
Cancel
</Button>
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(EditSmartViewModal)

View File

@@ -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 (
<div className="flex items-center gap-2 py-1.5">
<Icon type={view.iconString} size="custom" className="h-5.5 w-5.5" />
<span className="mr-auto text-sm">{view.title}</span>
<Button small onClick={onEdit}>
Edit
</Button>
<Button small onClick={onDelete}>
Delete
</Button>
</div>
)
}
export default SmartViewItem

View File

@@ -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<SmartView | undefined>(undefined)
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
const nonSystemSmartViews = navigationController.smartViews.filter((view) => !isSystemView(view))
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Smart Views</Title>
{!featuresController.hasSmartViews && (
<NoSubscriptionBanner
className="mt-2"
application={application}
title="Upgrade for smart views"
message="Create smart views to organize your notes according to conditions you define."
/>
)}
{featuresController.hasSmartViews && (
<>
<div className="my-2 flex flex-col">
{nonSystemSmartViews.map((view) => (
<SmartViewItem
key={view.uuid}
view={view}
onEdit={() => setEditingSmartView(view)}
onDelete={() => navigationController.remove(view, true)}
/>
))}
</div>
<Button
onClick={() => {
addSmartViewModalController.setIsAddingSmartView(true)
}}
>
Create Smart View
</Button>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
{!!editingSmartView && (
<EditSmartViewModal
application={application}
navigationController={navigationController}
view={editingSmartView}
closeDialog={() => {
setEditingSmartView(undefined)
}}
/>
)}
{addSmartViewModalController.isAddingSmartView && (
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</>
)
}
export default observer(SmartViews)

View File

@@ -7,7 +7,7 @@ type Props = {
}
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
<AlertDialogDescription className={`overflow-y-scroll px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
<AlertDialogDescription className={`overflow-y-auto px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
)
export default ModalDialogDescription

View File

@@ -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<HTMLInputElement>(null)
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
const toggleIconPicker = () => {
setShouldShowIconPicker((shouldShow) => !shouldShow)
}
const save = () => {
if (!title.length) {
titleInputRef.current?.focus()
return
}
void saveCurrentSmartView()
}
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
className="rounded border border-border bg-default py-1 px-2"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
ref={titleInputRef}
/>
</div>
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Icon:</div>
<button
className="rounded border border-border p-2"
aria-label="Change icon"
onClick={toggleIconPicker}
ref={iconPickerButtonRef}
>
<Icon type={icon || 'restore'} />
</button>
<Popover
open={shouldShowIconPicker}
anchorElement={iconPickerButtonRef.current}
togglePopover={toggleIconPicker}
align="start"
overrideZIndex="z-modal"
>
<div className="p-2">
<IconPicker
selectedValue={icon || 'restore'}
onIconChange={(value?: string | undefined) => {
setIcon(value ?? 'restore')
toggleIconPicker()
}}
platform={platform}
useIconGrid={true}
portalDropdown={false}
/>
</div>
</Popover>
</div>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-semibold">Predicate:</div>
<CompoundPredicateBuilder controller={predicateController} />
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button disabled={isSaving} onClick={save}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
</Button>
<Button disabled={isSaving} onClick={closeModal}>
Cancel
</Button>
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(AddSmartViewModal)

View File

@@ -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()
}
}

View File

@@ -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 (
<>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2">
<input
type="radio"
name="predicate"
value="and"
checked={operator === 'and'}
onChange={(event) => {
setOperator(event.target.value as PredicateCompoundOperator)
}}
/>
Should match ALL conditions
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="predicate"
value="or"
checked={operator === 'or'}
onChange={(event) => {
setOperator(event.target.value as PredicateCompoundOperator)
}}
/>
Should match ANY conditions
</label>
</div>
{predicates.map((predicate, index) => (
<div className="flex flex-col gap-2.5" key={index}>
<div className="flex w-full items-center gap-2">
{index !== 0 && <div className="mr-2 text-sm font-semibold">{operator === 'and' ? 'AND' : 'OR'}</div>}
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={predicate.keypath}
onChange={(event) => {
changePredicateKeypath(index, event.target.value as PredicateKeypath)
}}
>
{Object.entries(PredicateKeypathLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<select
className="rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={predicate.operator}
onChange={(event) => {
setPredicate(index, { operator: event.target.value as PredicateOperator })
}}
>
{AllNonCompoundPredicateOperators.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
{predicate.keypath && (
<PredicateValue
keypath={predicate.keypath as PredicateKeypath}
value={predicate.value.toString()}
setValue={(value: string) => {
setPredicate(index, { value })
}}
/>
)}
{index !== 0 && (
<button
className="rounded border border-border p-1 text-danger"
aria-label="Remove condition"
onClick={() => {
removePredicate(index)
}}
>
<Icon type="trash" />
</button>
)}
</div>
{index === predicates.length - 1 && (
<Button
className="flex items-center gap-2"
onClick={() => {
addPredicate()
}}
>
Add another condition
</Button>
)}
</div>
))}
{predicates.some((predicate) => PredicateKeypathTypes[predicate.keypath as PredicateKeypath] === 'date') && (
<div className="flex flex-col gap-2 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4 [&_code]:rounded [&_code]:bg-default [&_code]:px-1.5 [&_code]:py-1">
<div className="text-sm font-semibold">Date Examples:</div>
<ul className="space-y-2 pl-4">
<li>
To get all the items modified within the last 7 days, you can use <code>User Modified Date</code>{' '}
<code>&gt;</code> <code>7.days.ago</code>
</li>
<li>
To get all the items created before June 2022, you can use <code>Created At</code> <code>&lt;</code>{' '}
<code>06/01/2022</code>
</li>
</ul>
</div>
)}
</>
)
}
export default observer(CompoundPredicateBuilder)

View File

@@ -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<PredicateJsonForm>) => {
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()]
}
}

View File

@@ -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

View File

@@ -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' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
{Object.entries(NoteType).map(([key, value]) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
) : type === 'editorIdentifier' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
{editorItems.map((editor) => (
<option key={editor.value} value={editor.value}>
{editor.label}
</option>
))}
</select>
) : type === 'string' || type === 'date' ? (
<input
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
/>
) : type === 'boolean' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
) : type === 'number' ? (
<input
type="number"
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
/>
) : null
}
export default PredicateValue

View File

@@ -57,7 +57,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
return (
<div
className={classNames(
'fixed bottom-0 flex min-h-[50px] w-full w-full items-center border-t border-border bg-contrast',
'fixed bottom-0 flex min-h-[50px] w-full items-center border-t border-border bg-contrast',
'px-3.5 pb-safe-bottom pt-2.5 md:hidden',
)}
>
@@ -135,14 +135,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
'md:hover:[overflow-y:_overlay]',
)}
>
<div className={'section-title-bar'}>
<div className="section-title-bar-header">
<div className="title text-base md:text-sm">
<span className="font-bold">Views</span>
</div>
</div>
</div>
<SmartViewsSection viewControllerManager={viewControllerManager} />
<SmartViewsSection application={application} viewControllerManager={viewControllerManager} />
<TagsSection viewControllerManager={viewControllerManager} />
</div>
{NavigationFooter}

View File

@@ -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<Props> = ({ viewControllerManager }: Props) => {
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager, setEditingSmartView }: Props) => {
const allViews = viewControllerManager.navigationController.smartViews
return (
@@ -19,6 +21,7 @@ const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Pro
view={view}
tagsState={viewControllerManager.navigationController}
features={viewControllerManager.featuresController}
setEditingSmartView={setEditingSmartView}
/>
)
})}

View File

@@ -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<Props> = ({ view, tagsState }) => {
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEditingSmartView }) => {
const { toggleAppPane } = useResponsiveAppPane()
const [title, setTitle] = useState(view.title || '')
@@ -85,13 +86,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ 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<Props> = ({ 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<Props> = ({ view, tagsState }) => {
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
)}
<a className="item" onClick={onClickEdit}>
Edit
</a>
<a className="item" onClick={onClickDelete}>
Delete
</a>

View File

@@ -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<Props> = ({ viewControllerManager }) => {
const SmartViewsSection: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
const premiumModal = usePremiumModal()
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
const [editingSmartView, setEditingSmartView] = useState<SmartView | undefined>(undefined)
const createNewSmartView = useCallback(() => {
if (!viewControllerManager.featuresController.hasSmartViews) {
premiumModal.activate(SMART_TAGS_FEATURE_NAME)
return
}
addSmartViewModalController.setIsAddingSmartView(true)
}, [addSmartViewModalController, premiumModal, viewControllerManager.featuresController.hasSmartViews])
return (
<section>
<SmartViewsList viewControllerManager={viewControllerManager} />
<div className={'section-title-bar'}>
<div className="section-title-bar-header">
<div className="title text-base md:text-sm">
<span className="font-bold">Views</span>
</div>
<IconButton
focusable={true}
icon="add"
title="Create a new smart view"
className="p-0 text-neutral"
onClick={createNewSmartView}
/>
</div>
</div>
<SmartViewsList viewControllerManager={viewControllerManager} setEditingSmartView={setEditingSmartView} />
{!!editingSmartView && (
<EditSmartViewModal
application={application}
navigationController={viewControllerManager.navigationController}
view={editingSmartView}
closeDialog={() => {
setEditingSmartView(undefined)
}}
/>
)}
{addSmartViewModalController.isAddingSmartView && (
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</section>
)
}

View File

@@ -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"
/>
<HorizontalSeparator classes="my-2" />
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>

View File

@@ -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',