Skip to content

Commit de5217a

Browse files
committed
feat: smart composer edit mode
1 parent 56c384a commit de5217a

7 files changed

Lines changed: 140 additions & 27 deletions

File tree

packages/shared/src/components/modals/post/SmartComposerModal.spec.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { render, screen } from '@testing-library/react';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
34
import { useAuthContext } from '../../../contexts/AuthContext';
45
import { useLogContext } from '../../../contexts/LogContext';
56
import { useViewSize, ViewSize } from '../../../hooks';
@@ -71,6 +72,15 @@ jest.mock('../common/Modal', () => ({
7172
Modal: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
7273
}));
7374

75+
const renderWithClient = (ui: React.ReactElement) => {
76+
const client = new QueryClient({
77+
defaultOptions: { queries: { retry: false } },
78+
});
79+
return render(
80+
<QueryClientProvider client={client}>{ui}</QueryClientProvider>,
81+
);
82+
};
83+
7484
describe('SmartComposerModal', () => {
7585
const logEvent = jest.fn();
7686
const showPrompt = jest.fn();
@@ -123,7 +133,7 @@ describe('SmartComposerModal', () => {
123133
});
124134

125135
it('keeps the production notification CTA visible in the composer', () => {
126-
render(
136+
renderWithClient(
127137
<SmartComposerModal
128138
isOpen
129139
initialUrl="https://daily.dev"
@@ -139,7 +149,7 @@ describe('SmartComposerModal', () => {
139149
});
140150

141151
it('runs notification submit handling before closing after a successful post', async () => {
142-
render(
152+
renderWithClient(
143153
<SmartComposerModal
144154
isOpen
145155
initialUrl="https://daily.dev"

packages/shared/src/components/modals/post/SmartComposerModal.tsx

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, {
77
useState,
88
} from 'react';
99
import classNames from 'classnames';
10+
import { useQueryClient } from '@tanstack/react-query';
1011
import type { LazyModalCommonProps } from '../common/Modal';
1112
import { Modal } from '../common/Modal';
1213
import { ModalKind, ModalSize } from '../common/types';
@@ -30,7 +31,8 @@ import { useLogContext } from '../../../contexts/LogContext';
3031
import { LogEvent } from '../../../lib/log';
3132
import { useViewSize, ViewSize } from '../../../hooks';
3233
import { usePrompt } from '../../../hooks/usePrompt';
33-
import type { ExternalLinkPreview } from '../../../graphql/posts';
34+
import type { ExternalLinkPreview, Post } from '../../../graphql/posts';
35+
import { getPostByIdKey } from '../../../lib/query';
3436
import { AudienceChip } from '../../post/composer/AudienceChip';
3537
import { KindModePicker } from '../../post/composer/KindModePicker';
3638
import {
@@ -64,36 +66,66 @@ export interface SmartComposerModalProps extends LazyModalCommonProps {
6466
initialUrl?: string;
6567
initialSquadHandle?: string;
6668
preview?: ExternalLinkPreview;
69+
editPost?: Post;
6770
}
6871

6972
export function SmartComposerModal({
7073
onRequestClose,
7174
initialUrl,
7275
initialSquadHandle,
7376
preview: initialPreview,
77+
editPost,
7478
...props
7579
}: SmartComposerModalProps): ReactElement {
7680
const { user } = useAuthContext();
7781
const { logEvent } = useLogContext();
7882
const isLaptop = useViewSize(ViewSize.Laptop);
83+
const queryClient = useQueryClient();
7984
const { showPrompt } = usePrompt();
8085
const { shouldShowCta, isEnabled, onToggle, onSubmitted } =
8186
useNotificationToggle();
8287
const isStandupEnabled = useStandupCreation();
83-
const [kind, setKind] = useState<ComposerKind>(initialUrl ? 'link' : 'text');
84-
const [text, setText] = useState<TextFormState>(DEFAULT_TEXT);
88+
const isEditing = !!editPost;
89+
const [kind, setKind] = useState<ComposerKind>(() => {
90+
if (isEditing) {
91+
return 'text';
92+
}
93+
return initialUrl ? 'link' : 'text';
94+
});
95+
const [text, setText] = useState<TextFormState>(() =>
96+
editPost
97+
? { title: editPost.title ?? '', body: editPost.content ?? '' }
98+
: DEFAULT_TEXT,
99+
);
85100
const [link, setLink] = useState<LinkFormState>({
86101
...DEFAULT_LINK,
87102
url: initialUrl ?? '',
88103
});
89104
const [poll, setPoll] = useState<PollFormState>(DEFAULT_POLL);
90105
const [standup, setStandup] = useState<StandupFormState>(DEFAULT_STANDUP);
91-
const [cover, setCover] = useState<TextFormCover | null>(null);
106+
const [cover, setCover] = useState<TextFormCover | null>(() =>
107+
editPost?.image ? { preview: editPost.image } : null,
108+
);
92109
const [isExpanded, setIsExpanded] = useState(false);
93110
const [isMarkdownMode, setIsMarkdownMode] = useState(false);
94111
const textFormRef = useRef<TextFormHandle>(null);
95112

96113
const isDirty = useMemo(() => {
114+
if (editPost) {
115+
if (text.title !== (editPost.title ?? '')) {
116+
return true;
117+
}
118+
if (text.body !== (editPost.content ?? '')) {
119+
return true;
120+
}
121+
if (cover?.file) {
122+
return true;
123+
}
124+
if (!cover && editPost.image) {
125+
return true;
126+
}
127+
return false;
128+
}
97129
if (cover) {
98130
return true;
99131
}
@@ -110,7 +142,7 @@ export function SmartComposerModal({
110142
return true;
111143
}
112144
return false;
113-
}, [cover, text, link, poll, standup]);
145+
}, [cover, text, link, poll, standup, editPost]);
114146

115147
const handleClose = useCallback(
116148
async (event?: React.MouseEvent | React.KeyboardEvent) => {
@@ -126,7 +158,7 @@ export function SmartComposerModal({
126158
return;
127159
}
128160
const confirmed = await showPrompt({
129-
title: 'Discard draft?',
161+
title: isEditing ? 'Discard changes?' : 'Discard draft?',
130162
description:
131163
'You have unsaved changes. Are you sure you want to discard them?',
132164
okButton: {
@@ -140,7 +172,7 @@ export function SmartComposerModal({
140172
closeAndLog();
141173
}
142174
},
143-
[isDirty, kind, logEvent, onRequestClose, showPrompt],
175+
[isDirty, kind, logEvent, onRequestClose, showPrompt, isEditing],
144176
);
145177

146178
useEffect(() => {
@@ -206,7 +238,7 @@ export function SmartComposerModal({
206238
);
207239

208240
const { audiences, selectedIds, selected, setSelectedIds, userAudienceId } =
209-
useComposerAudience(initialSquadHandle);
241+
useComposerAudience(initialSquadHandle, editPost?.source?.id);
210242
const primary = selected[0];
211243
const isMulti = selected.length > 1;
212244

@@ -229,7 +261,13 @@ export function SmartComposerModal({
229261
selectedIds,
230262
isMulti,
231263
initialPreview,
264+
editPostId: editPost?.id,
232265
onComplete: () => {
266+
if (editPost?.id) {
267+
queryClient.invalidateQueries({
268+
queryKey: getPostByIdKey(editPost.id),
269+
});
270+
}
233271
onSubmitted();
234272
onRequestClose?.();
235273
},
@@ -241,15 +279,17 @@ export function SmartComposerModal({
241279
selected.filter((audience) => !isUserAudience(audience)).length > 1;
242280
const isStandupScheduled = standup.scheduleChoice === 'later';
243281
let submitLabel: string;
244-
if (isStandup) {
282+
if (isEditing) {
283+
submitLabel = 'Save changes';
284+
} else if (isStandup) {
245285
submitLabel = isStandupScheduled ? 'Schedule standup' : 'Create standup';
246286
} else if (kind === 'poll') {
247287
submitLabel = 'Post poll';
248288
} else {
249289
submitLabel = 'Post';
250290
}
251291

252-
const kindPickerNode = (
292+
const kindPickerNode = isEditing ? null : (
253293
<KindModePicker
254294
value={kind}
255295
onChange={onKindChange}
@@ -276,7 +316,7 @@ export function SmartComposerModal({
276316
type="submit"
277317
variant={ButtonVariant.Primary}
278318
size={ButtonSize.Small}
279-
disabled={isSubmitDisabled || isCoverUploading}
319+
disabled={isSubmitDisabled || isCoverUploading || (isEditing && !isDirty)}
280320
loading={isInFlight || isCoverUploading}
281321
className="ml-2 px-5"
282322
>
@@ -322,7 +362,7 @@ export function SmartComposerModal({
322362
selectedIds={selectedIds}
323363
onChange={setSelectedIds}
324364
userAudienceId={userAudienceId}
325-
disabled={isInFlight}
365+
disabled={isInFlight || isEditing}
326366
/>
327367
)}
328368
</div>

packages/shared/src/components/post/composer/TextForm.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { TITLE_MAX_LENGTH, type TextFormState } from './types';
2525

2626
export interface TextFormCover {
2727
preview: string;
28-
file: File;
28+
file?: File;
2929
isUploading?: boolean;
3030
}
3131

@@ -154,12 +154,12 @@ export const TextForm = forwardRef<TextFormHandle, TextFormProps>(
154154
className="w-full resize-none overflow-hidden break-words bg-transparent font-bold leading-tight text-text-primary outline-none typo-title2 placeholder:text-text-quaternary"
155155
/>
156156
{cover ? (
157-
<div className="group relative">
157+
<div className="group relative w-fit max-w-full">
158158
<img
159159
src={cover.preview}
160160
alt="Post cover"
161161
className={classNames(
162-
'block h-44 w-full rounded-16 object-cover transition-opacity',
162+
'block h-auto max-h-44 w-auto max-w-full rounded-16 object-contain transition-opacity',
163163
'group-hover:brightness-95',
164164
cover.isUploading && 'opacity-50',
165165
)}

packages/shared/src/components/post/composer/useComposerAudience.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface UseComposerAudience {
2121

2222
export const useComposerAudience = (
2323
initialSquadHandle?: string,
24+
initialSquadId?: string,
2425
): UseComposerAudience => {
2526
const { user, squads } = useAuthContext();
2627

@@ -42,6 +43,14 @@ export const useComposerAudience = (
4243
);
4344

4445
const defaultId = useMemo(() => {
46+
if (initialSquadId) {
47+
const match = audiences.find(
48+
(audience) => audience.id === initialSquadId,
49+
);
50+
if (match?.id) {
51+
return match.id;
52+
}
53+
}
4554
if (initialSquadHandle) {
4655
const match = audiences.find(
4756
(audience) => audience.handle === initialSquadHandle,
@@ -51,7 +60,7 @@ export const useComposerAudience = (
5160
}
5261
}
5362
return userAudienceId;
54-
}, [audiences, initialSquadHandle, userAudienceId]);
63+
}, [audiences, initialSquadHandle, initialSquadId, userAudienceId]);
5564

5665
const [selectedIds, setSelectedIds] = useState<string[]>(() =>
5766
defaultId ? [defaultId] : [],

packages/shared/src/components/post/composer/useComposerSubmit.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ interface UseComposerSubmitProps {
6969
isMulti: boolean;
7070
initialPreview?: ExternalLinkPreview;
7171
onComplete: () => void;
72+
editPostId?: string;
7273
}
7374

7475
interface UseComposerSubmit {
@@ -93,6 +94,7 @@ export const useComposerSubmit = ({
9394
isMulti,
9495
initialPreview,
9596
onComplete,
97+
editPostId,
9698
}: UseComposerSubmitProps): UseComposerSubmit => {
9799
const { displayToast } = useToastNotification();
98100
const router = useRouter();
@@ -106,6 +108,7 @@ export const useComposerSubmit = ({
106108
isPosting,
107109
onSubmitPost,
108110
onSubmitFreeformPost,
111+
onEditFreeformPost,
109112
onSubmitPollPost,
110113
} = usePostToSquad({
111114
initialPreview,
@@ -157,6 +160,18 @@ export const useComposerSubmit = ({
157160
content: text.body,
158161
...(cover?.file ? { image: cover.file } : {}),
159162
};
163+
if (editPostId) {
164+
await onEditFreeformPost(
165+
{ ...payload, id: editPostId },
166+
primary as Squad,
167+
);
168+
displayToast(
169+
moderationRequired(primary as Squad)
170+
? '✅ Your edit has been submitted for moderation'
171+
: '✅ Your post has been updated!',
172+
);
173+
return;
174+
}
160175
if (isMulti) {
161176
await createMulti({
162177
sourceIds: selectedIds,
@@ -265,6 +280,7 @@ export const useComposerSubmit = ({
265280
cover,
266281
selectedIds,
267282
isInFlight,
283+
editPostId,
268284
],
269285
);
270286

packages/shared/src/components/squads/SquadPageHeader.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '../../lib/config';
2222
import { useViewSize, ViewSize } from '../../hooks';
2323
import { useLazyModal } from '../../hooks/useLazyModal';
24+
import { useSmartComposer } from '../../hooks/post/useSmartComposer';
2425
import { LazyModal } from '../modals/common/types';
2526
import { SquadStat } from './common/SquadStat';
2627
import { SquadPrivacyState } from './common/SquadPrivacyState';
@@ -48,7 +49,11 @@ export function SquadPageHeader({
4849
shouldUseListMode,
4950
}: SquadPageHeaderProps): ReactElement {
5051
const { openModal } = useLazyModal();
52+
const isSmartComposerEnabled = useSmartComposer();
53+
const isLaptop = useViewSize(ViewSize.Laptop);
5154
const allowedToPost = verifyPermission(squad, SourcePermissions.Post);
55+
const shouldUseSmartComposer =
56+
isSmartComposerEnabled && isLaptop && allowedToPost;
5257
const { category } = squad;
5358
const squadId = squad.id ?? '';
5459
const isSquadMember = !!squad.currentMember;
@@ -245,15 +250,32 @@ export function SquadPageHeader({
245250
<span className="absolute -left-6 flex h-px w-[calc(100%+3rem)] bg-border-subtlest-tertiary tablet:hidden" />
246251
<span className="z-0 bg-background-default px-4">or</span>
247252
</FlexCentered>
248-
<Button
249-
tag="a"
250-
href={`${link.post.create}?sid=${squad.handle}`}
251-
variant={ButtonVariant.Primary}
252-
color={ButtonColor.Cabbage}
253-
className="w-full tablet:w-auto"
254-
>
255-
New post
256-
</Button>
253+
{shouldUseSmartComposer ? (
254+
<Button
255+
type="button"
256+
onClick={() =>
257+
openModal({
258+
type: LazyModal.SmartComposer,
259+
props: { initialSquadHandle: squad.handle },
260+
})
261+
}
262+
variant={ButtonVariant.Primary}
263+
color={ButtonColor.Cabbage}
264+
className="w-full tablet:w-auto"
265+
>
266+
New post
267+
</Button>
268+
) : (
269+
<Button
270+
tag="a"
271+
href={`${link.post.create}?sid=${squad.handle}`}
272+
variant={ButtonVariant.Primary}
273+
color={ButtonColor.Cabbage}
274+
className="w-full tablet:w-auto"
275+
>
276+
New post
277+
</Button>
278+
)}
257279
<Divider />
258280
</>
259281
)}

0 commit comments

Comments
 (0)