Skip to content

Commit d89b854

Browse files
drag & drop support for posts
1 parent b26eb8f commit d89b854

File tree

7 files changed

+215
-36
lines changed

7 files changed

+215
-36
lines changed

src/components/Groups/GroupPage/Feed.tsx

+158-21
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,133 @@
1-
import * as React from 'react';
1+
import React, { useState, useEffect } from 'react';
2+
import {
3+
closestCenter,
4+
DndContext,
5+
DragOverlay,
6+
KeyboardSensor,
7+
PointerSensor,
8+
useSensor,
9+
useSensors,
10+
} from '@dnd-kit/core';
11+
import {
12+
arrayMove,
13+
SortableContext,
14+
sortableKeyboardCoordinates,
15+
verticalListSortingStrategy,
16+
} from '@dnd-kit/sortable';
17+
import { useSortable } from '@dnd-kit/sortable';
18+
import { CSS } from '@dnd-kit/utilities';
219
import { useActiveGroup } from '../../../hooks/groups/useActiveGroup';
3-
import { sortPostsComparator } from '../../../models/groups/posts';
20+
import { PostData, sortPostsComparator } from '../../../models/groups/posts';
421
import Tabs from '../../Tabs';
522
import FeedItem from './FeedItem';
23+
import { MenuIcon } from '@heroicons/react/solid';
24+
import { GroupData } from '../../../models/groups/groups';
25+
import { useGroupActions } from '../../../hooks/groups/useGroupActions';
626

