Skip to content

Commit

Permalink
feat: SolidJS port
Browse files Browse the repository at this point in the history
This is a basic SolidJS port

It does not yet figure out how to make Replicache ❤️ SolidJS. This is
just a baseline to start from.
  • Loading branch information
arv committed Feb 29, 2024
1 parent e0bf03a commit b50f566
Show file tree
Hide file tree
Showing 20 changed files with 3,382 additions and 1,445 deletions.
2 changes: 1 addition & 1 deletion client/.eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ bin
dist
lib
env.d.ts
vite.config.ts
vite.config.ts
31 changes: 14 additions & 17 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,26 @@
"watch": "concurrently --kill-others 'npm run server' 'npm run check-types -- --watch --preserveWatchOutput' 'sleep 3; npm run dev'"
},
"dependencies": {
"classnames": "^2.3.1",
"navigo": "^8.11.1",
"qs": "^6.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"replicache-react": "5.0.1",
"@solidjs/router": "^0.12.4",
"shared": "^0.1.0",
"solid-js": "^1.8.15",
"todomvc-app-css": "^2.4.2"
},
"devDependencies": {
"@rocicorp/eslint-config": "^0.1.2",
"@rocicorp/prettier-config": "^0.1.1",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"concurrently": "^7.4.0",
"prettier": "^2.2.1",
"typescript": "^4.7.4",
"use-debounce": "^9.0.4",
"vite": "^3.0.7"
"@rocicorp/eslint-config": "^0.5.1",
"@rocicorp/prettier-config": "^0.2.0",
"concurrently": "^8.2.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-solid": "^2.10.1"
},
"eslintConfig": {
"extends": "@rocicorp/eslint-config"
"extends": "@rocicorp/eslint-config",
"rules": {
"@typescript-eslint/naming-convention": [
"off"
]
}
},
"prettier": "@rocicorp/prettier-config"
}
211 changes: 128 additions & 83 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,142 +1,145 @@
import {Dialog} from '@headlessui/react';
import {A, useNavigate, useParams} from '@solidjs/router';
import {nanoid} from 'nanoid';
import Navigo from 'navigo';
import {useEffect, useState} from 'react';
import {ReadTransaction, Replicache} from 'replicache';
import {useSubscribe} from 'replicache-react';
import {TodoUpdate, todosByList} from 'shared';
import {getList, listLists} from 'shared/src/list';
import Header from './components/header';
import MainSection from './components/main-section';
import {Share} from './components/share';
import {Replicache} from 'replicache';
import {List, Todo, TodoUpdate, getList, listLists, todosByList} from 'shared';
import {
Component,
For,
JSX,
createEffect,
createSignal,
onCleanup,
} from 'solid-js';
import Header from './components/header.jsx';
import MainSection from './components/main-section.jsx';
import Share from './components/share.jsx';
import {createEffectAccessor} from './create-effect-accessor.js';
import {M} from './mutators';

