diff --git a/src/actions/posts.js b/src/actions/posts.js index 5d279297869ea2defa12e9279db8388df1ec3b98..f2ca0a308d0785d0dd2eb103858a5a13b13ce20d 100644 --- a/src/actions/posts.js +++ b/src/actions/posts.js @@ -142,6 +142,26 @@ export const hide = createAsyncAction( } ); +/** + * Edit post content. + */ +export const edit = createAsyncAction( + /** + * @param {CF2021.Post} post + */ + async ({ post, newContent }) => { + try { + const body = JSON.stringify({ + content: newContent, + }); + await fetch(`/posts/${post.id}`, { method: "PUT", body }); + return successResult(); + } catch (err) { + return errorResult([], err.toString()); + } + } +); + /** * * @param {CF2021.ProposalPost} proposal diff --git a/src/components/Button.jsx b/src/components/Button.jsx index 6365546746d3dacc2fe1151764bf6d198aaf0eeb..de5690f97fee3032f2b35f78c01620736c13e15f 100644 --- a/src/components/Button.jsx +++ b/src/components/Button.jsx @@ -10,6 +10,7 @@ const Button = ({ iconChildren = null, hoverActive = true, fullwidth = false, + loading = false, children, routerTo, ...props @@ -21,6 +22,7 @@ const Button = ({ "btn--icon": !!icon, "btn--hoveractive": hoverActive, "btn--fullwidth md:btn--autowidth": fullwidth, + "btn--loading": loading, }, className ); diff --git a/src/components/ErrorMessage.jsx b/src/components/ErrorMessage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9c08d7ebd931ef453af9e74a0a3df88be5a9c32d --- /dev/null +++ b/src/components/ErrorMessage.jsx @@ -0,0 +1,12 @@ +import React from "react"; +import classNames from "classnames"; + +const ErrorMessage = ({ className, children }) => { + return ( + <div className={classNames("text-red-600 font-bold", className)}> + {children} + </div> + ); +}; + +export default ErrorMessage; diff --git a/src/components/annoucements/AnnouncementList.jsx b/src/components/annoucements/AnnouncementList.jsx index c275a6532437b82bcd0d13415e7b67c0589c2256..898d2f9f354254de9c9154cd8e764e1b4b8c574b 100644 --- a/src/components/annoucements/AnnouncementList.jsx +++ b/src/components/annoucements/AnnouncementList.jsx @@ -11,9 +11,9 @@ const AnnouncementList = ({ onEdit, onSeen, }) => { - const buildHandler = (responderFn) => (post) => (evt) => { + const buildHandler = (responderFn) => (announcement) => (evt) => { evt.preventDefault(); - responderFn(post); + responderFn(announcement); }; const onAnnouncementEdit = buildHandler(onEdit); diff --git a/src/components/modals/ModalConfirm.jsx b/src/components/modals/ModalConfirm.jsx index b427affd4af39b6350f6707b1aea10c150aaa7eb..eb8dcc23720be431d676700b70eab6d6a40c8978 100644 --- a/src/components/modals/ModalConfirm.jsx +++ b/src/components/modals/ModalConfirm.jsx @@ -18,6 +18,7 @@ const ModalConfirm = ({ cancelActionLabel = "Zrušit", onCancel, onConfirm, + confirming, ...props }) => { return ( @@ -38,6 +39,7 @@ const ModalConfirm = ({ color="blue-300" className="text-sm" onClick={onConfirm} + loading={confirming} > {yesActionLabel} </Button> diff --git a/src/components/posts/Post.jsx b/src/components/posts/Post.jsx index 9c79a5cd7cfdee96e2ba93df8aa505aeed412153..cd67cbf999a40e781da354b101b85165c7934223 100644 --- a/src/components/posts/Post.jsx +++ b/src/components/posts/Post.jsx @@ -29,6 +29,7 @@ const Post = ({ onAcceptProcedureProposal, onRejectProcedureProposal, onRejectProcedureProposalByChairman, + onEdit, onSeen, }) => { const { ref, inView } = useInView({ @@ -128,6 +129,7 @@ const Post = ({ type === "procedure-proposal" && state === "announced"; const showRejectByChairmanAction = type === "procedure-proposal" && ["announced", "pending"].includes(state); + const showEditAction = true; const showBanAction = true; const showHideAction = !archived; @@ -199,6 +201,13 @@ const Post = ({ title="Zamítnout procedurální návrh předsedajícím" /> )} + {showEditAction && ( + <DropdownMenuItem + onClick={onEdit} + icon="ico--edit-pencil" + title="Upravit příspěvek" + /> + )} {showBanAction && ( <DropdownMenuItem onClick={onBanUser} diff --git a/src/components/posts/PostEditModal.jsx b/src/components/posts/PostEditModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b801099744e01f08b6de40275ce1b012ffd4b3dc --- /dev/null +++ b/src/components/posts/PostEditModal.jsx @@ -0,0 +1,68 @@ +import React, { useState } from "react"; + +import Button from "components/Button"; +import { Card, CardActions, CardBody, CardHeadline } from "components/cards"; +import Modal from "components/modals/Modal"; + +const PostEditModal = ({ post, onCancel, onConfirm, ...props }) => { + const [text, setText] = useState(post.content); + + const onTextInput = (evt) => { + setText(evt.target.value); + }; + + const confirm = (evt) => { + if (!!text) { + onConfirm(text); + } + }; + + return ( + <Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}> + <Card> + <CardBody> + <div className="flex items-center justify-between mb-4"> + <CardHeadline>Upravit text příspěvku</CardHeadline> + <button onClick={onCancel}> + <i className="ico--close"></i> + </button> + </div> + <div className="form-field"> + <label className="form-field__label" htmlFor="field"> + Nový text příspěvku + </label> + <div className="form-field__wrapper form-field__wrapper--shadowed"> + <textarea + className="text-input form-field__control text-base" + value={text} + rows="8" + placeholder="Vyplňte text příspěvku" + onChange={onTextInput} + ></textarea> + </div> + </div> + </CardBody> + <CardActions right className="space-x-1"> + <Button + hoverActive + color="blue-300" + className="text-sm" + onClick={confirm} + > + Uložit + </Button> + <Button + hoverActive + color="red-600" + className="text-sm" + onClick={onCancel} + > + Zrušit + </Button> + </CardActions> + </Card> + </Modal> + ); +}; + +export default PostEditModal; diff --git a/src/components/posts/PostList.jsx b/src/components/posts/PostList.jsx index 12b5587b23f1703663aea13e9a47747d68b20a77..6faf9a2750c77a6481bb501a95c5b282b4e2c977 100644 --- a/src/components/posts/PostList.jsx +++ b/src/components/posts/PostList.jsx @@ -16,6 +16,7 @@ const PostList = ({ onAcceptProcedureProposal, onRejectProcedureProposal, onRejectProcedureProposalByChairman, + onEdit, onSeen, dimArchived, }) => { @@ -26,6 +27,7 @@ const PostList = ({ const onPostLike = buildHandler(onLike); const onPostDislike = buildHandler(onDislike); + const onPostEdit = buildHandler(onEdit); const onPostHide = buildHandler(onHide); const onPostBanUser = buildHandler(onBanUser); const onPostAnnounceProcedureProposal = buildHandler( @@ -71,11 +73,14 @@ const PostList = ({ onRejectProcedureProposalByChairman={onPostRejectProcedureProposalByChairman( item )} + onEdit={onPostEdit(item)} onSeen={onPostSeen(item)} /> ))} {!items.length && ( - <p>Nikdo zatím žádný příspěvek do rozpravy nepřidal. Budeš první?</p> + <p className="p-4 lg:p-0 lg:py-3 "> + Nikdo zatím žádný příspěvek do rozpravy nepřidal. Budeš první? + </p> )} </div> ); diff --git a/src/containers/AddAnnouncementForm.jsx b/src/containers/AddAnnouncementForm.jsx index ece3e18545edaf27cabac8e9f1d1afc525e2d299..c3ef0e493d75a26c458e9ddb1c945ffb2e34dc7d 100644 --- a/src/containers/AddAnnouncementForm.jsx +++ b/src/containers/AddAnnouncementForm.jsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import { addAnnouncement } from "actions/announcements"; import Button from "components/Button"; +import { useActionLoading } from "hooks"; const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; @@ -12,6 +13,12 @@ const AddAnnouncementForm = ({ className }) => { const [linkValid, setLinkValid] = useState(false); const [type, setType] = useState("announcement"); + const addingAnnouncement = useActionLoading(addAnnouncement, { + content: text, + link, + type, + }); + const onTextInput = (evt) => { setText(evt.target.value); }; @@ -24,11 +31,14 @@ const AddAnnouncementForm = ({ className }) => { } }; - const onAdd = (evt) => { + const onAdd = async (evt) => { if (!!text) { - addAnnouncement.run({ content: text, link, type }); - setText(""); - setLink(""); + const result = await addAnnouncement.run({ content: text, link, type }); + + if (!result.error) { + setText(""); + setLink(""); + } } }; @@ -102,7 +112,10 @@ const AddAnnouncementForm = ({ className }) => { onClick={onAdd} className="text-sm mt-4" hoverActive - disabled={!text || (type === "voting" && !linkValid)} + loading={addingAnnouncement} + disabled={ + !text || (type === "voting" && !linkValid) || addingAnnouncement + } > Přidat oznámení </Button> diff --git a/src/containers/AddPostForm.jsx b/src/containers/AddPostForm.jsx index ba79cc9ea548cde1c0fd0db94d7ba4ac9fbc3578..1818eb8fac7665252eb921049d883ba17b2e42c7 100644 --- a/src/containers/AddPostForm.jsx +++ b/src/containers/AddPostForm.jsx @@ -2,27 +2,36 @@ import React, { useState } from "react"; import { addPost, addProposal } from "actions/posts"; import Button from "components/Button"; +import { useActionLoading } from "hooks"; const AddPostForm = ({ className }) => { const [text, setText] = useState(""); + const addingPost = useActionLoading(addPost, { content: text }); + const addingProposal = useActionLoading(addPost, { content: text }); const onTextInput = (evt) => { setText(evt.target.value); }; - const onAddPost = (evt) => { + const onAddPost = async (evt) => { if (!!text) { - addPost.run({ content: text }); - setText(""); + const result = await addPost.run({ content: text }); + + if (!result.error) { + setText(""); + } } }; - const onAddProposal = (evt) => { + const onAddProposal = async (evt) => { evt.stopPropagation(); if (!!text) { - addProposal.run({ content: text }); - setText(""); + const result = await addProposal.run({ content: text }); + + if (!result.error) { + setText(""); + } } }; @@ -54,7 +63,8 @@ const AddPostForm = ({ className }) => { <div className="space-x-4"> <Button onClick={onAddPost} - disabled={!text} + disabled={!text || addingPost || addingProposal} + loading={addingPost || addingProposal} hoverActive icon="ico--chevron-down" iconWrapperClassName="dropdown-button" diff --git a/src/containers/AnnoucementsContainer.jsx b/src/containers/AnnoucementsContainer.jsx index 1a2318ab73115b668edaf51f09f006a251696dd4..0c052d980650f37cf9d28681fa2f6e5bd8431968 100644 --- a/src/containers/AnnoucementsContainer.jsx +++ b/src/containers/AnnoucementsContainer.jsx @@ -2,16 +2,20 @@ import React, { useCallback, useState } from "react"; import { deleteAnnouncement, + loadAnnouncements, updateAnnouncementContent, } from "actions/announcements"; import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal"; import AnnouncementList from "components/annoucements/AnnouncementList"; +import { CardBody } from "components/cards"; +import ErrorMessage from "components/ErrorMessage"; import ModalConfirm from "components/modals/ModalConfirm"; -import { useItemActionConfirm } from "hooks"; +import { useActionLoading, useItemActionConfirm } from "hooks"; import { AnnouncementStore, AuthStore } from "stores"; const AnnoucementsContainer = () => { const [itemToEdit, setItemToEdit] = useState(null); + const { 2: loadResult } = loadAnnouncements.useWatch(); const [ itemToDelete, @@ -20,6 +24,11 @@ const AnnoucementsContainer = () => { onDeleteCancel, ] = useItemActionConfirm(deleteAnnouncement); + const deletingAnnouncement = useActionLoading( + deleteAnnouncement, + itemToDelete + ); + const { isAuthenticated, user } = AuthStore.useState(); const items = AnnouncementStore.useState((state) => state.itemIds.map((id) => state.items[id]) @@ -51,6 +60,13 @@ const AnnoucementsContainer = () => { return ( <> + {loadResult && loadResult.error && ( + <CardBody> + <ErrorMessage> + Oznámení se nepodařilo načíst: {loadResult.message} + </ErrorMessage> + </CardBody> + )} <AnnouncementList items={items} canRunActions={isAuthenticated && user.role === "chairman"} @@ -62,6 +78,7 @@ const AnnoucementsContainer = () => { isOpen={!!itemToDelete} onConfirm={onDeleteConfirm} onCancel={onDeleteCancel} + confirming={deletingAnnouncement} title="Opravdu smazat?" yesActionLabel="Smazat" > diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx index c0ebf21a5163c680a09ea69172b2f696fd73172b..17c5f53b1b8ddd4e7e35b3482f8a0075dbed994c 100644 --- a/src/containers/PostsContainer.jsx +++ b/src/containers/PostsContainer.jsx @@ -1,22 +1,28 @@ -import React from "react"; +import React, { useCallback, useState } from "react"; import pick from "lodash/pick"; import { acceptProposal, announceProposal, dislike, + edit, hide, like, + loadPosts, rejectProposal, rejectProposalByChairman, } from "actions/posts"; import { ban } from "actions/users"; +import ErrorMessage from "components/ErrorMessage"; import ModalConfirm from "components/modals/ModalConfirm"; +import PostEditModal from "components/posts/PostEditModal"; import PostList from "components/posts/PostList"; -import { useItemActionConfirm } from "hooks"; +import { useActionLoading, useItemActionConfirm } from "hooks"; import { AuthStore, PostStore } from "stores"; const PostsContainer = ({ className }) => { + const [postToEdit, setPostToEdit] = useState(null); + const [ userToBan, setUserToBan, @@ -62,6 +68,32 @@ const PostsContainer = ({ className }) => { (state) => state.filters.flags === "archived" ); + const banningUser = useActionLoading(ban, userToBan); + const hidingPost = useActionLoading(hide, postToHide); + const announcingProposal = useActionLoading(announceProposal, postToAnnounce); + const acceptingProposal = useActionLoading(acceptProposal, postToAccept); + const rejectingProposal = useActionLoading(rejectProposal, postToReject); + const rejectingProposalByChairman = useActionLoading( + rejectProposalByChairman, + postToRejectByChairman + ); + + const { 2: loadResult } = loadPosts.useWatch(); + + const confirmEdit = useCallback( + async (newContent) => { + if (postToEdit && newContent) { + await edit.run({ post: postToEdit, newContent }); + setPostToEdit(null); + } + }, + [postToEdit, setPostToEdit] + ); + + const cancelEdit = useCallback(() => { + setPostToEdit(null); + }, [setPostToEdit]); + /** * Ban a post's author. * @param {CF2021.Post} post @@ -85,6 +117,11 @@ const PostsContainer = ({ className }) => { return ( <> + {loadResult && loadResult.error && ( + <ErrorMessage> + Příspěvky se nepodařilo načíst: {loadResult.message} + </ErrorMessage> + )} <PostList items={window.items .slice(sliceStart, sliceEnd) @@ -98,6 +135,7 @@ const PostsContainer = ({ className }) => { dimArchived={!showingArchivedOnly} onHide={setPostToHide} onBanUser={onBanUser} + onEdit={setPostToEdit} onAnnounceProcedureProposal={setPostToAnnounce} onAcceptProcedureProposal={setPostToAccept} onRejectProcedureProposal={setPostToReject} @@ -107,6 +145,7 @@ const PostsContainer = ({ className }) => { isOpen={!!userToBan} onConfirm={onBanUserConfirm} onCancel={onBanUserCancel} + confirming={banningUser} title={`Zablokovat uživatele ${userToBan ? userToBan.name : null}?`} yesActionLabel="Zablokovat" > @@ -117,15 +156,7 @@ const PostsContainer = ({ className }) => { isOpen={!!postToHide} onConfirm={onPostHideConfirm} onCancel={onPostHideCancel} - title="Skrýt příspěvek?" - yesActionLabel="Potvrdit" - > - Příspěvek se skryje a uživatelé ho neuvidí. Opravdu to chcete? - </ModalConfirm> - <ModalConfirm - isOpen={!!postToHide} - onConfirm={onPostHideConfirm} - onCancel={onPostHideCancel} + confirming={hidingPost} title="Skrýt příspěvek?" yesActionLabel="Potvrdit" > @@ -135,6 +166,7 @@ const PostsContainer = ({ className }) => { isOpen={!!postToAnnounce} onConfirm={onAnnounceConfirm} onCancel={onAnnounceCancel} + confirming={announcingProposal} title="Vyhlásit procedurální návrh?" yesActionLabel="Vyhlásit návrh" > @@ -144,6 +176,7 @@ const PostsContainer = ({ className }) => { isOpen={!!postToAccept} onConfirm={onAcceptConfirm} onCancel={onAcceptCancel} + confirming={acceptingProposal} title="Schválit procedurální návrh?" yesActionLabel="Schválit návrh" > @@ -153,6 +186,7 @@ const PostsContainer = ({ className }) => { isOpen={!!postToReject} onConfirm={onRejectConfirm} onCancel={onRejectCancel} + confirming={rejectingProposal} title="Zamítnout procedurální návrh?" yesActionLabel="Zamítnout návrh" > @@ -162,12 +196,21 @@ const PostsContainer = ({ className }) => { isOpen={!!postToRejectByChairman} onConfirm={onRejectByChairmanConfirm} onCancel={onRejectByChairmanCancel} + confirming={rejectingProposalByChairman} title="Zamítnout procedurální návrh předsedajícícm?" yesActionLabel="Zamítnout návrh předsedajícím" > Procedurální návrh bude <strong>zamítnut předsedajícím</strong>. Opravdu to chcete? </ModalConfirm> + {postToEdit && ( + <PostEditModal + isOpen={true} + post={postToEdit} + onConfirm={confirmEdit} + onCancel={cancelEdit} + /> + )} </> ); }; diff --git a/src/hooks.js b/src/hooks.js index 31703eb9b35e5513dd24b602ee8445ef8605a729..1b4e5b50c1b45512e4fbb4c2acb608a289ac9e28 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -3,10 +3,13 @@ import { useCallback, useState } from "react"; export const useItemActionConfirm = (actionFn) => { const [item, setItem] = useState(null); - const onActionConfirm = useCallback(() => { + const onActionConfirm = useCallback(async () => { if (item) { - actionFn.run(item); - setItem(null); + const result = await actionFn.run(item); + + if (!result.error) { + setItem(null); + } } }, [item, setItem, actionFn]); @@ -20,10 +23,13 @@ export const useItemActionConfirm = (actionFn) => { export const useActionConfirm = (actionFn, actionArgs) => { const [showConfirm, setShowConfirm] = useState(false); - const onActionConfirm = useCallback(() => { + const onActionConfirm = useCallback(async () => { if (showConfirm) { - actionFn.run(actionArgs); - setShowConfirm(false); + const result = await actionFn.run(actionArgs); + + if (!result.error) { + setShowConfirm(false); + } } }, [showConfirm, setShowConfirm, actionFn, actionArgs]); @@ -33,3 +39,8 @@ export const useActionConfirm = (actionFn, actionArgs) => { return [showConfirm, setShowConfirm, onActionConfirm, onActionCancel]; }; + +export const useActionLoading = (actionFn, actionArgs) => { + const { 0: started, 1: finished } = actionFn.useWatch(actionArgs); + return started && !finished; +};