- {title && (
-
-
{title}
-
-
+
+
- )}
- {children}
+ )}
+ {children}
+
@@ -97,4 +103,6 @@ function Modal({
)
}
-export default memo(Modal)
+const ModalMemo = memo(Modal)
+
+export default ModalMemo
diff --git a/examples/linearlite/src/components/ProfileMenu.tsx b/examples/linearlite/src/components/ProfileMenu.tsx
index 2a728b13..740d1588 100644
--- a/examples/linearlite/src/components/ProfileMenu.tsx
+++ b/examples/linearlite/src/components/ProfileMenu.tsx
@@ -3,13 +3,14 @@ import { useRef } from 'react'
import classnames from 'classnames'
import { useConnectivityState } from 'electric-sql/react'
import { useClickOutside } from '../hooks/useClickOutside'
-import Toggle from './Toggle'
import { useElectric } from '../electric'
+import Toggle from './Toggle'
interface Props {
isOpen: boolean
onDismiss?: () => void
setShowAboutModal?: (show: boolean) => void
+ setShowProjectModal?: (show: boolean) => void
className?: string
}
export default function ProfileMenu({
@@ -17,6 +18,7 @@ export default function ProfileMenu({
className,
onDismiss,
setShowAboutModal,
+ setShowProjectModal,
}: Props) {
const electric = useElectric()!
const connectivityState = useConnectivityState()
@@ -55,45 +57,55 @@ export default function ProfileMenu({
leave="transition easy-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
- className={classes}
>
-
-
- Visit ElectricSQL
-
-
- Documentation
-
-
- GitHub
-
-
-
- {connectivityStateDisplay}
-
-
+
diff --git a/examples/linearlite/src/components/ProjectItem.tsx b/examples/linearlite/src/components/ProjectItem.tsx
new file mode 100644
index 00000000..fd3bbd72
--- /dev/null
+++ b/examples/linearlite/src/components/ProjectItem.tsx
@@ -0,0 +1,96 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useElectric } from '../electric'
+
+import { BsCheck } from 'react-icons/bs'
+import { AiOutlineLoading3Quarters } from 'react-icons/ai'
+import { Checkbox } from '@headlessui/react'
+
+interface Props {
+ title: string
+ projectId: string
+ syncOnMount?: boolean
+}
+function ProjectItem({ title, projectId, syncOnMount = false }: Props) {
+ const { db, sync } = useElectric()!
+ const [loading, setLoading] = useState(false)
+ const [synced, setSynced] = useState(false)
+
+ const syncProject = useCallback(async () => {
+ setLoading(true)
+ try {
+ const synced = await db.issue.sync({
+ where: {
+ project_id: projectId,
+ },
+ include: {
+ comment: true,
+ project: true,
+ },
+ key: projectId,
+ })
+ await synced.synced
+ setSynced(true)
+ } catch (err) {
+ console.error(err)
+ } finally {
+ setLoading(false)
+ }
+ }, [projectId, db.issue])
+
+ const unsyncProject = useCallback(async () => {
+ setLoading(true)
+ try {
+ await sync.unsubscribe([projectId])
+ setSynced(false)
+ } catch (err) {
+ console.error(err)
+ } finally {
+ setLoading(false)
+ }
+ }, [projectId, sync])
+
+ useEffect(() => {
+ const status = sync.syncStatus(projectId)
+
+ // if sub not present, do nothing
+ if (!status) {
+ if (syncOnMount) syncProject()
+ return
+ }
+
+ switch (status.status) {
+ case 'cancelling':
+ unsyncProject()
+ break
+ case 'establishing':
+ syncProject()
+ break
+ case 'active':
+ setSynced(true)
+ break
+ }
+ }, [sync, syncProject, unsyncProject, projectId, syncOnMount])
+
+ return (
+
+ )
+}
+
+export default ProjectItem
diff --git a/examples/linearlite/src/components/ProjectModal.tsx b/examples/linearlite/src/components/ProjectModal.tsx
new file mode 100644
index 00000000..2d922792
--- /dev/null
+++ b/examples/linearlite/src/components/ProjectModal.tsx
@@ -0,0 +1,141 @@
+import { memo, useEffect, useRef, useState } from 'react'
+import { v4 as uuidv4 } from 'uuid'
+import { useElectric } from '../electric'
+import { showInfo, showWarning } from '../utils/notification'
+import { generateKeyBetween } from 'fractional-indexing'
+
+import { BsChevronRight as ChevronRight } from 'react-icons/bs'
+import CloseIcon from '../assets/icons/close.svg?react'
+import ElectricIcon from '../assets/images/icon.inverse.svg?react'
+
+import Modal from './Modal'
+import Editor from './editor/Editor'
+
+interface Props {
+ isOpen: boolean
+ onDismiss?: () => void
+}
+
+function ProjectModal({ isOpen, onDismiss }: Props) {
+ const ref = useRef
(null)
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState()
+ const { db } = useElectric()!
+
+ const handleSubmit = async () => {
+ if (title === '') {
+ showWarning('Please enter a title before submitting', 'Title required')
+ return
+ }
+
+ const lastProject = await db.project.findFirst({
+ orderBy: {
+ kanbanorder: 'desc',
+ },
+ })
+
+ const kanbanorder = generateKeyBetween(lastProject?.kanbanorder, null)
+
+ const date = new Date()
+ db.project.create({
+ data: {
+ id: uuidv4(),
+ name: title,
+ description: description ?? '',
+ created: date,
+ modified: date,
+ kanbanorder: kanbanorder,
+ },
+ })
+
+ if (onDismiss) onDismiss()
+ reset()
+ showInfo('You created new project.', 'Project created')
+ }
+
+ const handleClickCloseBtn = () => {
+ if (onDismiss) onDismiss()
+ reset()
+ }
+
+ const reset = () => {
+ setTimeout(() => {
+ setTitle('')
+ setDescription('')
+ }, 250)
+ }
+
+ useEffect(() => {
+ if (isOpen) {
+ setTimeout(() => {
+ ref.current?.focus()
+ }, 250)
+ }
+ }, [isOpen])
+
+ const body = (
+
+ {/* header */}
+
+
+
+
+ electric
+
+
+ New Project
+
+
+
+
+
+
+ {/* Project title */}
+
+ setTitle(e.target.value)}
+ />
+
+
+ {/* Project description editor */}
+
+ setDescription(val)}
+ placeholder="Add description..."
+ />
+
+
+
+ {/* Footer */}
+
+
+
+
+ )
+
+ return (
+
+ {body}
+
+ )
+}
+
+const ProjectModalMemo = memo(ProjectModal)
+export default ProjectModalMemo
diff --git a/examples/linearlite/src/components/TopFilter.tsx b/examples/linearlite/src/components/TopFilter.tsx
index fb3cbbe4..9818d3f8 100644
--- a/examples/linearlite/src/components/TopFilter.tsx
+++ b/examples/linearlite/src/components/TopFilter.tsx
@@ -1,4 +1,4 @@
-import { ReactComponent as MenuIcon } from '../assets/icons/menu.svg'
+import MenuIcon from '../assets/icons/menu.svg?react'
import { useState, useContext } from 'react'
import { BsSortUp, BsPlus, BsX, BsSearch as SearchIcon } from 'react-icons/bs'
import { useLiveQuery } from 'electric-sql/react'
@@ -16,7 +16,7 @@ interface Props {
title?: string
}
-export default function ({
+export default function TopFilter({
issues,
hideSort,
showSearch,
@@ -57,9 +57,9 @@ export default function ({
if (filterState.status?.length) {
if (eqStatuses(['backlog'])) {
- title = 'Backlog'
+ title += ' : Backlog'
} else if (eqStatuses(['todo', 'in_progress'])) {
- title = 'Active'
+ title += ' : Active'
}
}
@@ -75,9 +75,11 @@ export default function ({
- {title}
+
+ {title}
+
{/* {filteredIssuesCount} */}
-
+
{filteredIssuesCount}
{filteredIssuesCount !== totalIssuesCount
? ` of ${totalIssuesCount}`
diff --git a/examples/linearlite/src/components/ViewOptionMenu.tsx b/examples/linearlite/src/components/ViewOptionMenu.tsx
index 033b1228..5d469a55 100644
--- a/examples/linearlite/src/components/ViewOptionMenu.tsx
+++ b/examples/linearlite/src/components/ViewOptionMenu.tsx
@@ -8,7 +8,7 @@ interface Props {
isOpen: boolean
onDismiss?: () => void
}
-export default function ({ isOpen, onDismiss }: Props) {
+export default function ViewOptionMenu({ isOpen, onDismiss }: Props) {
const ref = useRef(null)
const [filterState, setFilterState] = useFilterState()
@@ -42,14 +42,14 @@ export default function ({ isOpen, onDismiss }: Props) {
leave="transition easy-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
- className="fixed right-0 z-30 flex flex-col bg-white rounded-lg shadow-modal top-12 w-70"
>
-
- View Options
-
+
+
+ View Options
+
-
- {/*
+
+ {/*
Grouping
*/}
-
-
Ordering
-
-
-
-
-
+
+
Ordering
+
+
+
+
+
+
diff --git a/examples/linearlite/src/components/contextmenu/StatusMenu.tsx b/examples/linearlite/src/components/contextmenu/StatusMenu.tsx
index c449aaa4..4bbdf908 100644
--- a/examples/linearlite/src/components/contextmenu/StatusMenu.tsx
+++ b/examples/linearlite/src/components/contextmenu/StatusMenu.tsx
@@ -8,7 +8,7 @@ interface Props {
id: string
button: ReactNode
className?: string
- onSelect?: (item: any) => void
+ onSelect?: (item: string) => void
}
export default function StatusMenu({ id, button, className, onSelect }: Props) {
const [keyword, setKeyword] = useState('')
diff --git a/examples/linearlite/src/components/editor/Editor.tsx b/examples/linearlite/src/components/editor/Editor.tsx
index df030d96..0b97a596 100644
--- a/examples/linearlite/src/components/editor/Editor.tsx
+++ b/examples/linearlite/src/components/editor/Editor.tsx
@@ -57,7 +57,7 @@ const Editor = ({
if (editor && markdownValue.current !== value) {
editor.commands.setContent(value)
}
- }, [value])
+ }, [value, editor])
if (placeholder) {
extensions.push(
diff --git a/examples/linearlite/src/electric.tsx b/examples/linearlite/src/electric.ts
similarity index 96%
rename from examples/linearlite/src/electric.tsx
rename to examples/linearlite/src/electric.ts
index f6984484..bdce115a 100644
--- a/examples/linearlite/src/electric.tsx
+++ b/examples/linearlite/src/electric.ts
@@ -82,6 +82,9 @@ export const initElectric = async () => {
console.log(conn)
console.log(schema)
console.log(config)
+
+ const { addToolbar } = await import('@electric-sql/debug-toolbar')
+ addToolbar(electric)
}
let userId = window.sessionStorage.getItem('userId')
diff --git a/examples/linearlite/src/hooks/useClickOutside.ts b/examples/linearlite/src/hooks/useClickOutside.ts
index e1665489..472baf8c 100644
--- a/examples/linearlite/src/hooks/useClickOutside.ts
+++ b/examples/linearlite/src/hooks/useClickOutside.ts
@@ -14,7 +14,7 @@ export const useClickOutside = (
callback(event)
}
},
- [callback, ref]
+ [callback, ref, outerRef]
)
useEffect(() => {
document.addEventListener('mousedown', handleClick)
diff --git a/examples/linearlite/src/pages/Board/IssueBoard.tsx b/examples/linearlite/src/pages/Board/IssueBoard.tsx
index bffa2f12..94a6eda1 100644
--- a/examples/linearlite/src/pages/Board/IssueBoard.tsx
+++ b/examples/linearlite/src/pages/Board/IssueBoard.tsx
@@ -1,4 +1,4 @@
-import { DragDropContext, DropResult } from 'react-beautiful-dnd'
+import { DragDropContext, DropResult } from '@hello-pangea/dnd'
import { useMemo, useState, useEffect } from 'react'
import { generateKeyBetween } from 'fractional-indexing'
import { Issue, useElectric } from '../../electric'
diff --git a/examples/linearlite/src/pages/Board/IssueCol.tsx b/examples/linearlite/src/pages/Board/IssueCol.tsx
index e1eb1e53..1d248826 100644
--- a/examples/linearlite/src/pages/Board/IssueCol.tsx
+++ b/examples/linearlite/src/pages/Board/IssueCol.tsx
@@ -7,7 +7,7 @@ import {
Draggable,
DraggableProvided,
DraggableStateSnapshot,
-} from 'react-beautiful-dnd'
+} from '@hello-pangea/dnd'
import { FixedSizeList as List, areEqual } from 'react-window'
import AutoSizer from 'react-virtualized-auto-sizer'
import { Issue } from '../../electric'
@@ -69,7 +69,9 @@ function IssueCol({ title, status, issues = [] }: Props) {
{({ height, width }) => (
{Row}
@@ -99,7 +101,7 @@ const Row = memo(
}: {
data: Issue[]
index: number
- style: any
+ style?: React.CSSProperties
}) => {
const issue = issues[index]
if (!issue) return null
@@ -120,4 +122,5 @@ const Row = memo(
areEqual
)
-export default memo(IssueCol)
+const IssueColMemo = memo(IssueCol)
+export default IssueColMemo
diff --git a/examples/linearlite/src/pages/Board/IssueItem.tsx b/examples/linearlite/src/pages/Board/IssueItem.tsx
index ee1ba78f..92270a4e 100644
--- a/examples/linearlite/src/pages/Board/IssueItem.tsx
+++ b/examples/linearlite/src/pages/Board/IssueItem.tsx
@@ -1,7 +1,7 @@
import { memo, type CSSProperties } from 'react'
import classNames from 'classnames'
import { useNavigate } from 'react-router-dom'
-import { DraggableProvided } from 'react-beautiful-dnd'
+import { DraggableProvided } from '@hello-pangea/dnd'
import Avatar from '../../components/Avatar'
import PriorityMenu from '../../components/contextmenu/PriorityMenu'
import PriorityIcon from '../../components/PriorityIcon'
@@ -85,4 +85,6 @@ const IssueItem = ({ issue, style, isDragging, provided }: IssueProps) => {
)
}
-export default memo(IssueItem)
+const IssueItemMemo = memo(IssueItem)
+
+export default IssueItemMemo
diff --git a/examples/linearlite/src/pages/Board/index.tsx b/examples/linearlite/src/pages/Board/index.tsx
index bc7e4fca..ac024c69 100644
--- a/examples/linearlite/src/pages/Board/index.tsx
+++ b/examples/linearlite/src/pages/Board/index.tsx
@@ -2,24 +2,34 @@ import TopFilter from '../../components/TopFilter'
import IssueBoard from './IssueBoard'
import { Issue, useElectric } from '../../electric'
import { useLiveQuery } from 'electric-sql/react'
+import { useParams } from 'react-router-dom'
import { useFilterState, filterStateToWhere } from '../../utils/filterState'
function Board() {
const [filterState] = useFilterState()
+ const { id } = useParams()
const { db } = useElectric()!
const { results } = useLiveQuery(
db.issue.liveMany({
orderBy: {
kanbanorder: 'asc',
},
- where: filterStateToWhere(filterState),
+ where: {
+ ...filterStateToWhere(filterState),
+ project_id: id,
+ },
+ })
+ )
+ const { results: project } = useLiveQuery(
+ db.project.liveUnique({
+ where: { id: id },
})
)
const issues: Issue[] = results ?? []
return (
-
+
)
diff --git a/examples/linearlite/src/pages/Issue/index.tsx b/examples/linearlite/src/pages/Issue/index.tsx
index 3317bf84..42e3e050 100644
--- a/examples/linearlite/src/pages/Issue/index.tsx
+++ b/examples/linearlite/src/pages/Issue/index.tsx
@@ -24,6 +24,17 @@ function IssuePage() {
const { results: issue } = useLiveQuery(
db.issue.liveUnique({
where: { id: id },
+ include: {
+ project: true,
+ },
+ })
+ )
+
+ const { results: projects } = useLiveQuery(
+ db.project.liveMany({
+ orderBy: {
+ name: 'asc',
+ },
})
)
@@ -120,6 +131,18 @@ function IssuePage() {
handleDescriptionChangeDebounced(description)
}
+ const handleProjectChange = (project_id: string) => {
+ db.issue.update({
+ data: {
+ project_id: project_id,
+ modified: new Date(),
+ },
+ where: {
+ id: issue.id,
+ },
+ })
+ }
+
const handleDelete = () => {
db.comment.deleteMany({
where: {
@@ -182,6 +205,27 @@ function IssuePage() {
+
+
+ Project
+
+
+ {/* */}
+
+
+
Opened by
diff --git a/examples/linearlite/src/pages/List/IssueRow.tsx b/examples/linearlite/src/pages/List/IssueRow.tsx
index 5500a0a4..6e2b90dc 100644
--- a/examples/linearlite/src/pages/List/IssueRow.tsx
+++ b/examples/linearlite/src/pages/List/IssueRow.tsx
@@ -77,4 +77,6 @@ function IssueRow({ issue, style }: Props) {
)
}
-export default memo(IssueRow)
+const IssueRowMemo = memo(IssueRow)
+
+export default IssueRowMemo
diff --git a/examples/linearlite/src/pages/List/index.tsx b/examples/linearlite/src/pages/List/index.tsx
index 5c781b2f..0b0b3009 100644
--- a/examples/linearlite/src/pages/List/index.tsx
+++ b/examples/linearlite/src/pages/List/index.tsx
@@ -2,22 +2,32 @@ import TopFilter from '../../components/TopFilter'
import IssueList from './IssueList'
import { Issue, useElectric } from '../../electric'
import { useLiveQuery } from 'electric-sql/react'
+import { useParams } from 'react-router-dom'
import { useFilterState, filterStateToWhere } from '../../utils/filterState'
function List({ showSearch = false }) {
const [filterState] = useFilterState()
+ const { id } = useParams()
const { db } = useElectric()!
const { results } = useLiveQuery(
db.issue.liveMany({
orderBy: { [filterState.orderBy]: filterState.orderDirection },
- where: filterStateToWhere(filterState),
+ where: {
+ ...filterStateToWhere(filterState),
+ ...(id && { project_id: id }),
+ },
+ })
+ )
+ const { results: project } = useLiveQuery(
+ db.project.liveUnique({
+ where: { id: id },
})
)
const issues: Issue[] = results ?? []
return (
-
+
)
diff --git a/examples/linearlite/src/shims/react-contextmenu.d.ts b/examples/linearlite/src/shims/react-contextmenu.d.ts
index 76ee7b68..367f0617 100644
--- a/examples/linearlite/src/shims/react-contextmenu.d.ts
+++ b/examples/linearlite/src/shims/react-contextmenu.d.ts
@@ -1,5 +1,6 @@
// Copied here from the unreleased master branch of github.com/firefox-devtools/react-contextmenu
/* eslint @typescript-eslint/ban-types: 0 */
+/* eslint @typescript-eslint/no-explicit-any: 0 */
declare module '@firefox-devtools/react-contextmenu' {
import * as React from 'react'
diff --git a/examples/linearlite/src/types/issue.ts b/examples/linearlite/src/types/issue.ts
index 6d071d71..80534792 100644
--- a/examples/linearlite/src/types/issue.ts
+++ b/examples/linearlite/src/types/issue.ts
@@ -1,16 +1,16 @@
import type React from 'react'
-import { ReactComponent as CancelIcon } from '../assets/icons/cancel.svg'
-import { ReactComponent as BacklogIcon } from '../assets/icons/circle-dot.svg'
-import { ReactComponent as TodoIcon } from '../assets/icons/circle.svg'
-import { ReactComponent as DoneIcon } from '../assets/icons/done.svg'
-import { ReactComponent as InProgressIcon } from '../assets/icons/half-circle.svg'
+import CancelIcon from '../assets/icons/cancel.svg?react'
+import BacklogIcon from '../assets/icons/circle-dot.svg?react'
+import TodoIcon from '../assets/icons/circle.svg?react'
+import DoneIcon from '../assets/icons/done.svg?react'
+import InProgressIcon from '../assets/icons/half-circle.svg?react'
-import { ReactComponent as HighPriorityIcon } from '../assets/icons/signal-strong.svg'
-import { ReactComponent as LowPriorityIcon } from '../assets/icons/signal-weak.svg'
-import { ReactComponent as MediumPriorityIcon } from '../assets/icons/signal-medium.svg'
-import { ReactComponent as NoPriorityIcon } from '../assets/icons/dots.svg'
-import { ReactComponent as UrgentPriorityIcon } from '../assets/icons/rounded-claim.svg'
+import HighPriorityIcon from '../assets/icons/signal-strong.svg?react'
+import LowPriorityIcon from '../assets/icons/signal-weak.svg?react'
+import MediumPriorityIcon from '../assets/icons/signal-medium.svg?react'
+import NoPriorityIcon from '../assets/icons/dots.svg?react'
+import UrgentPriorityIcon from '../assets/icons/rounded-claim.svg?react'
export const Priority = {
NONE: 'none',
diff --git a/examples/linearlite/src/utils/runOnce.ts b/examples/linearlite/src/utils/runOnce.ts
new file mode 100644
index 00000000..ce20cd76
--- /dev/null
+++ b/examples/linearlite/src/utils/runOnce.ts
@@ -0,0 +1,21 @@
+const RUN_ONCE_KEY_BASE = '__electric_run_once:'
+
+function runOnce
(key: string, fn: () => void): T | void {
+ if (!localStorage.getItem(RUN_ONCE_KEY_BASE + key)) {
+ const result = fn()
+ localStorage.setItem(RUN_ONCE_KEY_BASE + key, '1')
+ return result
+ }
+}
+
+function clearRuns() {
+ const numKeys = localStorage.length
+ for (let i = 0; i < numKeys; i++) {
+ const key = localStorage.key(i)
+ if (key?.startsWith(RUN_ONCE_KEY_BASE)) {
+ localStorage.removeItem(key)
+ }
+ }
+}
+
+export { runOnce, clearRuns }
diff --git a/examples/linearlite/src/vite-env.d.ts b/examples/linearlite/src/vite-env.d.ts
index 11f02fe2..b1f45c78 100644
--- a/examples/linearlite/src/vite-env.d.ts
+++ b/examples/linearlite/src/vite-env.d.ts
@@ -1 +1,2 @@
///
+///
diff --git a/examples/linearlite/tailwind.config.js b/examples/linearlite/tailwind.config.js
index 04d9eb03..6295ee9a 100644
--- a/examples/linearlite/tailwind.config.js
+++ b/examples/linearlite/tailwind.config.js
@@ -1,3 +1,6 @@
+import formsPlugin from '@tailwindcss/forms'
+import typographyPlugin from '@tailwindcss/typography'
+
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'],
@@ -84,5 +87,5 @@ export default {
borderColor: ['checked'],
},
},
- plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
+ plugins: [formsPlugin, typographyPlugin],
}