diff --git a/package-lock.json b/package-lock.json index e247fc2b9377fb86d610f56b4d3adf49d19eef80..235d29b41ad9435dfd95a134a74cb18b5044993a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index aee21f7eac97a098e46b3ba27eb9742dbc166eb4..114be49def5ade904c527d80748a059d17164ab7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index 848f3da3ad726e9583dce37352d6817469205177..f608dce1985a689a28666a3b01f7fc6b5f7f76d4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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"; diff --git a/src/actions/announcements.js b/src/actions/announcements.js new file mode 100644 index 0000000000000000000000000000000000000000..f4eee78850938963413c87a5ed271a9c5e75ac36 --- /dev/null +++ b/src/actions/announcements.js @@ -0,0 +1,28 @@ +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); + }); + }, + } +); diff --git a/src/actions/posts.js b/src/actions/posts.js new file mode 100644 index 0000000000000000000000000000000000000000..f196e0cf0af757b8c6307c49f2e94c7302144120 --- /dev/null +++ b/src/actions/posts.js @@ -0,0 +1,144 @@ +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); + }); + }, + } +); diff --git a/src/components/Button.jsx b/src/components/Button.jsx index 9527911d54ccf80b2d84aaf5dfdf15ba4a80c85f..d1af51ff52781b177ccc1ec9ba6cff6dd059811a 100644 --- a/src/components/Button.jsx +++ b/src/components/Button.jsx @@ -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> + <div className="btn__body">{children}</div> {!!icon && ( - <div className="btn__icon"> + <div className={iconWrapperClass}> <i className={icon}></i> + {iconChildren} </div> )} </div> diff --git a/src/containers/AddAnnouncementForm.jsx b/src/containers/AddAnnouncementForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9febd43c003ee16fbef231e60126daf5b6e89a40 --- /dev/null +++ b/src/containers/AddAnnouncementForm.jsx @@ -0,0 +1,47 @@ +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 }); + setText(""); + } + }; + + 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; diff --git a/src/containers/AddPostForm.jsx b/src/containers/AddPostForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b4f1bb3eb01b6a8a589463dabe6d5b8e9c03b0a9 --- /dev/null +++ b/src/containers/AddPostForm.jsx @@ -0,0 +1,84 @@ +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 }); + setText(""); + } + }; + + const onAddProposal = (evt) => { + evt.stopPropagation(); + + if (!!text) { + addProposal.run({ content: text }); + setText(""); + } + }; + + 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; diff --git a/src/components/posts/PostFilters.jsx b/src/containers/PostFilters.jsx similarity index 95% rename from src/components/posts/PostFilters.jsx rename to src/containers/PostFilters.jsx index 376cd32c4b70d1ecde6d7b9ad893e01ab1bc5042..81891290d41332811341c62f7e0d552ce1bc446b 100644 --- a/src/components/posts/PostFilters.jsx +++ b/src/containers/PostFilters.jsx @@ -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; } diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx index b0166c73b3ebf791cf6d6845e9476adb1e7c4db1..dc02809892b5215fc57690314e4f968cc453ee2f 100644 --- a/src/containers/PostsContainer.jsx +++ b/src/containers/PostsContainer.jsx @@ -1,25 +1,31 @@ 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} /> diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 964f3354c9cf08537951e506165d4e91e73365c5..8b5f95840a2650af6bcf8a1cddbdf52e0d39f16c 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,7 +1,9 @@ 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> </> diff --git a/src/stores.js b/src/stores.js index 58f433b89e31c122a84d9e3f6b7d778a3fd192fa..9a295d99a8142096e5a31a0a62987d9cd0d187f8 100644 --- a/src/stores.js +++ b/src/stores.js @@ -1,4 +1,6 @@ +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, diff --git a/src/utils.js b/src/utils.js index 9e9bd0904343707d1b9e75fb99fe1c166be04db2..8e8cb35035cc5a48076efc7467a33f2cc8acc877 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,13 @@ 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") + ); +}; diff --git a/typings/cf2021.d.ts b/typings/cf2021.d.ts index b560ff8dae57fe00d5cdc12ae24ddbbcbd77104a..5b985202c91957c9cf11a1c7ade7bad80df20fb6 100644 --- a/typings/cf2021.d.ts +++ b/typings/cf2021.d.ts @@ -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; } }