Write a POC of estimation session

This commit is contained in:
Pijus Kamandulis 2024-10-06 15:15:36 +03:00
parent 6fc1b81fd9
commit 45039d356f
15 changed files with 714 additions and 57 deletions

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "all",
"singleQuote": true,
"endOfLine": "lf",
"tabWidth": 2,
"semi": true
}

BIN
bun.lockb

Binary file not shown.

View File

@ -1,23 +1,27 @@
import js from '@eslint/js' import js from '@eslint/js';
import globals from 'globals' import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks' import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh' import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist'] }, { ignores: ['dist'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], extends: [
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
parserOptions: { parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"], project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
plugins: { plugins: {
react: react,
'react-hooks': reactHooks, 'react-hooks': reactHooks,
'react-refresh': reactRefresh, 'react-refresh': reactRefresh,
}, },
@ -27,6 +31,9 @@ export default tseslint.config(
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'react/react-in-jsx-scope': 'off',
'no-console': 'warn',
curly: ['error', 'all'],
}, },
}, },
) );

View File

@ -10,6 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-form": "^0.33.0",
"@tanstack/react-router": "^1.62.0",
"appwrite": "^16.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -19,6 +22,7 @@
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0", "globals": "^15.9.0",

View File

@ -1,35 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

5
src/constants.ts Normal file
View File

@ -0,0 +1,5 @@
// TODO: Think about moving to .env
export const APPWRITE_ENDPOINT = 'https://cloud.appwrite.io/v1';
export const APPWRITE_PROJECT_ID = 'scrummie-poker';
export const APPWRITE_DATABASE_ID = 'scrummie-poker-db';
export const APPWRITE_ESTIMATION_SESSION_COLLECTION_ID = 'estimation-session';

11
src/lib/appwrite.ts Normal file
View File

@ -0,0 +1,11 @@
import { Client, Account, Databases } from 'appwrite';
import { APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID } from '../constants';
export const client = new Client();
client.setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT_ID);
export { ID } from 'appwrite';
export const account = new Account(client);
export const databases = new Databases(client);

View File

@ -0,0 +1,257 @@
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
} from 'react';
import { client, databases } from '../appwrite';
import { ID, Models, Query } from 'appwrite';
import {
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
} from '../../constants';
interface EstimationSessionType extends Models.Document {
UserId: string;
Name: string;
Tickets: string[];
SessionState: string;
}
interface EstimationSessionTicket {
Id: string;
Name: string;
}
interface SessionStateType {
CurrentTicketId: string;
VotesRevealed: boolean;
Votes: {
UserId: string;
Estimate: number;
}[];
}
interface EstimationSessionsContextType {
current: EstimationSessionType[];
add: (
estimationSession: Omit<EstimationSessionType, keyof Models.Document>,
) => Promise<void>;
remove: (id: string) => Promise<void>;
addTicket: (
sessionId: string,
ticket: Omit<EstimationSessionTicket, 'Id'>,
) => Promise<void>;
getTickets: (sessionId: string) => EstimationSessionTicket[];
selectTicket: (sessionId: string, ticketId: string) => Promise<void>;
getState: (sessionId: string) => SessionStateType;
voteEstimate: (
sessionId: string,
ticketId: string,
estimate: number,
userId: string,
) => Promise<void>;
revealVotes: (sessionId: string) => Promise<void>;
}
const EstimationSessionsContext = createContext<
EstimationSessionsContextType | undefined
>(undefined);
export function useEstimationSessions() {
return useContext(EstimationSessionsContext);
}
export function EstimationSessionProvider(props: PropsWithChildren) {
const [estimationSessions, setEstimationSessions] = useState<
EstimationSessionType[]
>([]);
const add = async (
estimationSession: Omit<EstimationSessionType, keyof Models.Document>,
) => {
const response = await databases.createDocument<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
ID.unique(),
estimationSession,
);
setEstimationSessions((estimationSessions) =>
[response, ...estimationSessions].slice(0, 10),
);
};
const remove = async (id: string) => {
await databases.deleteDocument(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
id,
);
setEstimationSessions((estimationSessions) =>
estimationSessions.filter(
(estimationSession) => estimationSession.$id !== id,
),
);
await init();
};
const addTicket = async (
sessionId: string,
ticket: Omit<EstimationSessionTicket, 'Id'>,
) => {
const currentSession = estimationSessions.find((x) => x.$id === sessionId);
const response = await databases.updateDocument<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
sessionId,
{
Tickets: currentSession?.Tickets.concat([
JSON.stringify({
...ticket,
Id: crypto.randomUUID(),
}),
]),
},
);
setEstimationSessions((estimationSessions) =>
estimationSessions
.filter((x) => x.$id != sessionId)
.concat([response])
.slice(0, 10),
);
};
const getTickets = (sessionId: string) => {
return (
estimationSessions
.find((x) => x.$id === sessionId)
?.Tickets.map<EstimationSessionTicket>((x) => JSON.parse(x)) ?? []
);
};
const selectTicket = async (sessionId: string, ticketId: string) => {
const response = await databases.updateDocument<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
sessionId,
{
SessionState: JSON.stringify({
CurrentTicketId: ticketId,
}),
},
);
setEstimationSessions((estimationSessions) =>
estimationSessions
.filter((x) => x.$id != sessionId)
.concat([response])
.slice(0, 10),
);
};
const getState = (sessionId: string): SessionStateType => {
return JSON.parse(
estimationSessions.find((x) => x.$id === sessionId)?.SessionState ?? '{}',
);
};
const voteEstimate = async (
sessionId: string,
ticketId: string,
estimate: number,
userId: string,
) => {
const currentState = getState(sessionId);
const newVotes = (currentState.Votes ?? [])
.filter((x) => x.UserId !== userId)
.concat([
{
Estimate: estimate,
UserId: userId,
},
]);
const response = await databases.updateDocument<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
sessionId,
{
SessionState: JSON.stringify({
CurrentTicketId: ticketId,
Votes: newVotes,
}),
},
);
setEstimationSessions((estimationSessions) =>
estimationSessions
.filter((x) => x.$id != sessionId)
.concat([response])
.slice(0, 10),
);
};
const revealVotes = async (sessionId: string) => {
const currentState = getState(sessionId);
const response = await databases.updateDocument<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
sessionId,
{
SessionState: JSON.stringify({
...currentState,
VotesRevealed: true,
}),
},
);
setEstimationSessions((estimationSessions) =>
estimationSessions
.filter((x) => x.$id != sessionId)
.concat([response])
.slice(0, 10),
);
};
const init = async () => {
const response = await databases.listDocuments<EstimationSessionType>(
APPWRITE_DATABASE_ID,
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
[Query.orderDesc('$createdAt'), Query.limit(10)],
);
setEstimationSessions(response.documents);
client.subscribe<EstimationSessionType>(
[
`databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents`,
],
(payload) => {
setEstimationSessions((estimationSessions) =>
estimationSessions
.filter((x) => x.$id != payload.payload.$id)
.concat([payload.payload])
.slice(0, 10),
);
},
);
};
useEffect(() => {
init();
}, []);
return (
<EstimationSessionsContext.Provider
value={{
current: estimationSessions,
add,
remove,
addTicket,
getTickets,
selectTicket,
getState,
voteEstimate,
revealVotes,
}}
>
{props.children}
</EstimationSessionsContext.Provider>
);
}