// This is the top-level component for our app.
const App = ({
rep,
userID,
onUserIDChange,
}: {
const App = (props: {
rep: Replicache<M>;
userID: string;
onUserIDChange: (userID: string) => void;
}) => {
const router = new Navigo('/');
const [listID, setListID] = useState('');
const [showingShare, setShowingShare] = useState(false);

router.on('/list/:listID', match => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {data} = match!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {listID} = data!;
setListID(listID);
});

useEffect(() => {
router.resolve();
}, []);
const [showingShare, setShowingShare] = createSignal(false);

// Listen for pokes related to just this list.
useEventSourcePoke(`/api/replicache/poke?channel=list/${listID}`, rep);
// Listen for pokes related to the docs this user has access to.
useEventSourcePoke(`/api/replicache/poke?channel=user/${userID}`, rep);
const params = useParams();

const lists = useSubscribe(rep, listLists, {default: []});
lists.sort((a, b) => a.name.localeCompare(b.name));
createEffect(() => {
const {listID} = params;
if (!listID) {
return;
}
// Listen for pokes related to just this list.
useEventSourcePoke(
`/api/replicache/poke?channel=list/${listID}`,
props.rep,
);
// Listen for pokes related to the docs this user has access to.
useEventSourcePoke(
`/api/replicache/poke?channel=user/${props.userID}`,
props.rep,
);
});

const selectedList = useSubscribe(
rep,
(tx: ReadTransaction) => getList(tx, listID),
{dependencies: [listID]},
const lists = createEffectAccessor<List[]>(
set =>
onCleanup(
props.rep.subscribe(async tx => {
const arr = await listLists(tx);
return arr.sort((a, b) => a.name.localeCompare(b.name));
}, set),
),
[],
);

const selectedList = createEffectAccessor<List | undefined>(async set => {

This comment has been minimized.

Copy link
@aboodman

aboodman Mar 4, 2024

The old code was subscribing to this, not getting it just once.

const {listID} = params;
set(await props.rep.query(tx => getList(tx, listID)));
}, undefined);

// Subscribe to all todos and sort them.
const todos = useSubscribe(rep, async tx => todosByList(tx, listID), {
default: [],
dependencies: [listID],
});
todos.sort((a, b) => a.sort - b.sort);
const todos = createEffectAccessor<Todo[]>(set => {
const {rep} = props;
const {listID} = params;
onCleanup(
rep.subscribe(async tx => {
const todos = await todosByList(tx, listID);
return todos.sort((a, b) => a.sort - b.sort);
}, set),
);
}, []);

// Define event handlers and connect them to Replicache mutators. Each
// of these mutators runs immediately (optimistically) locally, then runs
// again on the server-side automatically.
const handleNewItem = (text: string) => {
void rep.mutate.createTodo({
void props.rep.mutate.createTodo({
id: nanoid(),
listID,
listID: params.listID,
text,
completed: false,
});
};

const handleUpdateTodo = (update: TodoUpdate) =>
rep.mutate.updateTodo(update);
props.rep.mutate.updateTodo(update);

const handleDeleteTodos = async (ids: string[]) => {
for (const id of ids) {
await rep.mutate.deleteTodo(id);
await props.rep.mutate.deleteTodo(id);
}
};

const handleCompleteTodos = async (completed: boolean, ids: string[]) => {
for (const id of ids) {
await rep.mutate.updateTodo({
await props.rep.mutate.updateTodo({
id,
completed,
});
}
};

const navigate = useNavigate();

const handleNewList = async (name: string) => {
const {userID} = props;
const id = nanoid();
await rep.mutate.createList({
await props.rep.mutate.createList({
id,
ownerID: userID,
name,
});
router.navigate(`/list/${id}`);
navigate(`/list/${id}`);
};

const handleDeleteList = async () => {
await rep.mutate.deleteList(listID);
await props.rep.mutate.deleteList(params.listID);
navigate('/');
};

// Render app.

return (
<div id="layout">
<div id="nav">
{lists.map(list => {
const path = `/list/${list.id}`;
return (
<a
key={list.id}
href={path}
onClick={e => {
router.navigate(path);
e.preventDefault();
return false;
}}
>
{list.name}
</a>
);
})}
<For each={lists()} fallback={<div>No lists</div>}>
{list => <A href={`/list/${list.id}`}>{list.name}</A>}
</For>
</div>
<div className="todoapp">
<div class="todoapp">
<Header
listName={selectedList?.name}
userID={userID}
listName={selectedList()?.name}
userID={props.userID}
onNewItem={handleNewItem}
onNewList={handleNewList}
onDeleteList={handleDeleteList}
onUserIDChange={onUserIDChange}
onShare={() => setShowingShare(!showingShare)}
onUserIDChange={props.onUserIDChange}
onShare={() => setShowingShare(!showingShare())}
/>
{selectedList ? (
{selectedList() ? (
<MainSection
todos={todos}
todos={todos()}
onUpdateTodo={handleUpdateTodo}
onDeleteTodos={handleDeleteTodos}
onCompleteTodos={handleCompleteTodos}
Expand All @@ -145,22 +148,64 @@ const App = ({
<div id="no-list-selected">No list selected</div>
)}
</div>
<div className="spacer" />
<Dialog open={showingShare} onClose={() => setShowingShare(false)}>
<Share rep={rep} listID={listID} />
<div class="spacer" />
<Dialog
open={showingShare()}
onClose={() => setShowingShare(false)}
class="share-dialog"
>
<Share rep={props.rep} listID={params.listID} />
</Dialog>
</div>
);
};

function useEventSourcePoke(url: string, rep: Replicache<M>) {
useEffect(() => {
createEffect(() => {
const ev = new EventSource(url);
ev.onmessage = () => {
void rep.pull();
};
return () => ev.close();
}, [url, rep]);
onCleanup(() => ev.close());
});
}

export default App;

const Dialog: Component<{
open: boolean;
children: JSX.Element;
onClose: () => void;
class?: string;
}> = props => {
let el!: HTMLDialogElement;
const onClick = (e: MouseEvent) => {
const dialogDimensions = el.getBoundingClientRect();
if (
e.clientX < dialogDimensions.left ||
e.clientX > dialogDimensions.right ||
e.clientY < dialogDimensions.top ||
e.clientY > dialogDimensions.bottom
) {
el.close();
}
};

const onClose = () => {
props.onClose();
};

createEffect(() => {
if (props.open) {
el.showModal();
} else {
el.close();
}
});

return (
<dialog ref={el} onClick={onClick} onClose={onClose} class={props.class}>
{props.children}
</dialog>
);
};
Loading

0 comments on commit b50f566

Please sign in to comment.