7-
export default function Feed() {
27+
function SortableItem(props: {
28+
id: string;
29+
group: GroupData;
30+
post: PostData;
31+
isBeingDragged: boolean;
32+
}) {
33+
// probably post was just deleted before items updated
34+
if (!props.post) return null;
35+
36+
const {
37+
attributes,
38+
listeners,
39+
setNodeRef,
40+
transform,
41+
transition,
42+
} = useSortable({ id: props.id });
43+
44+
const style = {
45+
transform: CSS.Transform.toString(transform),
46+
transition,
47+
};
48+
49+
return (
50+
<div ref={setNodeRef} style={style}>
51+
<FeedItem
52+
group={props.group}
53+
post={props.post}
54+
isBeingDragged={props.isBeingDragged}
55+
dragHandle={
56+
<div
57+
className="self-stretch flex items-center px-2"
58+
{...attributes}
59+
{...listeners}
60+
>
61+
<MenuIcon className="h-5 w-5 text-gray-300" />
62+
</div>
63+
}
64+
/>
65+
</div>
66+
);
67+
}
68+
69+
export default function Feed(): JSX.Element {
870
const feedTabs = ['all', 'assignments', 'announcements'];
971
const [currentFeed, setCurrentFeed] = React.useState<string>('all');
1072
const group = useActiveGroup();
73+
const { updatePostOrdering } = useGroupActions();
1174

12-
const feedPosts = group.posts
13-
?.filter(post => {
14-
if (!group.showAdminView && !post.isPublished) return false;
15-
if (currentFeed === 'all') return true;
16-
if (currentFeed === 'assignments') return post.type === 'assignment';
17-
if (currentFeed === 'announcements') return post.type === 'announcement';
18-
throw 'unknown feed ' + this.currentFeed;
75+
const [activeId, setActiveId] = useState(null);
76+
const [items, setItems] = useState([]);
77+
const sensors = useSensors(
78+
useSensor(PointerSensor),
79+
useSensor(KeyboardSensor, {
80+
coordinateGetter: sortableKeyboardCoordinates,
1981
})
20-
.sort(sortPostsComparator)
21-
.reverse();
82+
);
83+
84+
useEffect(() => {
85+
if (!group.groupData || !group.posts) return;
86+
if (group.groupData.postOrdering?.length !== group.posts.length) {
87+
// This shouldn't happen. But maybe this is from an old group
88+
// that was created before post ordering was implemented
89+
90+
// Note: There's a small bug here and in PostProblems.tsx
91+
// where immediately after creating a new post the post ordering and post length will be off...
92+
setItems(
93+
group.posts.filter(post => {
94+
if (!group.showAdminView && !post.isPublished) return false;
95+
if (currentFeed === 'all') return true;
96+
if (currentFeed === 'assignments') return post.type === 'assignment';
97+
if (currentFeed === 'announcements') return post.type === 'announcement';
98+
throw 'unknown feed ' + this.currentFeed;
99+
})
100+
.sort(sortPostsComparator)
101+
.reverse()
102+
.map(x => x.id)
103+
);
104+
} else {
105+
setItems(group.groupData.postOrdering);
106+
}
107+
}, [group.groupData?.postOrdering, group.posts]);
108+
109+
const handleDragStart = (event) => {
110+
const {active} = event;
111+
112+
setActiveId(active.id);
113+
}
114+
115+
const handleDragEnd = (event) => {
116+
const {active, over} = event;
117+
118+
if (active.id !== over.id) {
119+
setItems((items) => {
120+
const oldIndex = items.indexOf(active.id);
121+
const newIndex = items.indexOf(over.id);
122+
123+
const newArr = arrayMove(items, oldIndex, newIndex);
124+
updatePostOrdering(group.activeGroupId, newArr);
125+
return newArr;
126+
});
127+
}
128+
129+
setActiveId(null);
130+
}
22131

23132
return (
24133
<>
@@ -36,15 +145,43 @@ export default function Feed() {
36145
</div>
37146
<div className="mt-4">
38147
{group.isLoading && 'Loading posts...'}
39-
{!group.isLoading && (
40-
<ul className="divide-y divide-solid divide-gray-200 dark:divide-gray-600 sm:divide-none sm:space-y-4">
41-
{feedPosts.map(post => (
42-
<li key={post.id}>
43-
<FeedItem group={group.groupData} post={post} />
44-
</li>
45-
))}
46-
</ul>
47-
)}
148+
{!group.isLoading &&
149+
(group.showAdminView ? (
150+
<DndContext
151+
sensors={sensors}
152+
collisionDetection={closestCenter}
153+
onDragStart={handleDragStart}
154+
onDragEnd={handleDragEnd}
155+
>
156+
<SortableContext
157+
items={items}
158+
strategy={verticalListSortingStrategy}
159+
>
160+
<div className="divide-y divide-solid divide-gray-200 dark:divide-gray-600 sm:divide-none sm:space-y-4">
161+
{items.map(id => <SortableItem key={id} id={id} group={group.groupData} post={group.posts.find(x => x.id === id)} isBeingDragged={activeId === id} />)}
162+
</div>
163+
</SortableContext>
164+
<DragOverlay>
165+
{activeId ? <FeedItem
166+
group={group.groupData}
167+
post={group.posts.find(x => x.id === activeId)}
168+
dragHandle={(
169+
<div
170+
className="self-stretch flex items-center px-2"
171+
>
172+
<MenuIcon className="h-5 w-5 text-gray-300" />
173+
</div>
174+
)}
175+
/> : null}
176+
</DragOverlay>
177+
</DndContext>
178+
) : (
179+
<div className="divide-y divide-solid divide-gray-200 dark:divide-gray-600 sm:divide-none sm:space-y-4">
180+
{items.map(id => (
181+
<FeedItem group={group.groupData} post={group.posts.find(x => x.id === id)} key={id} />
182+
))}
183+
</div>
184+
))}
48185
</div>
49186
</>
50187
);

src/components/Groups/GroupPage/FeedItem.tsx

+19-4
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,16 @@ const AssignmentIcon = ({ pointsEarned, totalPoints }) => {
6666
export default function FeedItem({
6767
group,
6868
post,
69+
dragHandle,
70+
isBeingDragged = false,
6971
}: {
7072
group: GroupData;
7173
post: PostData;
74+
dragHandle?: JSX.Element;
75+
/**
76+
* If true, the feed item will be grayed out to show that it's being dragged
77+
*/
78+
isBeingDragged?: boolean;
7279
}): JSX.Element {
7380
const { showAdminView, groupData } = useActiveGroup();
7481
const { updatePost, deletePost } = usePostActions(group.id);
@@ -89,9 +96,16 @@ export default function FeedItem({
8996

9097
return (
9198
<div
92-
className={`bg-white dark:bg-gray-800 hover:bg-cyan-50 dark:hover:bg-cyan-900 px-4 shadow sm:px-6 sm:rounded-lg transition block`}
99+
className={`${
100+
isBeingDragged
101+
? 'bg-gray-200 dark:bg-gray-900'
102+
: 'bg-white dark:bg-gray-800 hover:bg-cyan-50 dark:hover:bg-cyan-900'
103+
} shadow ${
104+
dragHandle ? 'pr-4 sm:pr-6' : 'px-4 sm:px-6'
105+
} sm:rounded-lg transition flex`}
93106
>
94-
<div className="flex">
107+
{dragHandle}
108+
<div className="flex flex-1">
95109
<Link
96110
to={`/groups/${group.id}/post/${post.id}`}
97111
className="flex flex-1 space-x-4"
@@ -165,7 +179,8 @@ export default function FeedItem({
165179
aria-labelledby="options-menu-0"
166180
>
167181
<div className="py-1">
168-
<button
182+
{/* Pinning is no longer needed now that posts can be reordered easily */}
183+
{/* <button
169184
type="button"
170185
onClick={() =>
171186
updatePost(post.id, { isPinned: !post.isPinned })
@@ -175,7 +190,7 @@ export default function FeedItem({
175190
>
176191
<BookmarkIcon className="mr-3 h-5 w-5 text-gray-400" />
177192
<span>{post.isPinned ? 'Unpin Post' : 'Pin Post'}</span>
178-
</button>
193+
</button> */}
179194
<button
180195
type="button"
181196
onClick={() =>

src/components/Groups/PostPage/PostProblems.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {
1414
sortableKeyboardCoordinates,
1515
verticalListSortingStrategy,
1616
} from '@dnd-kit/sortable';
17+
import { useSortable } from '@dnd-kit/sortable';
18+
import { CSS } from '@dnd-kit/utilities';
1719
import { useActiveGroup } from '../../../hooks/groups/useActiveGroup';
1820
import { useActivePostProblems } from '../../../hooks/groups/useActivePostProblems';
1921
import { usePostActions } from '../../../hooks/groups/usePostActions';
2022
import { PostData } from '../../../models/groups/posts';
2123
import ProblemListItem from '../ProblemListItem';
22-
import { useSortable } from '@dnd-kit/sortable';
23-
import { CSS } from '@dnd-kit/utilities';
2424
import { GroupData } from '../../../models/groups/groups';
2525
import { ProblemData } from '../../../models/groups/problem';
2626
import { MenuIcon } from '@heroicons/react/solid';
@@ -31,6 +31,8 @@ function SortableItem(props: {
3131
post: PostData;
3232
problem: ProblemData;
3333
}) {
34+
if (!props.problem) return null;
35+
3436
const {
3537
attributes,
3638
listeners,

src/hooks/groups/useGroupActions.ts

+12
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function useGroupActions() {
4343
adminIds: [],
4444
memberIds: [],
4545
leaderboard: {},
46+
postOrdering: [],
4647
};
4748
const groupDoc = doc(collection(getFirestore(firebaseApp), 'groups'));
4849
const group: GroupData = {
@@ -161,6 +162,17 @@ export function useGroupActions() {
161162
data
162163
);
163164
},
165+
updatePostOrdering: async (
166+
groupId: string,
167+
ordering: string[]
168+
) => {
169+
await updateDoc(
170+
doc(getFirestore(firebaseApp), 'groups', groupId),
171+
{
172+
postOrdering: ordering
173+
}
174+
);
175+
},
164176
removeMemberFromGroup: async (
165177
groupId: string,
166178
targetUid: string

src/hooks/groups/usePostActions.ts

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import {
22
addDoc,
3+
arrayRemove,
4+
arrayUnion,
35
collection,
46
deleteField,
57
doc,
68
getFirestore,
79
serverTimestamp,
810
updateDoc,
911
writeBatch,
10-
arrayUnion,
1112
} from 'firebase/firestore';
1213
import { useContext } from 'react';
1314
import UserDataContext from '../../context/UserDataContext/UserDataContext';
@@ -21,9 +22,8 @@ import { useFirebaseApp } from '../useFirebase';
2122

2223
export function usePostActions(groupId: string) {
2324
const firebaseApp = useFirebaseApp();
24-
const { firebaseUser, setUserProgressOnProblems } = useContext(
25-
UserDataContext
26-
);
25+
const { firebaseUser, setUserProgressOnProblems } =
26+
useContext(UserDataContext);
2727

2828
const updatePost = async (postId: string, updatedData: Partial<PostData>) => {
2929
await updateDoc(
@@ -49,11 +49,17 @@ export function usePostActions(groupId: string) {
4949
dueTimestamp: null,
5050
}),
5151
};
52-
const doc = await addDoc(
53-
collection(getFirestore(firebaseApp), 'groups', groupId, 'posts'),
54-
{ ...defaultPost, timestamp: serverTimestamp() }
52+
const firestore = getFirestore(firebaseApp);
53+
const batch = writeBatch(firestore);
54+
const docRef = doc(
55+
collection(getFirestore(firebaseApp), 'groups', groupId, 'posts')
5556
);
56-
return doc.id;
57+
batch.set(docRef, { ...defaultPost, timestamp: serverTimestamp() });
58+
batch.update(doc(firestore, 'groups', groupId), {
59+
postOrdering: arrayUnion(docRef.id),
60+
});
61+
await batch.commit();
62+
return docRef.id;
5763
},
5864
deletePost: async (postId: string): Promise<void> => {
5965
const firestore = getFirestore(firebaseApp);
@@ -64,6 +70,7 @@ export function usePostActions(groupId: string) {
6470
});
6571
batch.update(doc(firestore, 'groups', groupId), {
6672
[`leaderboard.${postId}`]: deleteField(),
73+
"postOrdering": arrayRemove(postId),
6774
});
6875
return batch.commit();
6976
},
@@ -101,7 +108,7 @@ export function usePostActions(groupId: string) {
101108
doc(getFirestore(firebaseApp), 'groups', groupId, 'posts', post.id),
102109
{
103110
[`pointsPerProblem.${docRef.id}`]: defaultProblem.points,
104-
[`problemOrdering`]: arrayUnion(post.id),
111+
[`problemOrdering`]: arrayUnion(docRef.id),
105112
}
106113
);
107114
await batch.commit();
@@ -160,6 +167,7 @@ export function usePostActions(groupId: string) {
160167
});
161168
batch.update(doc(firestore, 'groups', groupId, 'posts', post.id), {
162169
[`pointsPerProblem.${problemId}`]: deleteField(),
170+
"problemOrdering": arrayRemove(problemId)
163171
});
164172
await batch.commit();
165173
},

src/models/groups/groups.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type GroupData = {
99
adminIds: string[];
1010
memberIds: string[];
1111
leaderboard: Leaderboard;
12+
postOrdering: string[];
1213
};
1314

1415
export enum GroupPermission {

src/models/groups/posts.ts

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export type PostData = {
1616
* Markdown string of the post content
1717
*/
1818
body: string;
19+
/**
20+
* no longer needed since posts can be more easily reordered (?)
21+
* @deprecated
22+
*/
1923
isPinned: boolean;
2024
isPublished: boolean;
2125
isDeleted: boolean;

0 commit comments

Comments
 (0)