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
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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ně 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} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user