feat: GUI to create smart views (#1997)
This commit is contained in:
@@ -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 | ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
25
packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts
Normal file
25
packages/models/src/Domain/Runtime/Predicate/Utils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>></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><</code>{' '}
|
||||
<code>06/01/2022</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(CompoundPredicateBuilder)
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user