feat: GUI to create smart views (#1997)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user