feat: nová stránka pro návrhy na vylepšení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s
This commit is contained in:
@@ -6,15 +6,22 @@ import { ToastContainer } from "react-toastify";
|
||||
import { SocketContext, socket } from "./context/socket";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||
import App from "./App";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
export const OBJEDNANI_URL = '/objednani';
|
||||
export const NAVRHY_URL = '/navrhy';
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path={NAVRHY_URL} element={
|
||||
<ProvideSettings>
|
||||
<SuggestionsPage />
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path={OBJEDNANI_URL} element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
|
||||
@@ -4,15 +4,14 @@ import { useAuth } from "../context/auth";
|
||||
import SettingsModal from "./modals/SettingsModal";
|
||||
import { useSettings, ThemePreference } from "../context/settings";
|
||||
import HuePicker from "./HuePicker";
|
||||
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||
import GenerateQrModal from "./modals/GenerateQrModal";
|
||||
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||
import { useNavigate } from "react-router";
|
||||
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
|
||||
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
||||
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
|
||||
import { LunchChoices, getChangelogs } from "../../../types";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||
import { formatDateString } from "../Utils";
|
||||
@@ -31,7 +30,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
const settings = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
||||
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
|
||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||
@@ -39,18 +37,9 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
getVotes().then(response => {
|
||||
setFeatureVotes(response.data);
|
||||
})
|
||||
}
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login) return;
|
||||
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
|
||||
@@ -68,10 +57,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
setSettingsModalOpen(false);
|
||||
}
|
||||
|
||||
const closeVotingModal = () => {
|
||||
setVotingModalOpen(false);
|
||||
}
|
||||
|
||||
const closePizzaModal = () => {
|
||||
setPizzaModalOpen(false);
|
||||
}
|
||||
@@ -158,17 +143,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
closeSettingsModal();
|
||||
}
|
||||
|
||||
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
|
||||
await updateVote({ body: { option, active } });
|
||||
const votes = [...featureVotes || []];
|
||||
if (active) {
|
||||
votes.push(option);
|
||||
} else {
|
||||
votes.splice(votes.indexOf(option), 1);
|
||||
}
|
||||
setFeatureVotes(votes);
|
||||
}
|
||||
|
||||
return <Navbar variant='dark' expand="lg">
|
||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
@@ -190,7 +164,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||
@@ -220,7 +194,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
||||
</Navbar.Collapse>
|
||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||
{choices && settings?.bankAccount && settings?.holderName && (
|
||||
<GenerateQrModal
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { Modal, Button, Form } from "react-bootstrap";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string, description: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Modální dialog pro přidání nového návrhu na vylepšení. */
|
||||
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || !description.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(title.trim(), description.trim());
|
||||
reset();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Název</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Stručný název návrhu"
|
||||
value={title}
|
||||
maxLength={120}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Popis</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={5}
|
||||
placeholder="Detailní popis navrhované úpravy, řešení apod."
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
|
||||
Přidat
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Modal, Button, Form } from "react-bootstrap"
|
||||
import { FeatureRequest } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean,
|
||||
onClose: () => void,
|
||||
onChange: (option: FeatureRequest, active: boolean) => void,
|
||||
initialValues?: FeatureRequest[],
|
||||
}
|
||||
|
||||
/** Modální dialog pro hlasování o nových funkcích. */
|
||||
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
|
||||
}
|
||||
|
||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
Hlasujte pro nové funkce
|
||||
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
|
||||
return <Form.Check
|
||||
key={key}
|
||||
type='checkbox'
|
||||
id={key}
|
||||
label={FeatureRequest[key]}
|
||||
onChange={handleChange}
|
||||
value={key}
|
||||
defaultChecked={initialValues?.includes(key as FeatureRequest)}
|
||||
/>
|
||||
})}
|
||||
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Modal, Button } from "react-bootstrap";
|
||||
import { Suggestion } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
suggestion?: Suggestion;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
|
||||
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
|
||||
return (
|
||||
<Modal show={!!suggestion} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="text-muted mb-3">
|
||||
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
|
||||
</p>
|
||||
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -89,67 +89,4 @@
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: var(--luncher-border);
|
||||
}
|
||||
|
||||
.voting-stats-section {
|
||||
margin-top: 48px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.voting-stats-table {
|
||||
width: 100%;
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
background: var(--luncher-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:last-child {
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--luncher-border-light);
|
||||
color: var(--luncher-text);
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:last-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Header from "../components/Header";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
||||
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
|
||||
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
|
||||
import Loader from "../components/Loader";
|
||||
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||
@@ -32,7 +32,6 @@ export default function StatsPage() {
|
||||
const auth = useAuth();
|
||||
const [dateRange, setDateRange] = useState<Date[]>();
|
||||
const [data, setData] = useState<WeeklyStats>();
|
||||
const [votingStats, setVotingStats] = useState<VotingStats>();
|
||||
|
||||
// Prvotní nastavení aktuálního týdne
|
||||
useEffect(() => {
|
||||
@@ -49,19 +48,6 @@ export default function StatsPage() {
|
||||
}
|
||||
}, [dateRange]);
|
||||
|
||||
// Načtení statistik hlasování
|
||||
useEffect(() => {
|
||||
getVotingStats().then(response => {
|
||||
setVotingStats(response.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sortedVotingStats = useMemo(() => {
|
||||
if (!votingStats) return [];
|
||||
return Object.entries(votingStats)
|
||||
.sort((a, b) => (b[1] as number) - (a[1] as number));
|
||||
}, [votingStats]);
|
||||
|
||||
const renderLine = (location: LunchChoice) => {
|
||||
const index = Object.values(LunchChoice).indexOf(location);
|
||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||
@@ -142,27 +128,6 @@ export default function StatsPage() {
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</LineChart>
|
||||
{sortedVotingStats.length > 0 && (
|
||||
<div className="voting-stats-section">
|
||||
<h2>Hlasování o funkcích</h2>
|
||||
<table className="voting-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funkce</th>
|
||||
<th>Počet hlasů</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedVotingStats.map(([feature, count]) => (
|
||||
<tr key={feature}>
|
||||
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
|
||||
<td>{count as number}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
.suggestions-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 24px;
|
||||
min-height: calc(100vh - 140px);
|
||||
background: var(--luncher-bg);
|
||||
|
||||
.suggestions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--luncher-text);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-info {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 12px 0 24px;
|
||||
color: var(--luncher-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.suggestions-empty {
|
||||
color: var(--luncher-text-secondary);
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.suggestions-table {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
background: var(--luncher-bg-card);
|
||||
border-radius: var(--luncher-radius-lg);
|
||||
box-shadow: var(--luncher-shadow);
|
||||
border: 1px solid var(--luncher-border-light);
|
||||
overflow: hidden;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
background: var(--luncher-primary);
|
||||
color: #ffffff;
|
||||
padding: 12px 20px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--luncher-border-light);
|
||||
color: var(--luncher-text);
|
||||
font-size: 0.9rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.col-score {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td.col-score {
|
||||
color: var(--luncher-primary);
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--luncher-radius-sm, 6px);
|
||||
color: var(--luncher-text-secondary);
|
||||
transition: var(--luncher-transition);
|
||||
|
||||
&:hover {
|
||||
background: var(--luncher-bg-hover);
|
||||
color: var(--luncher-text);
|
||||
}
|
||||
|
||||
&.vote-up.active {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
&.vote-down.active {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
&.delete-btn:hover {
|
||||
color: #c62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||
import Header from "../components/Header";
|
||||
import Footer from "../components/Footer";
|
||||
import Loader from "../components/Loader";
|
||||
import { useAuth } from "../context/auth";
|
||||
import Login from "../Login";
|
||||
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
|
||||
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
|
||||
import {
|
||||
Suggestion,
|
||||
VoteDirection,
|
||||
listSuggestions,
|
||||
addSuggestion,
|
||||
voteSuggestion,
|
||||
deleteSuggestion,
|
||||
} from "../../../types";
|
||||
import "./SuggestionsPage.scss";
|
||||
|
||||
export default function SuggestionsPage() {
|
||||
const auth = useAuth();
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>();
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<Suggestion>();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!auth?.login) return;
|
||||
const response = await listSuggestions();
|
||||
setSuggestions(response.data ?? []);
|
||||
}, [auth?.login]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const handleAdd = async (title: string, description: string) => {
|
||||
const response = await addSuggestion({ body: { title, description } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVote = async (id: string, direction: VoteDirection) => {
|
||||
const response = await voteSuggestion({ body: { id, direction } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (suggestion: Suggestion) => {
|
||||
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
|
||||
return;
|
||||
}
|
||||
const response = await deleteSuggestion({ body: { id: suggestion.id } });
|
||||
if (response.data) {
|
||||
setSuggestions(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
if (!auth?.login) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (!suggestions) {
|
||||
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="suggestions-page">
|
||||
<div className="suggestions-header">
|
||||
<h1>Návrhy na vylepšení</h1>
|
||||
<Button onClick={() => setAddModalOpen(true)}>
|
||||
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="suggestions-info">
|
||||
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
|
||||
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
|
||||
</p>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
|
||||
) : (
|
||||
<table className="suggestions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Navrhovatel</th>
|
||||
<th>Název</th>
|
||||
<th className="col-score">Hlasy</th>
|
||||
<th className="col-actions">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suggestions.map(suggestion => (
|
||||
<OverlayTrigger
|
||||
key={suggestion.id}
|
||||
placement="top"
|
||||
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
|
||||
>
|
||||
<tr onClick={() => setDetail(suggestion)}>
|
||||
<td>{suggestion.author}</td>
|
||||
<td>{suggestion.title}</td>
|
||||
<td className="col-score">{suggestion.voteScore}</td>
|
||||
<td className="col-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
|
||||
title="Hlasovat pro"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsUp} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
|
||||
title="Hlasovat proti"
|
||||
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsDown} />
|
||||
</button>
|
||||
{suggestion.isMine && (
|
||||
<button
|
||||
type="button"
|
||||
className="vote-btn delete-btn"
|
||||
title="Smazat návrh"
|
||||
onClick={() => handleDelete(suggestion)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</OverlayTrigger>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
|
||||
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
|
||||
]
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { storageReady } from "./storage";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
import votingRoutes from "./routes/votingRoutes";
|
||||
import suggestionRoutes from "./routes/suggestionRoutes";
|
||||
import easterEggRoutes from "./routes/easterEggRoutes";
|
||||
import statsRoutes from "./routes/statsRoutes";
|
||||
import notificationRoutes from "./routes/notificationRoutes";
|
||||
@@ -199,7 +199,7 @@ app.get("/api/data", async (req, res) => {
|
||||
// Ostatní routes
|
||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||
app.use("/api/food", foodRoutes);
|
||||
app.use("/api/voting", votingRoutes);
|
||||
app.use("/api/suggestions", suggestionRoutes);
|
||||
app.use("/api/easterEggs", easterEggRoutes);
|
||||
app.use("/api/stats", statsRoutes);
|
||||
app.use("/api/notifications", notificationRoutes);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
|
||||
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/list", async (req: Request, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await listSuggestions(login);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.title || !req.body?.description) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await addSuggestion(login, req.body.title, req.body.description);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.id || !req.body?.direction) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await voteSuggestion(login, req.body.id, req.body.direction);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (!req.body?.id) {
|
||||
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
const data = await deleteSuggestion(login, req.body.id);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,33 +0,0 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
|
||||
import { GetVotesData, UpdateVoteData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const data = await getUserVotes(login);
|
||||
res.status(200).json(data);
|
||||
});
|
||||
|
||||
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
if (req.body?.option == null || req.body?.active == null) {
|
||||
res.status(400).json({ error: "Chybné parametry volání" });
|
||||
}
|
||||
try {
|
||||
const data = await updateFeatureVote(login, req.body.option, req.body.active);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
router.get("/stats", async (req, res, next) => {
|
||||
try {
|
||||
const data = await getVotingStats();
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,142 @@
|
||||
import crypto from "crypto";
|
||||
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
|
||||
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
|
||||
interface StoredSuggestion {
|
||||
id: string;
|
||||
author: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
/** Loginy uživatelů hlasujících PRO návrh */
|
||||
upvoters: string[];
|
||||
/** Loginy uživatelů hlasujících PROTI návrhu */
|
||||
downvoters: string[];
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const STORAGE_KEY = 'suggestions';
|
||||
|
||||
/** Načte interní seznam návrhů ze storage. */
|
||||
async function loadSuggestions(): Promise<StoredSuggestion[]> {
|
||||
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
|
||||
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
|
||||
*/
|
||||
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
|
||||
let myVote: VoteDirection | undefined;
|
||||
if (suggestion.upvoters.includes(login)) {
|
||||
myVote = 'up';
|
||||
} else if (suggestion.downvoters.includes(login)) {
|
||||
myVote = 'down';
|
||||
}
|
||||
return {
|
||||
id: suggestion.id,
|
||||
author: suggestion.author,
|
||||
title: suggestion.title,
|
||||
description: suggestion.description,
|
||||
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
|
||||
myVote,
|
||||
isMine: suggestion.author === login,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
|
||||
* (při shodě skóre stabilně dle data vytvoření vzestupně).
|
||||
*
|
||||
* @param login login přihlášeného uživatele
|
||||
*/
|
||||
export async function listSuggestions(login: string): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
return suggestions
|
||||
.map(s => toDto(s, login))
|
||||
.sort((a, b) => b.voteScore - a.voteScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
|
||||
*
|
||||
* @param login login autora
|
||||
* @param title název návrhu
|
||||
* @param description detailní popis návrhu
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
|
||||
const trimmedTitle = title?.trim();
|
||||
const trimmedDescription = description?.trim();
|
||||
if (!trimmedTitle) {
|
||||
throw new Error('Název návrhu nesmí být prázdný');
|
||||
}
|
||||
if (!trimmedDescription) {
|
||||
throw new Error('Popis návrhu nesmí být prázdný');
|
||||
}
|
||||
const suggestions = await loadSuggestions();
|
||||
suggestions.push({
|
||||
id: crypto.randomUUID(),
|
||||
author: login,
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
createdAt: new Date().toISOString(),
|
||||
// Autor automaticky hlasuje pro svůj návrh
|
||||
upvoters: [login],
|
||||
downvoters: [],
|
||||
});
|
||||
await storage.setData(STORAGE_KEY, suggestions);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
|
||||
* opačný směr stávající hlas přepíše.
|
||||
*
|
||||
* @param login login hlasujícího uživatele
|
||||
* @param id identifikátor návrhu
|
||||
* @param direction směr hlasu, na který uživatel klikl
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
const suggestion = suggestions.find(s => s.id === id);
|
||||
if (!suggestion) {
|
||||
throw new Error('Návrh nebyl nalezen');
|
||||
}
|
||||
const hadUp = suggestion.upvoters.includes(login);
|
||||
const hadDown = suggestion.downvoters.includes(login);
|
||||
// Nejprve odebereme případný stávající hlas uživatele
|
||||
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
|
||||
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
|
||||
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
|
||||
if (direction === 'up' && !hadUp) {
|
||||
suggestion.upvoters.push(login);
|
||||
} else if (direction === 'down' && !hadDown) {
|
||||
suggestion.downvoters.push(login);
|
||||
}
|
||||
await storage.setData(STORAGE_KEY, suggestions);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
|
||||
*
|
||||
* @param login login uživatele požadujícího smazání
|
||||
* @param id identifikátor návrhu ke smazání
|
||||
* @returns aktualizovaný seznam návrhů jako DTO
|
||||
*/
|
||||
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
|
||||
const suggestions = await loadSuggestions();
|
||||
const suggestion = suggestions.find(s => s.id === id);
|
||||
if (!suggestion) {
|
||||
throw new Error('Návrh nebyl nalezen');
|
||||
}
|
||||
if (suggestion.author !== login) {
|
||||
throw new Error('Smazat lze pouze vlastní návrh');
|
||||
}
|
||||
const filtered = suggestions.filter(s => s.id !== id);
|
||||
await storage.setData(STORAGE_KEY, filtered);
|
||||
return listSuggestions(login);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
|
||||
|
||||
const AUTHOR = 'tomas';
|
||||
const VOTER = 'petr';
|
||||
const OTHER = 'jana';
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
/** Pomocná funkce: vytvoří návrh a vrátí jeho id (z pohledu autora). */
|
||||
async function createSuggestion(author = AUTHOR, title = 'Tmavý režim', description = 'Přidat tmavý režim aplikace') {
|
||||
const list = await addSuggestion(author, title, description);
|
||||
return list.find(s => s.title === title)!.id;
|
||||
}
|
||||
|
||||
describe('addSuggestion', () => {
|
||||
test('přidá návrh a autorovi nastaví hlas pro', async () => {
|
||||
const list = await addSuggestion(AUTHOR, 'Tmavý režim', 'Popis');
|
||||
expect(list).toHaveLength(1);
|
||||
const s = list[0];
|
||||
expect(s.author).toBe(AUTHOR);
|
||||
expect(s.title).toBe('Tmavý režim');
|
||||
expect(s.description).toBe('Popis');
|
||||
expect(s.voteScore).toBe(1);
|
||||
expect(s.myVote).toBe('up');
|
||||
expect(s.isMine).toBe(true);
|
||||
});
|
||||
|
||||
test('ořízne mezery a odmítne prázdný název i popis', async () => {
|
||||
await expect(addSuggestion(AUTHOR, ' ', 'popis')).rejects.toThrow();
|
||||
await expect(addSuggestion(AUTHOR, 'název', ' ')).rejects.toThrow();
|
||||
const list = await addSuggestion(AUTHOR, ' Název ', ' Popis ');
|
||||
expect(list[0].title).toBe('Název');
|
||||
expect(list[0].description).toBe('Popis');
|
||||
});
|
||||
|
||||
test('jeden uživatel může přidat více návrhů', async () => {
|
||||
await addSuggestion(AUTHOR, 'První', 'popis');
|
||||
const list = await addSuggestion(AUTHOR, 'Druhý', 'popis');
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSuggestions', () => {
|
||||
test('řadí sestupně dle skóre', async () => {
|
||||
const lowId = await createSuggestion(AUTHOR, 'Nízké', 'popis');
|
||||
const highId = await createSuggestion(OTHER, 'Vysoké', 'popis');
|
||||
// "Vysoké" dostane další hlas pro
|
||||
await voteSuggestion(VOTER, highId, 'up');
|
||||
|
||||
const list = await listSuggestions(VOTER);
|
||||
expect(list[0].id).toBe(highId);
|
||||
expect(list[0].voteScore).toBe(2);
|
||||
expect(list[1].id).toBe(lowId);
|
||||
});
|
||||
|
||||
test('myVote a isMine jsou relativní k uživateli', async () => {
|
||||
const id = await createSuggestion(AUTHOR);
|
||||
const asAuthor = (await listSuggestions(AUTHOR))[0];
|
||||
expect(asAuthor.isMine).toBe(true);
|
||||
expect(asAuthor.myVote).toBe('up');
|
||||
|
||||
const asOther = (await listSuggestions(VOTER))[0];
|
||||
expect(asOther.isMine).toBe(false);
|
||||
expect(asOther.myVote).toBeUndefined();
|
||||
// Seznamy hlasujících se klientovi neposílají
|
||||
expect((asOther as any).upvoters).toBeUndefined();
|
||||
expect(id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('voteSuggestion', () => {
|
||||
test('hlas pro a proti od jiného uživatele', async () => {
|
||||
const id = await createSuggestion();
|
||||
let list = await voteSuggestion(VOTER, id, 'up');
|
||||
expect(list[0].voteScore).toBe(2);
|
||||
expect(list.find(s => s.id === id)!.voteScore).toBe(2);
|
||||
|
||||
// přehlasování z pro na proti
|
||||
list = await voteSuggestion(VOTER, id, 'down');
|
||||
// autor +1, voter -1 => 0
|
||||
expect(list[0].voteScore).toBe(0);
|
||||
expect((await listSuggestions(VOTER))[0].myVote).toBe('down');
|
||||
});
|
||||
|
||||
test('klik na aktivní směr hlas zruší', async () => {
|
||||
const id = await createSuggestion();
|
||||
await voteSuggestion(VOTER, id, 'up');
|
||||
const list = await voteSuggestion(VOTER, id, 'up');
|
||||
// zůstává jen autorův hlas
|
||||
expect(list[0].voteScore).toBe(1);
|
||||
expect((await listSuggestions(VOTER))[0].myVote).toBeUndefined();
|
||||
});
|
||||
|
||||
test('autor může svůj automatický hlas odebrat', async () => {
|
||||
const id = await createSuggestion();
|
||||
const list = await voteSuggestion(AUTHOR, id, 'up');
|
||||
expect(list[0].voteScore).toBe(0);
|
||||
expect((await listSuggestions(AUTHOR))[0].myVote).toBeUndefined();
|
||||
});
|
||||
|
||||
test('hlasování pro neexistující návrh vyhodí chybu', async () => {
|
||||
await expect(voteSuggestion(VOTER, 'neexistuje', 'up')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSuggestion', () => {
|
||||
test('autor smaže svůj návrh včetně hlasů', async () => {
|
||||
const id = await createSuggestion();
|
||||
await voteSuggestion(VOTER, id, 'up');
|
||||
const list = await deleteSuggestion(AUTHOR, id);
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('cizí uživatel nemůže smazat návrh', async () => {
|
||||
const id = await createSuggestion();
|
||||
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
|
||||
expect(await listSuggestions(AUTHOR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('smazání neexistujícího návrhu vyhodí chybu', async () => {
|
||||
await expect(deleteSuggestion(AUTHOR, 'neexistuje')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
||||
|
||||
const OPT_A = FeatureRequest.STATISTICS;
|
||||
const OPT_B = FeatureRequest.UI;
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
describe('updateFeatureVote', () => {
|
||||
test('přidá hlas pro nového uživatele', async () => {
|
||||
const result = await updateFeatureVote('alice', OPT_A, true);
|
||||
expect(result['alice']).toContain(OPT_A);
|
||||
});
|
||||
|
||||
test('vyhodí chybu při duplicitním hlasování', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali');
|
||||
});
|
||||
|
||||
test('odebere hlas', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await updateFeatureVote('alice', OPT_A, false);
|
||||
const stats = await getVotingStats();
|
||||
expect(stats[OPT_A] ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
test('odebrání neexistujícího hlasu je no-op', async () => {
|
||||
await expect(updateFeatureVote('alice', OPT_A, false)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('odebrání posledního hlasu odstraní login ze storage', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
const data = await updateFeatureVote('alice', OPT_A, false);
|
||||
expect('alice' in data).toBe(false);
|
||||
});
|
||||
|
||||
test('vyhodí chybu po 4 hlasech', async () => {
|
||||
const options = Object.values(FeatureRequest);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await updateFeatureVote('alice', options[i], true);
|
||||
}
|
||||
await expect(updateFeatureVote('alice', options[4], true)).rejects.toThrow('4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserVotes', () => {
|
||||
test('vrátí hlasy uživatele', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
const votes = await getUserVotes('alice');
|
||||
expect(votes).toContain(OPT_A);
|
||||
});
|
||||
|
||||
test('vrátí prázdné pole pro uživatele bez hlasů', async () => {
|
||||
const votes = await getUserVotes('neexistujici');
|
||||
expect(votes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVotingStats', () => {
|
||||
test('vrátí agregované počty hlasů', async () => {
|
||||
await updateFeatureVote('alice', OPT_A, true);
|
||||
await updateFeatureVote('bob', OPT_A, true);
|
||||
await updateFeatureVote('bob', OPT_B, true);
|
||||
|
||||
const stats = await getVotingStats();
|
||||
expect(stats[OPT_A]).toBe(2);
|
||||
expect(stats[OPT_B]).toBe(1);
|
||||
});
|
||||
|
||||
test('vrátí prázdný objekt bez hlasů', async () => {
|
||||
const stats = await getVotingStats();
|
||||
expect(stats).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import bodyParser from 'body-parser';
|
||||
import { generateToken } from '../auth';
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
||||
import votingRouter from '../routes/votingRoutes';
|
||||
|
||||
const VALID_OPTION = FeatureRequest.STATISTICS;
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(bodyParser.json());
|
||||
app.use('/api/voting', votingRouter);
|
||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
||||
res.status(400).json({ error: err.message });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
const TOKEN = `Bearer ${generateToken('testuser')}`;
|
||||
|
||||
beforeEach(() => {
|
||||
resetMemoryStorage();
|
||||
});
|
||||
|
||||
test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/voting/getVotes')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
test('GET /getVotes vrátí 401 bez tokenu', async () => {
|
||||
const res = await request(buildApp()).get('/api/voting/getVotes');
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('POST /updateVote přidá hlas a vrátí 200', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('POST /updateVote vrátí 400 pro chybějící parametry', async () => {
|
||||
const res = await request(buildApp())
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => {
|
||||
const app = buildApp();
|
||||
await request(app)
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
const res = await request(app)
|
||||
.post('/api/voting/updateVote')
|
||||
.set('Authorization', TOKEN)
|
||||
.send({ option: VALID_OPTION, active: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('hlasovali');
|
||||
});
|
||||
|
||||
test('GET /stats vrátí 200 s objektem', async () => {
|
||||
const res = await request(buildApp())
|
||||
.get('/api/voting/stats')
|
||||
.set('Authorization', TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body).toBe('object');
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
|
||||
interface VotingData {
|
||||
[login: string]: FeatureRequest[],
|
||||
}
|
||||
|
||||
export interface VotingStatsResult {
|
||||
[feature: string]: number;
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const STORAGE_KEY = 'voting';
|
||||
|
||||
/**
|
||||
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
|
||||
*
|
||||
* @param login login uživatele
|
||||
* @returns pole voleb
|
||||
*/
|
||||
export async function getUserVotes(login: string) {
|
||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
return data?.[login] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje hlas uživatele pro konkrétní volbu.
|
||||
*
|
||||
* @param login login uživatele
|
||||
* @param option volba
|
||||
* @param active příznak, zda volbu přidat nebo odebrat
|
||||
* @returns aktuální data
|
||||
*/
|
||||
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
||||
let data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
data ??= {};
|
||||
if (!(login in data)) {
|
||||
data[login] = [];
|
||||
}
|
||||
const index = data[login].indexOf(option);
|
||||
if (index > -1) {
|
||||
if (active) {
|
||||
throw new Error('Pro tuto možnost jste již hlasovali');
|
||||
} else {
|
||||
data[login].splice(index, 1);
|
||||
if (data[login].length === 0) {
|
||||
delete data[login];
|
||||
}
|
||||
}
|
||||
} else if (active) {
|
||||
if (data[login].length == 4) {
|
||||
throw new Error('Je možné hlasovat pro maximálně 4 možnosti');
|
||||
}
|
||||
data[login].push(option);
|
||||
}
|
||||
await storage.setData(STORAGE_KEY, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
|
||||
*
|
||||
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
|
||||
*/
|
||||
export async function getVotingStats(): Promise<VotingStatsResult> {
|
||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
||||
const stats: VotingStatsResult = {};
|
||||
if (data) {
|
||||
for (const votes of Object.values(data)) {
|
||||
for (const feature of votes) {
|
||||
stats[feature] = (stats[feature] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
+9
-7
@@ -69,13 +69,15 @@ paths:
|
||||
/stats:
|
||||
$ref: "./paths/stats/stats.yml"
|
||||
|
||||
# Hlasování (/api/voting)
|
||||
/voting/getVotes:
|
||||
$ref: "./paths/voting/getVotes.yml"
|
||||
/voting/updateVote:
|
||||
$ref: "./paths/voting/updateVote.yml"
|
||||
/voting/stats:
|
||||
$ref: "./paths/voting/getVotingStats.yml"
|
||||
# Návrhy na vylepšení (/api/suggestions)
|
||||
/suggestions/list:
|
||||
$ref: "./paths/suggestions/list.yml"
|
||||
/suggestions/add:
|
||||
$ref: "./paths/suggestions/add.yml"
|
||||
/suggestions/vote:
|
||||
$ref: "./paths/suggestions/vote.yml"
|
||||
/suggestions/delete:
|
||||
$ref: "./paths/suggestions/delete.yml"
|
||||
|
||||
# Changelog (/api/changelogs)
|
||||
/changelogs:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
post:
|
||||
operationId: addSuggestion
|
||||
summary: Přidá nový návrh na vylepšení. Autorovi se automaticky nastaví hlas pro.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- description
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: Stručný jednořádkový název návrhu
|
||||
description:
|
||||
type: string
|
||||
description: Detailní popis navrhované úpravy
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný seznam návrhů.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||
@@ -0,0 +1,26 @@
|
||||
post:
|
||||
operationId: deleteSuggestion
|
||||
summary: >-
|
||||
Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh
|
||||
(validováno na serveru).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Identifikátor návrhu ke smazání
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný seznam návrhů.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||
@@ -0,0 +1,11 @@
|
||||
get:
|
||||
operationId: listSuggestions
|
||||
summary: Vrátí seznam návrhů na vylepšení seřazený sestupně dle počtu hlasů.
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||
@@ -0,0 +1,30 @@
|
||||
post:
|
||||
operationId: voteSuggestion
|
||||
summary: >-
|
||||
Přepne hlas přihlášeného uživatele u návrhu. Klik na již aktivní směr hlas
|
||||
zruší, opačný směr stávající hlas přepíše.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- direction
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Identifikátor návrhu
|
||||
direction:
|
||||
description: Směr hlasu, na který uživatel klikl
|
||||
$ref: "../../schemas/_index.yml#/VoteDirection"
|
||||
responses:
|
||||
"200":
|
||||
description: Aktualizovaný seznam návrhů.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||
@@ -1,11 +0,0 @@
|
||||
get:
|
||||
operationId: getVotes
|
||||
summary: Vrátí statistiky hlasování o nových funkcích.
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/FeatureRequest"
|
||||
@@ -1,9 +0,0 @@
|
||||
get:
|
||||
operationId: getVotingStats
|
||||
summary: Vrátí agregované statistiky hlasování o nových funkcích.
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/VotingStats"
|
||||
@@ -1,22 +0,0 @@
|
||||
post:
|
||||
operationId: updateVote
|
||||
summary: Aktualizuje hlasování uživatele o dané funkcionalitě.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- option
|
||||
- active
|
||||
properties:
|
||||
option:
|
||||
description: Hlasovací možnost, kterou uživatel zvolil.
|
||||
$ref: "../../schemas/_index.yml#/FeatureRequest"
|
||||
active:
|
||||
type: boolean
|
||||
description: True, pokud uživatel hlasoval pro, jinak false.
|
||||
responses:
|
||||
"200":
|
||||
description: Hlasování bylo úspěšně aktualizováno.
|
||||
+38
-27
@@ -272,39 +272,50 @@ DepartureTime:
|
||||
- T12_45
|
||||
- T13_00
|
||||
|
||||
# --- HLASOVÁNÍ ---
|
||||
FeatureRequest:
|
||||
# --- NÁVRHY NA VYLEPŠENÍ ---
|
||||
VoteDirection:
|
||||
description: Směr hlasu uživatele u návrhu
|
||||
type: string
|
||||
enum:
|
||||
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
|
||||
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
|
||||
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
|
||||
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
|
||||
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
|
||||
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
|
||||
- Vylepšení responzivního designu
|
||||
- Zvýšení zabezpečení aplikace
|
||||
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
|
||||
- Celkové vylepšení UI/UX
|
||||
- Zlepšení dokumentace/postupů pro ostatní vývojáře
|
||||
- up
|
||||
- down
|
||||
x-enum-varnames:
|
||||
- FAVORITES
|
||||
- SINGLE_PAYMENT
|
||||
- NO_WEEKENDS
|
||||
- QR_FOREVER
|
||||
- PIZZA_PICTURES
|
||||
- STATISTICS
|
||||
- RESPONSIVITY
|
||||
- SECURITY
|
||||
- SAFETY
|
||||
- UI
|
||||
- DEVELOPMENT
|
||||
- UP
|
||||
- DOWN
|
||||
|
||||
VotingStats:
|
||||
description: Statistiky hlasování - klíčem je název funkce, hodnotou počet hlasů
|
||||
Suggestion:
|
||||
description: Návrh na vylepšení aplikace tak, jak je posílán klientovi.
|
||||
type: object
|
||||
additionalProperties:
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- author
|
||||
- title
|
||||
- description
|
||||
- voteScore
|
||||
- isMine
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Jednoznačný identifikátor návrhu
|
||||
author:
|
||||
type: string
|
||||
description: Login uživatele, který návrh vytvořil
|
||||
title:
|
||||
type: string
|
||||
description: Stručný jednořádkový název návrhu
|
||||
description:
|
||||
type: string
|
||||
description: Detailní popis navrhované úpravy
|
||||
voteScore:
|
||||
type: integer
|
||||
description: Skóre návrhu = počet hlasů pro mínus počet hlasů proti
|
||||
myVote:
|
||||
description: Hlas přihlášeného uživatele (chybí, pokud nehlasoval)
|
||||
$ref: "#/VoteDirection"
|
||||
isMine:
|
||||
type: boolean
|
||||
description: True, pokud návrh vytvořil přihlášený uživatel
|
||||
|
||||
# --- EASTER EGGS ---
|
||||
EasterEgg:
|
||||
|
||||
Reference in New Issue
Block a user