76
src/lib/context/user.tsx Normal file
View File

@ -0,0 +1,76 @@
import { ID, Models } from 'appwrite';
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
} from 'react';
import { account } from '../appwrite';
interface UserContextType {
current: Models.Session | Models.User<Models.Preferences> | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export const useUser = () => {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
};
export const UserProvider = (props: PropsWithChildren) => {
const [user, setUser] = useState<
Models.Session | Models.User<Models.Preferences> | null
>(null);
const login = async (email: string, password: string) => {
const loggedIn = await account.createEmailPasswordSession(email, password);
setUser(loggedIn);
window.location.replace('/'); // you can use different redirect method for your application
};
const logout = async () => {
await account.deleteSession('current');
setUser(null);
};
const register = async (email: string, password: string) => {
await account.create(ID.unique(), email, password);
await login(email, password);
};
const loginAsGuest = async () => {
const session = await account.createAnonymousSession();
setUser(session);
window.location.replace('/'); // you can use different redirect method for your application
};
const init = async () => {
try {
const loggedIn = await account.get();
setUser(loggedIn);
} catch (err) {
setUser(null);
}
};
useEffect(() => {
init();
}, []);
return (
<UserContext.Provider
value={{ current: user, login, logout, register, loginAsGuest }}
>
{props.children}
</UserContext.Provider>
);
};

View File

@ -1,10 +1,67 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import App from './App.tsx' import './index.css';
import './index.css' import {
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router';
import Home from './pages/Home';
import { UserProvider } from './lib/context/user';
import Login from './pages/Login';
import EstimationSession from './pages/EstimationSession';
import CreateEstimationSession from './pages/CreateEstimationSession';
import { EstimationSessionProvider } from './lib/context/estimationSession';
const rootRoute = createRootRoute();
const indexRoute = createRoute({
path: '/',
component: Home,
getParentRoute: () => rootRoute,
});
const loginRoute = createRoute({
path: 'login',
component: Login,
getParentRoute: () => rootRoute,
});
const createEstimationSessionRoute = createRoute({
path: 'estimate/new',
component: CreateEstimationSession,
getParentRoute: () => rootRoute,
});
const estimationSessionRoute = createRoute({
path: 'estimate/session/$sessionId',
component: EstimationSession,
getParentRoute: () => rootRoute,
});
const router = createRouter({
routeTree: rootRoute.addChildren([
indexRoute,
loginRoute,
createEstimationSessionRoute,
estimationSessionRoute,
]),
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <EstimationSessionProvider>
{/* TODO: Move ctx providers to layout */}
<UserProvider>
<RouterProvider router={router} />
</UserProvider>
</EstimationSessionProvider>
</StrictMode>, </StrictMode>,
) );

View File

@ -0,0 +1,48 @@
import { useForm } from '@tanstack/react-form';
import { useEstimationSessions } from '../lib/context/estimationSession';
import { useUser } from '../lib/context/user';
const CreateEstimationSession = () => {
const user = useUser();
const estimationSessions = useEstimationSessions();
const form = useForm({
defaultValues: {
name: '',
},
onSubmit: async ({ value }) => {
await estimationSessions?.add({
Name: value.name,
UserId: user.current?.$id,
});
},
});
return (
<>
<h1>Create Estimation Session</h1>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="name"
children={(field) => (
<input
placeholder="Name"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<button type="submit">Submit</button>
</form>
</>
);
};
export default CreateEstimationSession;

View File

@ -0,0 +1,110 @@
import { getRouteApi } from '@tanstack/react-router';
import { useEstimationSessions } from '../lib/context/estimationSession';
import { useForm } from '@tanstack/react-form';
import { useUser } from '../lib/context/user';
const route = getRouteApi('/estimate/session/$sessionId');
const EstimationSession = () => {
const { sessionId } = route.useParams();
const user = useUser();
const estimationSessions = useEstimationSessions();
const estimationSession = estimationSessions?.current.find(
(x) => x.$id == sessionId,
);
const tickets = estimationSessions?.getTickets(sessionId);
const currentState = estimationSessions?.getState(sessionId);
const createTicketForm = useForm({
defaultValues: {
name: '',
},
onSubmit: async ({ value }) => {
await estimationSessions?.addTicket(sessionId, {
Name: value.name,
});
},
});
return (
<>
<h1>Estimation Session - {estimationSession?.Name}</h1>
<div>
<h2>Tasks</h2>
{tickets?.map((x) => (
<div key={x.Id}>
{x.Id} - {x.Name}
<button
onClick={() => estimationSessions?.selectTicket(sessionId, x.Id)}
>
Select
</button>
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
createTicketForm.handleSubmit();
}}
>
<createTicketForm.Field
name="name"
children={(field) => (
<input
placeholder="Name"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<button type="submit">Submit</button>
</form>
</div>
{currentState?.CurrentTicketId && (
<div>
<h2>
{currentState.CurrentTicketId} -{' '}
{tickets?.find((x) => x.Id === currentState.CurrentTicketId)?.Name}
</h2>
{[0.5, 1, 2, 3, 5, 8, 13, 21].map((estimate) => (
<button
key={estimate}
onClick={() =>
estimationSessions?.voteEstimate(
sessionId,
currentState.CurrentTicketId,
estimate,
user.current?.$id ?? '',
)
}
>
{estimate}
</button>
))}
{currentState.VotesRevealed ? (
<>
<h3>Votes</h3>
<ul>
{currentState.Votes.map((vote) => (
<li key={vote.UserId}>
{vote.UserId} - {vote.Estimate}
</li>
))}
</ul>
</>
) : (
<button onClick={() => estimationSessions?.revealVotes(sessionId)}>
Reveal Votes
</button>
)}
</div>
)}
<pre>Session Id: {sessionId}</pre>
</>
);
};
export default EstimationSession;

View File

@ -32,11 +32,3 @@
animation: logo-spin infinite 20s linear; animation: logo-spin infinite 20s linear;
} }
} }
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

46
src/pages/Home.tsx Normal file
View File

@ -0,0 +1,46 @@
import reactLogo from '../assets/react.svg';
import viteLogo from '/vite.svg';
import './Home.css';
import { Link } from '@tanstack/react-router';
import { useUser } from '../lib/context/user';
import { useEstimationSessions } from '../lib/context/estimationSession';
function Home() {
const user = useUser();
const estimationSessions = useEstimationSessions();
return (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Scrummie-Poker</h1>
<ul>
<li>
<Link to="/login">Login</Link>
</li>
<li>
<Link to="/estimate/new">Create Estimation Session</Link>
</li>
</ul>
<pre>User Id: {user.current?.$id}</pre>
<div>
<p>Estimation sessions</p>
{estimationSessions?.current.map((session) => (
<Link key={session.$id} to={`/estimate/session/${session.$id}`}>
{session.Name}
</Link>
))}
</div>
</>
);
}
export default Home;

72
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,72 @@
import { useForm } from '@tanstack/react-form';
import { useUser } from '../lib/context/user';
const Login = () => {
const user = useUser();
const form = useForm({
defaultValues: {
email: '',
password: '',
},
onSubmit: async ({ value }) => {
console.log({ value });
},
});
return (
<>
<h1>Login or register</h1>
<form>
<form.Field
name="email"
children={(field) => (
<input
type="email"
placeholder="Email"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<form.Field
name="password"
children={(field) => (
<input
type="password"
placeholder="Password"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<div>
<button
className="button"
type="button"
onClick={() =>
user.login(form.state.values.email, form.state.values.password)
}
>
Login
</button>
<button
className="button"
type="button"
onClick={() =>
user.register(form.state.values.email, form.state.values.password)
}
>
Register
</button>
</div>
</form>
<button onClick={() => user.loginAsGuest()}>Login as guest</button>
</>
);
};
export default Login;