feat: možnost označení návrhu jako vyřešeného (resolved)
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 36s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-10 18:38:55 +02:00
parent 491ec25b52
commit cc09ddbd2c
6 changed files with 165 additions and 52 deletions
@@ -16,6 +16,7 @@ export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<
<Modal.Body> <Modal.Body>
<p className="text-muted mb-3"> <p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong> Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
</p> </p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p> <p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body> </Modal.Body>
+23
View File
@@ -36,6 +36,29 @@
margin-top: 32px; margin-top: 32px;
} }
.resolved-section {
width: 100%;
max-width: 900px;
margin-top: 48px;
h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
}
}
.suggestions-table.resolved {
th {
background: var(--luncher-text-secondary);
}
td.col-score {
color: var(--luncher-text-secondary);
}
}
.suggestions-table { .suggestions-table {
width: 100%; width: 100%;
max-width: 900px; max-width: 900px;
+78 -41
View File
@@ -60,6 +60,54 @@ export default function SuggestionsPage() {
} }
}; };
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
// ale autor je stále může smazat.
const renderRow = (suggestion: 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()}>
{!suggestion.resolved && (
<>
<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>
);
if (!auth?.login) { if (!auth?.login) {
return <Login />; return <Login />;
} }
@@ -68,6 +116,9 @@ export default function SuggestionsPage() {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />; return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
} }
const activeSuggestions = suggestions.filter(s => !s.resolved);
const resolvedSuggestions = suggestions.filter(s => s.resolved);
return ( return (
<> <>
<Header /> <Header />
@@ -86,6 +137,8 @@ export default function SuggestionsPage() {
{suggestions.length === 0 ? ( {suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p> <p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : ( ) : (
<>
{activeSuggestions.length > 0 && (
<table className="suggestions-table"> <table className="suggestions-table">
<thead> <thead>
<tr> <tr>
@@ -96,50 +149,34 @@ export default function SuggestionsPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{suggestions.map(suggestion => ( {activeSuggestions.map(renderRow)}
<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> </tbody>
</table> </table>
)} )}
{resolvedSuggestions.length > 0 && (
<div className="resolved-section">
<h2>Vyřešené návrhy</h2>
<p className="suggestions-info">
Tyto návrhy již byly zapracovány. Nelze pro hlasovat, autor je však může odstranit.
</p>
<table className="suggestions-table resolved">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{resolvedSuggestions.map(renderRow)}
</tbody>
</table>
</div>
)}
</>
)}
</div> </div>
<Footer /> <Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} /> <AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
+6
View File
@@ -13,6 +13,8 @@ interface StoredSuggestion {
upvoters: string[]; upvoters: string[];
/** Loginy uživatelů hlasujících PROTI návrhu */ /** Loginy uživatelů hlasujících PROTI návrhu */
downvoters: string[]; downvoters: string[];
/** Příznak vyřešeného (zapracovaného) návrhu - nastavuje se pouze ručním zásahem do dat */
resolved?: boolean;
} }
const storage = getStorage(); const storage = getStorage();
@@ -42,6 +44,7 @@ function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
voteScore: suggestion.upvoters.length - suggestion.downvoters.length, voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
myVote, myVote,
isMine: suggestion.author === login, isMine: suggestion.author === login,
resolved: suggestion.resolved ?? false,
}; };
} }
@@ -105,6 +108,9 @@ export async function voteSuggestion(login: string, id: string, direction: VoteD
if (!suggestion) { if (!suggestion) {
throw new Error('Návrh nebyl nalezen'); throw new Error('Návrh nebyl nalezen');
} }
if (suggestion.resolved) {
throw new Error('Pro vyřešený návrh nelze hlasovat');
}
const hadUp = suggestion.upvoters.includes(login); const hadUp = suggestion.upvoters.includes(login);
const hadDown = suggestion.downvoters.includes(login); const hadDown = suggestion.downvoters.includes(login);
// Nejprve odebereme případný stávající hlas uživatele // Nejprve odebereme případný stávající hlas uživatele
+39
View File
@@ -1,6 +1,15 @@
import { resetMemoryStorage } from '../storage/memory'; import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage';
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions'; import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
/** Ručně označí návrh jako vyřešený - simuluje zásah do dat (Redis/JSON). */
async function markResolved(id: string) {
const storage = getStorage();
const data = await storage.getData<any[]>('suggestions');
data!.find(s => s.id === id).resolved = true;
await storage.setData('suggestions', data);
}
const AUTHOR = 'tomas'; const AUTHOR = 'tomas';
const VOTER = 'petr'; const VOTER = 'petr';
const OTHER = 'jana'; const OTHER = 'jana';
@@ -106,6 +115,36 @@ describe('voteSuggestion', () => {
}); });
}); });
describe('vyřešené návrhy', () => {
test('listSuggestions vrací příznak resolved', async () => {
const id = await createSuggestion();
expect((await listSuggestions(AUTHOR))[0].resolved).toBe(false);
await markResolved(id);
expect((await listSuggestions(AUTHOR))[0].resolved).toBe(true);
});
test('pro vyřešený návrh nelze hlasovat', async () => {
const id = await createSuggestion();
await markResolved(id);
await expect(voteSuggestion(VOTER, id, 'up')).rejects.toThrow();
// skóre zůstává nezměněné (jen autorův hlas)
expect((await listSuggestions(VOTER))[0].voteScore).toBe(1);
});
test('autor může vyřešený návrh stále smazat', async () => {
const id = await createSuggestion();
await markResolved(id);
const list = await deleteSuggestion(AUTHOR, id);
expect(list).toHaveLength(0);
});
test('cizí uživatel nemůže smazat ani vyřešený návrh', async () => {
const id = await createSuggestion();
await markResolved(id);
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
});
});
describe('deleteSuggestion', () => { describe('deleteSuggestion', () => {
test('autor smaže svůj návrh včetně hlasů', async () => { test('autor smaže svůj návrh včetně hlasů', async () => {
const id = await createSuggestion(); const id = await createSuggestion();
+7
View File
@@ -294,6 +294,7 @@ Suggestion:
- description - description
- voteScore - voteScore
- isMine - isMine
- resolved
properties: properties:
id: id:
type: string type: string
@@ -316,6 +317,12 @@ Suggestion:
isMine: isMine:
type: boolean type: boolean
description: True, pokud návrh vytvořil přihlášený uživatel description: True, pokud návrh vytvořil přihlášený uživatel
resolved:
type: boolean
description: >-
True, pokud byl návrh označen jako vyřešený (zapracovaný). Vyřešené
návrhy jsou read-only - nelze pro ně hlasovat. Nastavuje se pouze
ručním zásahem do dat.
# --- EASTER EGGS --- # --- EASTER EGGS ---
EasterEgg: EasterEgg: