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

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