diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index 40fe994fd8..37d5d0658c 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -546,7 +546,12 @@ export const defaultSession: SessionState = { }; export const defaultStories: StoriesState = { - storyList: [], + storyLists: { + draft: [], + pending: [], + rejected: [], + published: [] + }, currentStoryId: null, currentStory: null, envs: {} diff --git a/src/commons/sagas/StoriesSaga.ts b/src/commons/sagas/StoriesSaga.ts index 4d771c78b0..e6ba89e93b 100644 --- a/src/commons/sagas/StoriesSaga.ts +++ b/src/commons/sagas/StoriesSaga.ts @@ -9,7 +9,13 @@ import { postStory, updateStory } from 'src/features/stories/storiesComponents/BackendAccess'; -import { StoryData, StoryListView, StoryView } from 'src/features/stories/StoriesTypes'; +import { + StoryData, + StoryListView, + StoryListViews, + StoryStatus, + StoryView +} from 'src/features/stories/StoriesTypes'; import { OverallState, StoriesRole } from '../application/ApplicationTypes'; import { Tokens } from '../application/types/SessionTypes'; @@ -25,11 +31,34 @@ const StoriesSaga = combineSagaHandlers(StoriesActions, { // TODO: This should be using `takeLatest`, not `takeEvery` getStoriesList: function* () { const tokens: Tokens = yield selectTokens(); - const allStories: StoryListView[] = yield call(async () => { - const resp = await getStories(tokens); + + const draftStories: StoryListView[] = yield call(async () => { + const resp = await getStories(tokens, StoryStatus.Draft); + return resp ?? []; + }); + + const pendingStories: StoryListView[] = yield call(async () => { + const resp = await getStories(tokens, StoryStatus.Pending); + return resp ?? []; + }); + + const rejectedStories: StoryListView[] = yield call(async () => { + const resp = await getStories(tokens, StoryStatus.Rejected); + return resp ?? []; + }); + + const publishedStories: StoryListView[] = yield call(async () => { + const resp = await getStories(tokens, StoryStatus.Published); return resp ?? []; }); + const allStories: StoryListViews = { + draft: draftStories, + pending: pendingStories, + rejected: rejectedStories, + published: publishedStories + }; + yield put(actions.updateStoriesList(allStories)); }, setCurrentStoryId: function* (action) { @@ -42,7 +71,9 @@ const StoriesSaga = combineSagaHandlers(StoriesActions, { const defaultStory: StoryData = { title: '', content: defaultStoryContent, - pinOrder: null + pinOrder: null, + status: StoryStatus.Draft, + statusMessage: '' }; yield put(actions.setCurrentStory(defaultStory)); } @@ -82,7 +113,9 @@ const StoriesSaga = combineSagaHandlers(StoriesActions, { id, story.title, story.content, - story.pinOrder + story.pinOrder, + story.status, + story.statusMessage ); // TODO: Check correctness diff --git a/src/features/stories/StoriesActions.ts b/src/features/stories/StoriesActions.ts index 2b8faed6c1..b8205109de 100644 --- a/src/features/stories/StoriesActions.ts +++ b/src/features/stories/StoriesActions.ts @@ -9,7 +9,7 @@ import { SET_CURRENT_STORIES_GROUP, SET_CURRENT_STORIES_USER, StoryData, - StoryListView, + StoryListViews, StoryParams } from './StoriesTypes'; @@ -31,7 +31,7 @@ const newActions = createActions('stories', { // New action creators post-refactor getStoriesList: () => ({}), - updateStoriesList: (storyList: StoryListView[]) => storyList, + updateStoriesList: (storyLists: StoryListViews) => storyLists, setCurrentStory: (story: StoryData | null) => story, setCurrentStoryId: (id: number | null) => id, createStory: (story: StoryParams) => story, diff --git a/src/features/stories/StoriesReducer.ts b/src/features/stories/StoriesReducer.ts index b578bbfd2a..d4bcdd3874 100644 --- a/src/features/stories/StoriesReducer.ts +++ b/src/features/stories/StoriesReducer.ts @@ -153,7 +153,7 @@ const newStoriesReducer = createReducer(defaultStories, builder => { }) // New cases post-refactor .addCase(updateStoriesList, (state, action) => { - state.storyList = action.payload; + state.storyLists = action.payload; }) .addCase(setCurrentStoryId, (state, action) => { state.currentStoryId = action.payload; diff --git a/src/features/stories/StoriesTypes.ts b/src/features/stories/StoriesTypes.ts index b281b70ed3..37a4d11e99 100644 --- a/src/features/stories/StoriesTypes.ts +++ b/src/features/stories/StoriesTypes.ts @@ -19,10 +19,19 @@ export type StoryData = { title: string; content: string; pinOrder: number | null; + status: StoryStatus; + statusMessage: string; }; export type StoryParams = StoryData; +export enum StoryStatus { + Draft = 0, + Pending, + Rejected, + Published +} + export type StoryListView = StoryData & StoryMetadata & { id: number; @@ -53,8 +62,15 @@ export type StoriesAuthState = { readonly role?: StoriesRole; }; +export type StoryListViews = { + readonly draft: StoryListView[]; + readonly pending: StoryListView[]; + readonly rejected: StoryListView[]; + readonly published: StoryListView[]; +}; + export type StoriesState = { - readonly storyList: StoryListView[]; + readonly storyLists: StoryListViews; readonly currentStoryId: number | null; readonly currentStory: StoryData | null; readonly envs: { [key: string]: StoriesEnvState }; diff --git a/src/features/stories/storiesComponents/BackendAccess.ts b/src/features/stories/storiesComponents/BackendAccess.ts index f996240be4..f050a49469 100644 --- a/src/features/stories/storiesComponents/BackendAccess.ts +++ b/src/features/stories/storiesComponents/BackendAccess.ts @@ -11,7 +11,7 @@ import { store } from 'src/pages/createStore'; import { Tokens } from '../../../commons/application/types/SessionTypes'; import { NameUsernameRole } from '../../../pages/academy/adminPanel/subcomponents/AddStoriesUserPanel'; -import { StoryListView, StoryView } from '../StoriesTypes'; +import { StoryListView, StoryStatus, StoryView } from '../StoriesTypes'; // Helpers @@ -75,8 +75,22 @@ export const postNewStoriesUsers = async ( // TODO: Return response JSON directly. }; -export const getStories = async (tokens: Tokens): Promise => { - const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/stories`, 'GET', { +export const getStories = async ( + tokens: Tokens, + status: StoryStatus | null = null +): Promise => { + const route = + status === StoryStatus.Draft + ? '/draft' + : status === StoryStatus.Pending + ? '/pending' + : status === StoryStatus.Rejected + ? '/rejected' + : status === StoryStatus.Published + ? '/published' + : ''; + + const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/stories${route}`, 'GET', { ...tokens }); if (!resp) { @@ -124,10 +138,12 @@ export const updateStory = async ( id: number, title: string, content: string, - pinOrder: number | null + pinOrder: number | null, + status: StoryStatus, + statusMessage: string ): Promise => { const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/stories/${id}`, 'PUT', { - body: { title, content, pinOrder }, + body: { title, content, pinOrder, status, statusMessage }, ...tokens }); if (!resp) { diff --git a/src/pages/stories/Stories.tsx b/src/pages/stories/Stories.tsx index 85357d9501..58cc2b51f1 100644 --- a/src/pages/stories/Stories.tsx +++ b/src/pages/stories/Stories.tsx @@ -12,6 +12,7 @@ import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import { deleteStory, getStoriesList, saveStory } from 'src/features/stories/StoriesActions'; import { getYamlHeader } from 'src/features/stories/storiesComponents/UserBlogContent'; +import { StoryStatus } from 'src/features/stories/StoriesTypes'; import StoriesTable from './StoriesTable'; import StoryActions from './StoryActions'; @@ -48,13 +49,13 @@ const Stories: React.FC = () => { [dispatch] ); - const storyList = useTypedSelector(state => state.stories.storyList); + const storyLists = useTypedSelector(state => state.stories.storyLists); const handleTogglePinStory = useCallback( (id: number) => { // Safe to use ! as the story ID comes a story in storyList - const story = storyList.find(story => story.id === id)!; - const pinnedLength = storyList.filter(story => story.isPinned).length; + const story = storyLists.published.find(story => story.id === id)!; + const pinnedLength = storyLists.published.filter(story => story.isPinned).length; const newStory = { ...story, isPinned: !story.isPinned, @@ -63,19 +64,19 @@ const Stories: React.FC = () => { }; dispatch(saveStory(newStory, id)); }, - [dispatch, storyList] + [dispatch, storyLists] ); const handleMovePinUp = useCallback( (id: number) => { // Safe to use ! as the story ID comes a story in storyList - const oldIndex = storyList.findIndex(story => story.id === id)!; + const oldIndex = storyLists.published.findIndex(story => story.id === id)!; if (oldIndex === 0) { return; } - const toMoveUp = storyList[oldIndex]; - const toMoveDown = storyList[oldIndex - 1]; + const toMoveUp = storyLists.published[oldIndex]; + const toMoveDown = storyLists.published[oldIndex - 1]; const storiesToUpdate = [ { ...toMoveUp, pinOrder: oldIndex - 1 }, @@ -83,19 +84,19 @@ const Stories: React.FC = () => { ]; storiesToUpdate.forEach(story => dispatch(saveStory(story, story.id))); }, - [dispatch, storyList] + [dispatch, storyLists] ); const handleMovePinDown = useCallback( (id: number) => { // Safe to use ! as the story ID comes a story in storyList - const oldIndex = storyList.findIndex(story => story.id === id)!; - const pinnedLength = storyList.filter(story => story.isPinned).length; + const oldIndex = storyLists.published.findIndex(story => story.id === id)!; + const pinnedLength = storyLists.published.filter(story => story.isPinned).length; if (oldIndex === pinnedLength - 1) { return; } - const toMoveDown = storyList[oldIndex]; - const toMoveUp = storyList[oldIndex + 1]; + const toMoveDown = storyLists.published[oldIndex]; + const toMoveUp = storyLists.published[oldIndex + 1]; const storiesToUpdate = [ { ...toMoveDown, pinOrder: oldIndex + 1 }, @@ -103,7 +104,33 @@ const Stories: React.FC = () => { ]; storiesToUpdate.forEach(story => dispatch(saveStory(story, story.id))); }, - [dispatch, storyList] + [dispatch, storyLists] + ); + + const handleRejectStory = useCallback( + (id: number) => { + // Safe to use ! as the story ID comes a story in storyList + const story = storyLists.pending.find(story => story.id === id)!; + const newStory = { + ...story, + status: StoryStatus.Rejected + }; + dispatch(saveStory(newStory, id)); + }, + [dispatch, storyLists] + ); + + const handlePublishStory = useCallback( + (id: number) => { + // Safe to use ! as the story ID comes a story in storyList + const story = storyLists.pending.find(story => story.id === id)!; + const newStory = { + ...story, + status: StoryStatus.Published + }; + dispatch(saveStory(newStory, id)); + }, + [dispatch, storyLists] ); return isStoriesDisabled ? ( @@ -138,36 +165,130 @@ const Stories: React.FC = () => { /> - ({ ...story, content: getYamlHeader(story.content).content })) - .filter( - story => - // Always show pinned stories - story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) - )} - storyActions={story => { - const isAuthor = storiesUserId === story.authorId; - const hasWritePermissions = - storiesRole === StoriesRole.Moderator || storiesRole === StoriesRole.Admin; - return ( - - ); - }} - /> + {storyLists.published.length > 0 && ( + ({ ...story, content: getYamlHeader(story.content).content })) + .filter( + story => + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} + storyActions={story => { + const isAuthor = storiesUserId === story.authorId; + const hasWritePermissions = + storiesRole === StoriesRole.Moderator || storiesRole === StoriesRole.Admin; + return ( + + ); + }} + /> + )} + + {storyLists.pending.length > 0 && ( + ({ ...story, content: getYamlHeader(story.content).content })) + .filter( + story => + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} + storyActions={story => { + const isAuthor = storiesUserId === story.authorId; + const hasWritePermissions = + storiesRole === StoriesRole.Moderator || storiesRole === StoriesRole.Admin; + return ( + + ); + }} + /> + )} + + {storyLists.rejected.length > 0 && ( + ({ ...story, content: getYamlHeader(story.content).content })) + .filter( + story => + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} + storyActions={story => { + const isAuthor = storiesUserId === story.authorId; + const hasWritePermissions = + storiesRole === StoriesRole.Moderator || storiesRole === StoriesRole.Admin; + return ( + + ); + }} + /> + )} + + {storyLists.draft.length > 0 && ( + ({ ...story, content: getYamlHeader(story.content).content })) + .filter( + story => + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} + storyActions={story => { + const isAuthor = storiesUserId === story.authorId; + const hasWritePermissions = + storiesRole === StoriesRole.Moderator || storiesRole === StoriesRole.Admin; + return ( + + ); + }} + /> + )} } /> diff --git a/src/pages/stories/StoriesTable.tsx b/src/pages/stories/StoriesTable.tsx index f0f88391eb..9466f02f26 100644 --- a/src/pages/stories/StoriesTable.tsx +++ b/src/pages/stories/StoriesTable.tsx @@ -9,7 +9,8 @@ import { TableHead, TableHeaderCell, TableRow, - Text + Text, + Title } from '@tremor/react'; import React from 'react'; import { StoryListView } from 'src/features/stories/StoriesTypes'; @@ -18,49 +19,53 @@ type Props = { headers: Array<{ id: string; header: string }>; stories: StoryListView[]; storyActions: (stor: StoryListView) => React.ReactNode; + title: string; }; const MAX_EXCERPT_LENGTH = 35; -const StoriesTable: React.FC = ({ headers, stories, storyActions }) => { +const StoriesTable: React.FC = ({ headers, stories, storyActions, title }) => { return ( - - - - {headers.map(({ id, header }) => ( - {header} - ))} - - - - {stories.map(story => { - const { id, authorName, isPinned, title, content } = story; - return ( - - {authorName} - - - {isPinned && } />} - {title} - - - - - {content.replaceAll(/\s+/g, ' ').length <= MAX_EXCERPT_LENGTH - ? content.replaceAll(/\s+/g, ' ') - : content.split(/\s+/).reduce((acc, cur) => { - return acc.length + cur.length <= MAX_EXCERPT_LENGTH - ? acc + ' ' + cur - : acc; - }, '') + '…'} - - - {storyActions(story)} - - ); - })} - -
+ <> + {title} + + + + {headers.map(({ id, header }) => ( + {header} + ))} + + + + {stories.map(story => { + const { id, authorName, isPinned, title, content } = story; + return ( + + {authorName} + + + {isPinned && } />} + {title} + + + + + {content.replaceAll(/\s+/g, ' ').length <= MAX_EXCERPT_LENGTH + ? content.replaceAll(/\s+/g, ' ') + : content.split(/\s+/).reduce((acc, cur) => { + return acc.length + cur.length <= MAX_EXCERPT_LENGTH + ? acc + ' ' + cur + : acc; + }, '') + '…'} + + + {storyActions(story)} + + ); + })} + +
+ ); }; diff --git a/src/pages/stories/StoryActions.tsx b/src/pages/stories/StoryActions.tsx index db5f1e020c..719dceb6fe 100644 --- a/src/pages/stories/StoryActions.tsx +++ b/src/pages/stories/StoryActions.tsx @@ -15,6 +15,10 @@ type Props = { handleTogglePin?: (id: number) => void; handleMovePinUp?: (id: number) => void; handleMovePinDown?: (id: number) => void; + canModerate?: boolean; + isPending?: boolean; + handleRejectStory?: (id: number) => void; + handlePublishStory?: (id: number) => void; }; const StoryActions: React.FC = ({ @@ -27,7 +31,11 @@ const StoryActions: React.FC = ({ isPinned = false, handleTogglePin = () => {}, handleMovePinUp = () => {}, - handleMovePinDown = () => {} + handleMovePinDown = () => {}, + canModerate = false, + isPending = false, + handleRejectStory = () => {}, + handlePublishStory = () => {} }) => { return ( @@ -91,6 +99,26 @@ const StoryActions: React.FC = ({ /> )} + {isPending && canModerate && ( + + )} + {isPending && canModerate && ( + + )} ); };