Style estimation screen
This commit is contained in:
parent
cbde13314b
commit
253d13abd4
|
@ -9,15 +9,17 @@ enum ButtonColor {
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
color?: ButtonColor;
|
color?: ButtonColor;
|
||||||
|
fullWidth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
const Button: React.FC<ButtonProps> = ({
|
||||||
children,
|
children,
|
||||||
color = ButtonColor.Primary,
|
color = ButtonColor.Primary,
|
||||||
|
fullWidth = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const buttonClass = classNames(
|
const buttonClass = classNames(
|
||||||
'flex w-full justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
'flex justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||||
{
|
{
|
||||||
'bg-indigo-600 hover:bg-indigo-500 focus-visible:outline-indigo-600':
|
'bg-indigo-600 hover:bg-indigo-500 focus-visible:outline-indigo-600':
|
||||||
color === ButtonColor.Primary,
|
color === ButtonColor.Primary,
|
||||||
|
@ -27,6 +29,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||||
color === ButtonColor.Error,
|
color === ButtonColor.Error,
|
||||||
'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600':
|
'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600':
|
||||||
color === ButtonColor.Success,
|
color === ButtonColor.Success,
|
||||||
|
'w-full': fullWidth,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ const Card: React.FC<CardProps> = ({ title, description, onClick }) => {
|
||||||
'p-4 border rounded-lg shadow-sm transition',
|
'p-4 border rounded-lg shadow-sm transition',
|
||||||
{
|
{
|
||||||
'hover:bg-gray-100 dark:hover:bg-nero-800 cursor-pointer': onClick,
|
'hover:bg-gray-100 dark:hover:bg-nero-800 cursor-pointer': onClick,
|
||||||
'cursor-default': !onClick,
|
|
||||||
},
|
},
|
||||||
'border-gray-300 dark:border-nero-700',
|
'border-gray-300 dark:border-nero-700',
|
||||||
'bg-white dark:bg-nero-900',
|
'bg-white dark:bg-nero-900',
|
||||||
|
|
|
@ -32,7 +32,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, children }) => {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'dark:bg-nero-900 relative h-full w-80 transform space-y-6 bg-white p-6 shadow-lg transition-transform',
|
'relative h-full w-80 transform space-y-6 bg-white p-6 shadow-lg transition-transform dark:bg-nero-900',
|
||||||
{
|
{
|
||||||
'translate-x-0': isOpen,
|
'translate-x-0': isOpen,
|
||||||
'translate-x-full': !isOpen,
|
'translate-x-full': !isOpen,
|
||||||
|
@ -41,7 +41,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, children }) => {
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Button onClick={onClose} color={ButtonColor.Secondary}>
|
<Button onClick={onClose} color={ButtonColor.Secondary} fullWidth>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
interface GridListProps<T> {
|
interface GridListProps<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
colNum: number;
|
colNum: number;
|
||||||
onAddItem: () => void;
|
onAddItem?: () => void;
|
||||||
itemComponent: React.ComponentType<{ item: T }>;
|
itemComponent: React.ComponentType<{ item: T }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,8 @@ const CreateEstimationSessionForm: React.FC<
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
await estimationSessions?.add({
|
await estimationSessions?.add({
|
||||||
Name: value.name,
|
name: value.name,
|
||||||
UserId: user.current?.$id,
|
userId: user.current?.$id,
|
||||||
});
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
},
|
},
|
||||||
|
@ -51,7 +51,9 @@ const CreateEstimationSessionForm: React.FC<
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit" fullWidth>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// TODO: Think about moving to .env
|
// TODO: Think about moving to .env
|
||||||
export const APPWRITE_ENDPOINT = 'https://cloud.appwrite.io/v1';
|
export const APPWRITE_ENDPOINT = 'https://cloud.appwrite.io/v1';
|
||||||
export const APPWRITE_PROJECT_ID = 'scrummie-poker';
|
export const APPWRITE_PROJECT_ID = 'scrummie-poker';
|
||||||
export const APPWRITE_DATABASE_ID = 'scrummie-poker-db';
|
export const APPWRITE_DATABASE_ID = '670402eb000f5aff721f';
|
||||||
export const APPWRITE_ESTIMATION_SESSION_COLLECTION_ID = 'estimation-session';
|
export const APPWRITE_ESTIMATION_SESSION_COLLECTION_ID = '670402f60023cb78d441';
|
||||||
|
|
|
@ -0,0 +1,185 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { client, databases } from '../appwrite';
|
||||||
|
import { DatabaseModels, EntityModels } from '../types';
|
||||||
|
import {
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
|
} from '../../constants';
|
||||||
|
import { useUser } from './user';
|
||||||
|
import { EstimationSessionTicket } from '../types/entityModels';
|
||||||
|
|
||||||
|
interface EstimationContextType {
|
||||||
|
setSessionId: (sessionId: string) => void;
|
||||||
|
setActiveTicket: (ticketId: string) => Promise<void>;
|
||||||
|
setRevealed: (revealed: boolean) => Promise<void>;
|
||||||
|
setVote: (estimate: string) => Promise<void>;
|
||||||
|
createTicket: (ticket: Omit<EstimationSessionTicket, 'id'>) => Promise<void>;
|
||||||
|
currentSessionData?: EntityModels.EstimationSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EstimationContext = createContext<EstimationContextType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useEstimationContext = () => {
|
||||||
|
return useContext(EstimationContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapEstimationSession = (
|
||||||
|
data: DatabaseModels.EstimationSession,
|
||||||
|
{ userId }: { userId?: string },
|
||||||
|
) => {
|
||||||
|
const sessionState = JSON.parse(
|
||||||
|
data.sessionState,
|
||||||
|
) as EntityModels.SessionState;
|
||||||
|
|
||||||
|
const tickets = data.tickets.map<EntityModels.EstimationSessionTicket>(
|
||||||
|
(ticket) => JSON.parse(ticket),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: EntityModels.EstimationSession = {
|
||||||
|
id: data.$id,
|
||||||
|
name: data.name,
|
||||||
|
userId: data.userId,
|
||||||
|
tickets,
|
||||||
|
sessionState: {
|
||||||
|
...sessionState,
|
||||||
|
currentPlayerVote: sessionState.votes.find((x) => x.userId === userId)
|
||||||
|
?.estimate,
|
||||||
|
currentTicket: tickets.find((x) => x.id === sessionState.currentTicketId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
result,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EstimationContextProvider = (props: PropsWithChildren) => {
|
||||||
|
const [sessionId, setSessionId] = useState('');
|
||||||
|
const [currentSessionData, setCurrentSessionData] =
|
||||||
|
useState<EntityModels.EstimationSession>();
|
||||||
|
|
||||||
|
const { current: userData } = useUser();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId || !userData) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
databases
|
||||||
|
.getDocument<DatabaseModels.EstimationSession>(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
|
sessionId,
|
||||||
|
)
|
||||||
|
.then((payload) => {
|
||||||
|
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
|
||||||
|
setCurrentSessionData(mapEstimationSession(payload, { userId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.subscribe<DatabaseModels.EstimationSession>(
|
||||||
|
[
|
||||||
|
`databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents.${sessionId}`,
|
||||||
|
],
|
||||||
|
({ payload }) => {
|
||||||
|
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
|
||||||
|
setCurrentSessionData(mapEstimationSession(payload, { userId }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [sessionId, userData]);
|
||||||
|
|
||||||
|
const setSessionIdFunc = (newSessionId: string) => {
|
||||||
|
if (sessionId !== newSessionId) {
|
||||||
|
setSessionId(newSessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSessionState = async (
|
||||||
|
data: Partial<EntityModels.SessionState>,
|
||||||
|
) => {
|
||||||
|
await databases.updateDocument<DatabaseModels.EstimationSession>(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
|
sessionState: JSON.stringify({
|
||||||
|
...currentSessionData?.sessionState,
|
||||||
|
...data,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveTicket = async (ticketId: string) => {
|
||||||
|
await updateSessionState({
|
||||||
|
currentTicketId: ticketId,
|
||||||
|
votes: [],
|
||||||
|
votesRevealed: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVote = async (estimate: string) => {
|
||||||
|
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
|
||||||
|
await updateSessionState({
|
||||||
|
votes: currentSessionData?.sessionState.votes
|
||||||
|
.filter((x) => x.userId !== userId)
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
estimate: estimate,
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRevealed = async (revealed: boolean) => {
|
||||||
|
await updateSessionState({
|
||||||
|
votesRevealed: revealed,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTicket = async (ticket: Omit<EstimationSessionTicket, 'id'>) => {
|
||||||
|
const newTicketsValue = currentSessionData?.tickets
|
||||||
|
.concat([
|
||||||
|
{
|
||||||
|
...ticket,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.map((x) => JSON.stringify(x));
|
||||||
|
|
||||||
|
await databases.updateDocument<DatabaseModels.EstimationSession>(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
|
tickets: newTicketsValue,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EstimationContext.Provider
|
||||||
|
value={{
|
||||||
|
setSessionId: setSessionIdFunc,
|
||||||
|
setActiveTicket,
|
||||||
|
setRevealed,
|
||||||
|
setVote,
|
||||||
|
createTicket,
|
||||||
|
currentSessionData,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</EstimationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -13,23 +13,23 @@ import {
|
||||||
} from '../../constants';
|
} from '../../constants';
|
||||||
|
|
||||||
interface EstimationSessionType extends Models.Document {
|
interface EstimationSessionType extends Models.Document {
|
||||||
UserId: string;
|
userId: string;
|
||||||
Name: string;
|
name: string;
|
||||||
Tickets: string[];
|
tickets: string[];
|
||||||
SessionState: string;
|
sessionState: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EstimationSessionTicket {
|
interface EstimationSessionTicket {
|
||||||
Id: string;
|
id: string;
|
||||||
Name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionStateType {
|
interface SessionStateType {
|
||||||
CurrentTicketId: string;
|
currentTicketId: string;
|
||||||
VotesRevealed: boolean;
|
votesRevealed: boolean;
|
||||||
Votes: {
|
votes: {
|
||||||
UserId: string;
|
userId: string;
|
||||||
Estimate: number;
|
estimate: number;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ interface EstimationSessionsContextType {
|
||||||
remove: (id: string) => Promise<void>;
|
remove: (id: string) => Promise<void>;
|
||||||
addTicket: (
|
addTicket: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
ticket: Omit<EstimationSessionTicket, 'Id'>,
|
ticket: Omit<EstimationSessionTicket, 'id'>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
getTickets: (sessionId: string) => EstimationSessionTicket[];
|
getTickets: (sessionId: string) => EstimationSessionTicket[];
|
||||||
selectTicket: (sessionId: string, ticketId: string) => Promise<void>;
|
selectTicket: (sessionId: string, ticketId: string) => Promise<void>;
|
||||||
|
@ -98,7 +98,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
|
|
||||||
const addTicket = async (
|
const addTicket = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
ticket: Omit<EstimationSessionTicket, 'Id'>,
|
ticket: Omit<EstimationSessionTicket, 'id'>,
|
||||||
) => {
|
) => {
|
||||||
const currentSession = estimationSessions.find((x) => x.$id === sessionId);
|
const currentSession = estimationSessions.find((x) => x.$id === sessionId);
|
||||||
const response = await databases.updateDocument<EstimationSessionType>(
|
const response = await databases.updateDocument<EstimationSessionType>(
|
||||||
|
@ -106,10 +106,10 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
sessionId,
|
sessionId,
|
||||||
{
|
{
|
||||||
Tickets: currentSession?.Tickets.concat([
|
tickets: currentSession?.tickets.concat([
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...ticket,
|
...ticket,
|
||||||
Id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
@ -126,7 +126,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
estimationSessions
|
estimationSessions
|
||||||
.find((x) => x.$id === sessionId)
|
.find((x) => x.$id === sessionId)
|
||||||
?.Tickets.map<EstimationSessionTicket>((x) => JSON.parse(x)) ?? []
|
?.tickets.map<EstimationSessionTicket>((x) => JSON.parse(x)) ?? []
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -136,8 +136,8 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
sessionId,
|
sessionId,
|
||||||
{
|
{
|
||||||
SessionState: JSON.stringify({
|
sessionState: JSON.stringify({
|
||||||
CurrentTicketId: ticketId,
|
currentTicketId: ticketId,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -151,7 +151,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
|
|
||||||
const getState = (sessionId: string): SessionStateType => {
|
const getState = (sessionId: string): SessionStateType => {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
estimationSessions.find((x) => x.$id === sessionId)?.SessionState ?? '{}',
|
estimationSessions.find((x) => x.$id === sessionId)?.sessionState ?? '{}',
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -162,12 +162,12 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
userId: string,
|
userId: string,
|
||||||
) => {
|
) => {
|
||||||
const currentState = getState(sessionId);
|
const currentState = getState(sessionId);
|
||||||
const newVotes = (currentState.Votes ?? [])
|
const newVotes = (currentState.votes ?? [])
|
||||||
.filter((x) => x.UserId !== userId)
|
.filter((x) => x.userId !== userId)
|
||||||
.concat([
|
.concat([
|
||||||
{
|
{
|
||||||
Estimate: estimate,
|
estimate: estimate,
|
||||||
UserId: userId,
|
userId: userId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const response = await databases.updateDocument<EstimationSessionType>(
|
const response = await databases.updateDocument<EstimationSessionType>(
|
||||||
|
@ -175,9 +175,9 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
sessionId,
|
sessionId,
|
||||||
{
|
{
|
||||||
SessionState: JSON.stringify({
|
sessionState: JSON.stringify({
|
||||||
CurrentTicketId: ticketId,
|
currentTicketId: ticketId,
|
||||||
Votes: newVotes,
|
votes: newVotes,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -196,7 +196,7 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
sessionId,
|
sessionId,
|
||||||
{
|
{
|
||||||
SessionState: JSON.stringify({
|
sessionState: JSON.stringify({
|
||||||
...currentState,
|
...currentState,
|
||||||
VotesRevealed: true,
|
VotesRevealed: true,
|
||||||
}),
|
}),
|
||||||
|
@ -217,8 +217,12 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
[Query.orderDesc('$createdAt'), Query.limit(10)],
|
[Query.orderDesc('$createdAt'), Query.limit(10)],
|
||||||
);
|
);
|
||||||
setEstimationSessions(response.documents);
|
setEstimationSessions(response.documents);
|
||||||
|
};
|
||||||
|
|
||||||
client.subscribe<EstimationSessionType>(
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
|
||||||
|
return client.subscribe<EstimationSessionType>(
|
||||||
[
|
[
|
||||||
`databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents`,
|
`databases.${APPWRITE_DATABASE_ID}.collections.${APPWRITE_ESTIMATION_SESSION_COLLECTION_ID}.documents`,
|
||||||
],
|
],
|
||||||
|
@ -231,10 +235,6 @@ export function EstimationSessionProvider(props: PropsWithChildren) {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
init();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Models } from 'appwrite';
|
||||||
|
|
||||||
|
interface EstimationSession extends Models.Document {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
tickets: string[];
|
||||||
|
sessionState: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { EstimationSession };
|
|
@ -0,0 +1,32 @@
|
||||||
|
interface EstimationSession {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
tickets: EstimationSessionTicket[];
|
||||||
|
sessionState: SessionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EstimationSessionTicket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
currentTicketId?: string;
|
||||||
|
votesRevealed: boolean;
|
||||||
|
votes: PlayerVote[];
|
||||||
|
currentPlayerVote?: string;
|
||||||
|
currentTicket?: EstimationSessionTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerVote {
|
||||||
|
userId: string;
|
||||||
|
estimate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
EstimationSession,
|
||||||
|
EstimationSessionTicket,
|
||||||
|
SessionState,
|
||||||
|
PlayerVote,
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * as DatabaseModels from './databaseModels';
|
||||||
|
export * as EntityModels from './entityModels';
|
13
src/main.tsx
13
src/main.tsx
|
@ -10,8 +10,9 @@ import {
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import { UserProvider } from './lib/context/user';
|
import { UserProvider } from './lib/context/user';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import EstimationSession from './pages/EstimationSession';
|
|
||||||
import { EstimationSessionProvider } from './lib/context/estimationSession';
|
import { EstimationSessionProvider } from './lib/context/estimationSession';
|
||||||
|
import { EstimationContextProvider } from './lib/context/estimation';
|
||||||
|
import Estimation from './pages/Estimation/Estimation';
|
||||||
|
|
||||||
const rootRoute = createRootRoute();
|
const rootRoute = createRootRoute();
|
||||||
|
|
||||||
|
@ -29,7 +30,7 @@ const loginRoute = createRoute({
|
||||||
|
|
||||||
const estimationSessionRoute = createRoute({
|
const estimationSessionRoute = createRoute({
|
||||||
path: 'estimate/session/$sessionId',
|
path: 'estimate/session/$sessionId',
|
||||||
component: EstimationSession,
|
component: Estimation,
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -49,11 +50,13 @@ declare module '@tanstack/react-router' {
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<EstimationSessionProvider>
|
|
||||||
{/* TODO: Move ctx providers to layout */}
|
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
|
<EstimationSessionProvider>
|
||||||
|
<EstimationContextProvider>
|
||||||
|
{/* TODO: Move ctx providers to layout */}
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</UserProvider>
|
</EstimationContextProvider>
|
||||||
</EstimationSessionProvider>
|
</EstimationSessionProvider>
|
||||||
|
</UserProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useEstimationContext } from '../../lib/context/estimation';
|
||||||
|
import { getRouteApi } from '@tanstack/react-router';
|
||||||
|
import TaskSidebar from './components/TaskSidebar';
|
||||||
|
import VoteSelection from './components/VoteSelection';
|
||||||
|
import VoteList from './components/VoteList';
|
||||||
|
import { Button, ButtonColor, Drawer } from '../../components';
|
||||||
|
import CreateTicketForm from './components/CreateTicketForm';
|
||||||
|
|
||||||
|
const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100];
|
||||||
|
|
||||||
|
const route = getRouteApi('/estimate/session/$sessionId');
|
||||||
|
|
||||||
|
const Estimation: React.FC = () => {
|
||||||
|
const { sessionId } = route.useParams();
|
||||||
|
const estimationState = useEstimationContext();
|
||||||
|
useEffect(() => estimationState?.setSessionId(sessionId), [sessionId]);
|
||||||
|
const [isDrawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!estimationState?.currentSessionData) {
|
||||||
|
return null; // TODO: Add a loader
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
setActiveTicket,
|
||||||
|
setVote,
|
||||||
|
setRevealed,
|
||||||
|
createTicket,
|
||||||
|
currentSessionData: {
|
||||||
|
tickets: tickets,
|
||||||
|
sessionState: {
|
||||||
|
votesRevealed: revealed,
|
||||||
|
votes: votes,
|
||||||
|
currentPlayerVote,
|
||||||
|
currentTicket,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} = estimationState;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<TaskSidebar
|
||||||
|
className="dark:bg-customGray-800 w-64 overflow-y-scroll bg-gray-50 p-4 dark:bg-nero-700"
|
||||||
|
tickets={tickets}
|
||||||
|
onSelectTicket={(ticket) => setActiveTicket(ticket.id)}
|
||||||
|
onAddTicket={() => setDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-grow flex-col p-6">
|
||||||
|
{currentTicket ? (
|
||||||
|
<>
|
||||||
|
<h1 className="mb-4 text-2xl font-bold">{currentTicket.name}</h1>
|
||||||
|
<p className="mb-8 text-gray-700 dark:text-gray-200">
|
||||||
|
{currentTicket.id}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<VoteSelection
|
||||||
|
className="mb-4 mt-auto flex flex-wrap gap-1 space-x-4"
|
||||||
|
onSelect={(vote) => setVote(vote)}
|
||||||
|
options={fibonacciSequence.map((x) => `${x}`)}
|
||||||
|
value={currentPlayerVote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VoteList className="mt-6" revealed={revealed} votes={votes} />
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
color={ButtonColor.Error}
|
||||||
|
onClick={() => setRevealed(true)}
|
||||||
|
>
|
||||||
|
Reveal Votes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Select a task to see the details and estimate.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer isOpen={isDrawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
<CreateTicketForm
|
||||||
|
onCreate={async (ticket) => {
|
||||||
|
await createTicket(ticket);
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Estimation;
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { Button, Input } from '../../../components';
|
||||||
|
import { EstimationSessionTicket } from '../../../lib/types/entityModels';
|
||||||
|
|
||||||
|
interface CreateTicketFormProps {
|
||||||
|
onCreate: (ticket: Omit<EstimationSessionTicket, 'id'>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateTicketForm: React.FC<CreateTicketFormProps> = ({ onCreate }) => {
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await onCreate(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-xl font-bold">Create a New Ticket</h2>
|
||||||
|
<form
|
||||||
|
className="space-y-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form.Field
|
||||||
|
name="name"
|
||||||
|
children={(field) => (
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateTicketForm;
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Card, GridList } from '../../../components';
|
||||||
|
import { EstimationSessionTicket } from '../../../lib/types/entityModels';
|
||||||
|
|
||||||
|
interface TaskSidebarProps {
|
||||||
|
className?: string;
|
||||||
|
tickets: EstimationSessionTicket[];
|
||||||
|
onSelectTicket: (ticket: EstimationSessionTicket) => void;
|
||||||
|
onAddTicket: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskSidebar: React.FC<TaskSidebarProps> = ({
|
||||||
|
className,
|
||||||
|
tickets,
|
||||||
|
onSelectTicket,
|
||||||
|
onAddTicket,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<GridList
|
||||||
|
items={tickets}
|
||||||
|
colNum={1}
|
||||||
|
itemComponent={({ item }) => (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
title={item.name}
|
||||||
|
description={item.id}
|
||||||
|
onClick={() => onSelectTicket(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
onAddItem={onAddTicket}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskSidebar;
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Card, GridList } from '../../../components';
|
||||||
|
import { PlayerVote } from '../../../lib/types/entityModels';
|
||||||
|
|
||||||
|
interface VoteListProps {
|
||||||
|
className?: string;
|
||||||
|
votes: PlayerVote[];
|
||||||
|
revealed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoteList: React.FC<VoteListProps> = ({ className, votes, revealed }) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h2 className="mb-4 text-xl font-bold">Player Votes</h2>
|
||||||
|
<GridList
|
||||||
|
colNum={5}
|
||||||
|
itemComponent={({ item }, idx) => (
|
||||||
|
<Card
|
||||||
|
key={idx}
|
||||||
|
title={item.userId}
|
||||||
|
description={revealed ? item.estimate : 'Hidden'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
items={votes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoteList;
|
|
@ -0,0 +1,37 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
interface VoteSelectionProps {
|
||||||
|
className: string;
|
||||||
|
value?: string;
|
||||||
|
options: string[];
|
||||||
|
onSelect: (vote: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoteSelection: React.FC<VoteSelectionProps> = ({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const getItemClassName = (option: string) =>
|
||||||
|
classNames('rounded-md px-4 py-2 text-white transition-colors', {
|
||||||
|
'bg-indigo-800': value !== option,
|
||||||
|
'bg-indigo-600 hover:bg-indigo-500': value === option,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
className={getItemClassName(option)}
|
||||||
|
onClick={() => onSelect(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoteSelection;
|
|
@ -1,110 +0,0 @@
|
||||||
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;
|
|
|
@ -1,10 +1,3 @@
|
||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 6em;
|
height: 6em;
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
|
|
|
@ -47,7 +47,7 @@ function Home() {
|
||||||
itemComponent={({ item }) => (
|
itemComponent={({ item }) => (
|
||||||
<Card
|
<Card
|
||||||
key={item.$id}
|
key={item.$id}
|
||||||
title={item.Name}
|
title={item.name}
|
||||||
description={item.$id}
|
description={item.$id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate({
|
navigate({
|
||||||
|
|
Loading…
Reference in New Issue