Skip to content
Snippets Groups Projects
Commit 553808a1 authored by xaralis's avatar xaralis
Browse files

feat: like/dislike, add post, add proposal, add announcement

parent e5ab3953
Branches
No related tags found
No related merge requests found
......@@ -5648,6 +5648,17 @@
}
}
},
"eslint-plugin-jest-dom": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest-dom/-/eslint-plugin-jest-dom-3.6.3.tgz",
"integrity": "sha512-GYipB8RrAO4tu97pP7sl5uGMNrlAqtf4YiFadyVj4g89qrXJ4G1BuBV688V30tkK8EVv3p22AXYoBrP0vXrnjQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.6",
"@testing-library/dom": "^7.28.1",
"requireindex": "^1.2.0"
}
},
"eslint-plugin-jsx-a11y": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.3.1.tgz",
......@@ -7084,9 +7095,9 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg=="
},
"immer": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz",
"integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg=="
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/immer/-/immer-7.0.15.tgz",
"integrity": "sha512-yM7jo9+hvYgvdCQdqvhCNRRio0SCXc8xDPzA25SvKWa7b1WVPjLwQs1VYU5JPXjcJPTqAa5NP5dqpORGYBQ2AA=="
},
"import-cwd": {
"version": "2.1.0",
......@@ -11182,6 +11193,11 @@
"path-exists": "^4.0.0"
}
},
"immer": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-1.10.0.tgz",
"integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg=="
},
"inquirer": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz",
......@@ -12046,6 +12062,12 @@
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
......
......@@ -7,6 +7,7 @@
"@sentry/react": "^5.23.0",
"classnames": "^2.2.6",
"date-fns": "^2.16.1",
"immer": "^7.0.15",
"keycloak-js": "^10.0.2",
"lodash": "^4.17.20",
"pullstate": "^1.20.4",
......@@ -52,7 +53,7 @@
"^@?\\w"
],
[
"^(components|containers|pages|utils|stores|keycloak)(/.*|$)"
"^(actions|components|containers|pages|utils|stores|keycloak)(/.*|$)"
],
[
"^(test-utils)(/.*|$)"
......@@ -107,6 +108,7 @@
"eslint-config-airbnb": "^18.2.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-jest-dom": "^3.6.3",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.20.6",
......
......@@ -2,8 +2,8 @@ import React, { Suspense } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { KeycloakProvider } from "@react-keycloak/web";
import * as Sentry from "@sentry/react";
import { loadGroupMappings } from "actions/misc";
import { loadGroupMappings } from "actions/misc";
import Footer from "components/Footer";
import Navbar from "components/Navbar";
import Home from "pages/Home";
......
import { createAsyncAction, successResult } from "pullstate";
import { AnnouncementStore } from "stores";
/**
* Add new announcement.
*/
export const addAnnouncement = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.Announcement} */
const payload = {
id: "999",
datetime: new Date(),
type: "announcement",
content,
seen: true,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
AnnouncementStore.update((state) => {
state.items.push(result.payload);
});
},
}
);
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { PostStore } from "stores";
import { updateWindowPosts } from "utils";
export const like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.likes += 1;
state.items[result.payload.id].ranking.score += 1;
state.items[result.payload.id].ranking.myVote = "like";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
});
}
},
}
);
export const dislike = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.dislikes += 1;
state.items[result.payload.id].ranking.score -= 1;
state.items[result.payload.id].ranking.myVote = "dislike";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
});
}
},
}
);
/**
* Add new discussion post.
*/
export const addPost = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.DiscussionPost} */
const payload = {
id: "999",
datetime: new Date(),
author: {
name: "John Doe",
group: "KS Pardubický kraj",
},
type: "post",
content,
ranking: {
score: 0,
likes: 0,
dislikes: 0,
myVote: "none",
},
historyLog: [],
archived: false,
hidden: false,
seen: true,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
});
},
}
);
/**
* Add new proposal.
*/
export const addProposal = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.ProposalPost} */
const payload = {
id: "999",
datetime: new Date(),
author: {
name: "John Doe",
group: "KS Pardubický kraj",
},
type: "procedure-proposal",
state: "pending",
content,
ranking: {
score: 0,
likes: 0,
dislikes: 0,
myVote: "none",
},
historyLog: [],
archived: false,
hidden: false,
seen: true,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
});
},
}
);
......@@ -3,7 +3,9 @@ import classNames from "classnames";
const Button = ({
className,
iconWrapperClassName,
icon,
iconChildren = null,
hoverActive = true,
fullwidth = false,
children,
......@@ -19,13 +21,16 @@ const Button = ({
className
);
const iconWrapperClass = classNames("btn__icon", iconWrapperClassName);
return (
<button className={btnClass} {...props}>
<div className="btn__body-wrap">
<div className="btn__body">{children}</div>
{!!icon && (
<div className="btn__icon">
<div className={iconWrapperClass}>
<i className={icon}></i>
{iconChildren}
</div>
)}
</div>
......
import React, { useState } from "react";
import { addAnnouncement } from "actions/announcements";
import Button from "components/Button";
const AddAnnouncementForm = ({ className }) => {
const [text, setText] = useState("");
const onTextInput = (evt) => {
setText(evt.target.value);
};
const onAdd = (evt) => {
if (!!text) {
addAnnouncement.run({ content: text });
}
};
return (
<div className={className}>
<div className="form-field">
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control "
value={text}
rows="3"
cols="40"
placeholder="Vyplňte text oznámení"
onChange={onTextInput}
></textarea>
</div>
</div>
<Button
onClick={onAdd}
className="btn--black text-sm mt-2"
hoverActive
disabled={!text}
>
Přidat oznámení
</Button>
</div>
);
};
export default AddAnnouncementForm;
import React, { useState } from "react";
import { addPost, addProposal } from "actions/posts";
import Button from "components/Button";
const AddPostForm = ({ className }) => {
const [text, setText] = useState("");
const onTextInput = (evt) => {
setText(evt.target.value);
};
const onAddPost = (evt) => {
if (!!text) {
addPost.run({ content: text });
}
};
const onAddProposal = (evt) => {
evt.stopPropagation();
if (!!text) {
addProposal.run({ content: text });
}
};
const buttonDropdownActionList = (
<ul className="dropdown-button__choices bg-white text-black whitespace-no-wrap">
<li className="dropdown-button__choice hover:bg-grey-125">
<span className="block px-4 py-3" onClick={onAddProposal}>
Navrhnout postup
</span>
</li>
</ul>
);
return (
<div className={className}>
<div className="form-field">
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control "
value={text}
rows="5"
cols="40"
placeholder="Vyplňte text vašeho příspěvku"
onChange={onTextInput}
></textarea>
</div>
</div>
<div className="space-x-4">
<Button
className="btn--black"
onClick={onAddPost}
disabled={!text}
hoverActive
icon="ico--chevron-down"
iconWrapperClassName="dropdown-button"
iconChildren={buttonDropdownActionList}
>
Přidat příspěvek
</Button>
<span className="text-sm text-grey-200 hidden lg:inline">
Pro pokročilejší formátování můžete používat{" "}
<a
href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet"
className="underline"
target="_blank"
rel="noreferrer noopener"
>
Markdown
</a>
.
</span>
</div>
</div>
);
};
export default AddPostForm;
......@@ -4,10 +4,10 @@ import pick from "lodash/pick";
import Chip from "components/Chip";
import Dropdown from "components/Dropdown";
import { PostStore } from "stores";
import { filterPosts } from "utils";
import { updateWindowPosts } from "utils";
const PostFilters = () => {
const { window, filters, items } = PostStore.useState((state) =>
const { window, filters } = PostStore.useState((state) =>
pick(state, ["window", "filters", "items"])
);
......@@ -31,9 +31,10 @@ const PostFilters = () => {
const setFilter = (prop, newValue, resetPage = true) => {
PostStore.update((state) => {
state.filters[prop] = newValue;
state.window.items = filterPosts(state.filters, items);
state.window.itemCount = state.window.items.length;
updateWindowPosts(state);
if (resetPage) {
state.window.page = 1;
}
......
import React from "react";
import pick from "lodash/pick";
import { dislike, like } from "actions/posts";
import PostList from "components/posts/PostList";
import { PostStore } from "stores";
const PostsContainer = ({ className }) => {
const window = PostStore.useState((state) => state.window);
const { window, items } = PostStore.useState((state) =>
pick(state, ["window", "items"])
);
const showingArchivedOnly = PostStore.useState(
(state) => state.filters.flags === "archived"
);
const onLike = (post) => console.log("like", post);
const onDislike = (post) => console.log("dislike", post);
// const onLike = (post) => like.run();
// const onDislike = (post) => console.log("dislike", post);
const sliceStart = (window.page - 1) * window.perPage;
const sliceEnd = window.page * window.perPage;
return (
<PostList
items={window.items.slice(sliceStart, sliceEnd)}
onLike={onLike}
onDislike={onDislike}
items={window.items
.slice(sliceStart, sliceEnd)
.map((postId) => items[postId])}
onLike={like.run}
onDislike={dislike.run}
className={className}
dimArchived={!showingArchivedOnly}
/>
......
import React from "react";
import PostFilters from "components/posts/PostFilters";
import AddAnnouncementForm from "containers/AddAnnouncementForm";
import AddPostForm from "containers/AddPostForm";
import AnnouncementsContainer from "containers/AnnoucementsContainer";
import PostFilters from "containers/PostFilters";
import PostsContainer from "containers/PostsContainer";
const Home = () => {
......@@ -34,25 +36,7 @@ const Home = () => {
</div>
<AnnouncementsContainer className="container-padding--zero lg:container-padding--auto" />
<div className="lg:card__body pt-4 lg:py-6">
<div className="form-field ">
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control "
value=""
rows="3"
cols="40"
placeholder="Vyplňte text oznámení"
readOnly
></textarea>
</div>
</div>
<button className="btn btn--black btn--hoveractive text-sm mt-2">
<div className="btn__body ">Přidat oznámení</div>
</button>
</div>
<AddAnnouncementForm className="lg:card__body pt-4 lg:py-6" />
</div>
</section>
......@@ -65,52 +49,7 @@ const Home = () => {
</div>
<PostsContainer className="container-padding--zero lg:container-padding--auto" />
<div className="my-8 space-y-4">
<div className="form-field ">
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control "
value=""
rows="5"
cols="40"
placeholder="Vyplňte text vašeho příspěvku"
readOnly
></textarea>
</div>
</div>
<div className="space-x-4">
<button className="btn btn--icon ">
<div className="btn__body-wrap">
<div className="btn__body ">Přidat příspěvek</div>
<div className="btn__icon dropdown-button">
<i className="ico--chevron-down"></i>
<ul className="dropdown-button__choices bg-white text-black whitespace-no-wrap">
<li className="dropdown-button__choice hover:bg-grey-125">
<span className="block px-4 py-3" href="#">
Navrhnout postup
</span>
</li>
</ul>
</div>
</div>
</button>
<span className="text-sm text-grey-200 hidden lg:inline">
Pro pokročilejší formátování můžete používat{" "}
<a
href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet"
className="underline"
target="_blank"
rel="noreferrer noopener"
>
Markdown
</a>
.
</span>
</div>
</div>
<AddPostForm className="my-8 space-y-4" />
</section>
</article>
</>
......
import keyBy from "lodash/keyBy";
import memoize from "lodash/memoize";
import property from "lodash/property";
import { Store } from "pullstate";
import { filterPosts } from "utils";
......@@ -302,10 +304,10 @@ const filteredPosts = filterPosts(initialPostFilters, allPosts);
/** @type {CF2021.PostStorePayload} */
const postStoreInitial = {
items: allPosts,
items: keyBy(allPosts, property("id")),
itemCount: allPosts.length,
window: {
items: filteredPosts,
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
page: 1,
perPage: 5,
......
import filter from "lodash/filter";
import property from "lodash/property";
import values from "lodash/values";
/**
* Filter & sort collection of posts.
* @param {CF2021.PostStoreFilters} filters
* @param {CF2021.PostStoreItems} allItems
* @returns {CF2021.Post[]}
*/
export const filterPosts = (filters, allItems) => {
const predicate = {};
......@@ -25,3 +33,13 @@ export const filterPosts = (filters, allItems) => {
return filteredItems.sort((a, b) => b.ranking.score - a.ranking.score);
};
/**
* Update current posts window according to used filters.
* @param {CF2021.PostStorePayload} state
*/
export const updateWindowPosts = (state) => {
state.window.items = filterPosts(state.filters, values(state.items)).map(
property("id")
);
};
......@@ -99,24 +99,29 @@ declare namespace CF2021 {
| "accepted"
| "rejected"
| "rejected-by-chairman";
originalContent?: string;
}
export type Post = ProposalPost | DiscussionPost;
export interface PostStoreItems {
[key: string]: Post;
}
export interface PostStoreFilters {
flags: "all" | "active" | "archived";
sort: "byDate" | "byScore";
type: "all" | "proposalsOnly" | "discussionOnly";
}
export interface PostStorePayload {
items: Post[];
items: PostStoreItems;
itemCount: number;
window: {
items: Post[];
items: string[];
itemCount: number;
page: number;
perPage: number;
};
filters: {
flags: "all" | "active" | "archived";
sort: "byDate" | "byScore";
type: "all" | "proposalsOnly" | "discussionOnly";
};
filters: PostStoreFilters;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment