Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import ControlledSwitch from '@dailydotdev/shared/src/components/fields/Controll

```bash
# Setup
nvm use # Use correct Node version from .nvmrc
npm i -g pnpm@9.14.4
pnpm install

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export * from './Trash';
export * from './Trending';
export * from './Twitter';
export * from './Unread';
export * from './Upload';
export * from './Upvote';
export * from './User';
export * from './UserShare';
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ const OpportunityEditRecruiterModal = dynamic(() =>
).then((mod) => mod.OpportunityEditRecruiterModal),
);

const OpportunityReimportModal = dynamic(() =>
import(
/* webpackChunkName: "opportunityReimportModal" */ '../opportunity/OpportunityEditModal/OpportunityReimportModal'
).then((mod) => mod.OpportunityReimportModal),
);

const DirtyFormModal = dynamic(
() => import(/* webpackChunkName: "dirtyFormModal" */ './DirtyFormModal'),
);
Expand Down Expand Up @@ -456,6 +462,7 @@ export const modals = {
[LazyModal.SquadNotificationSettings]: SquadNotificationSettingsModal,
[LazyModal.OpportunityEdit]: OpportunityEditModal,
[LazyModal.OpportunityEditRecruiter]: OpportunityEditRecruiterModal,
[LazyModal.OpportunityReimport]: OpportunityReimportModal,
[LazyModal.DirtyForm]: DirtyFormModal,
[LazyModal.JobOpportunity]: JobOpportunityModal,
[LazyModal.RecruiterIntro]: RecruiterIntroModal,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/components/modals/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export enum LazyModal {
SquadNotificationSettings = 'squadNotificationSettings',
OpportunityEdit = 'opportunityEdit',
OpportunityEditRecruiter = 'opportunityEditRecruiter',
OpportunityReimport = 'opportunityReimport',
JobOpportunity = 'jobOpportunity',
RecruiterIntro = 'recruiterIntro',
RecruiterTrust = 'recruiterTrust',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import type { ReactElement } from 'react';
import React, { useCallback, useRef, useEffect } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type z from 'zod';
import classNames from 'classnames';
import { opportunityByIdOptions } from '../../../features/opportunity/queries';
import { editOpportunityContentMutationOptions } from '../../../features/opportunity/mutations';
import { ApiError } from '../../../graphql/common';
import { useUpdateQuery } from '../../../hooks/useUpdateQuery';
import { useToastNotification } from '../../../hooks';
import { opportunityEditContentSchema } from '../../../lib/schema/opportunity';
import type { MarkdownRef } from '../../fields/MarkdownInput';
import MarkdownInput from '../../fields/MarkdownInput';
import { applyZodErrorsToForm } from '../../../lib/form';
import { labels } from '../../../lib';
import { InlineEditor } from './InlineEditor';
import type { ContentSection } from '../../../features/opportunity/types';

export interface InlineContentEditorProps {
opportunityId: string;
section: ContentSection;
title: string;
isRequired?: boolean;
}

export const InlineContentEditor = ({
opportunityId,
section,
title,
isRequired = false,
}: InlineContentEditorProps): ReactElement => {
const { displayToast } = useToastNotification();
const markdownRef = useRef<MarkdownRef>();

const { data: opportunity, promise } = useQuery({
...opportunityByIdOptions({ id: opportunityId }),
experimental_prefetchInRender: true,
});

const [, updateOpportunity] = useUpdateQuery(
opportunityByIdOptions({ id: opportunityId }),
);

const { mutateAsync, isPending } = useMutation({
...editOpportunityContentMutationOptions(),
onSuccess: (result) => {
updateOpportunity(result);
displayToast(`${title} saved`);
},
});

const {
control,
handleSubmit,
formState: { isDirty, errors },
setError,
setValue,
reset,
} = useForm({
resolver: zodResolver(
opportunityEditContentSchema.extend({
content: opportunityEditContentSchema.shape.content.pick({
[section]: true,
}),
}),
),
defaultValues: async () => {
const opportunityData = await promise;
return {
content: {
[section]: {
content: opportunityData?.content?.[section]?.content || '',
},
},
};
},
});

// Reset form when opportunity data changes
useEffect(() => {
if (opportunity) {
const serverContent = opportunity.content?.[section]?.content || '';
reset({
content: {
[section]: {
content: serverContent,
},
},
});
// Also update MarkdownInput's internal state
markdownRef.current?.setInput(serverContent);
}
}, [opportunity, section, reset]);

const onSubmit = handleSubmit(async (data) => {
try {
await mutateAsync({
id: opportunityId,
payload: data as z.infer<typeof opportunityEditContentSchema>,
});
} catch (originalError) {
if (
originalError.response?.errors?.[0]?.extensions?.code ===
ApiError.ZodValidationError
) {
applyZodErrorsToForm({
error: originalError,
setError,
});
} else {
displayToast(
originalError.response?.errors?.[0]?.message || labels.error.generic,
);
}
throw originalError;
}
});

const handleDiscard = useCallback(() => {
if (opportunity) {
reset({
content: {
[section]: {
content: opportunity.content?.[section]?.content || '',
},
},
});
markdownRef.current?.setInput(
opportunity.content?.[section]?.content || '',
);
}
}, [opportunity, section, reset]);

const handleRemoveSection = useCallback(async () => {
markdownRef.current?.setInput('');
setValue(`content.${section}.content`, '');
await onSubmit();
}, [setValue, section, onSubmit]);

const hasContent = !!opportunity?.content?.[section]?.html;
const contentPreview = hasContent
? 'Click to edit'
: 'No content yet - click to add';

return (
<InlineEditor
title={title}
description={contentPreview}
isRequired={isRequired}
isComplete={!isRequired || hasContent}
isDirty={isDirty}
isSubmitting={isPending}
onSave={onSubmit}
onDiscard={handleDiscard}
onRemove={hasContent ? handleRemoveSection : undefined}
defaultExpanded={hasContent}
>
<Controller
control={control}
name={`content.${section}.content`}
render={({ field }) => {
const hint = errors.content?.[section]?.content?.message;
const valid = !errors.content?.[section]?.content;

return (
<div className="flex flex-col gap-2">
<MarkdownInput
ref={markdownRef}
allowPreview={false}
textareaProps={{
name: field.name,
rows: 8,
maxLength: 2000,
placeholder:
labels.opportunity.contentFields.placeholders[section] ||
labels.opportunity.contentFields.placeholders.generic,
}}
className={{
container: 'flex-1',
}}
initialContent={field.value}
enabledCommand={{
upload: false,
link: false,
mention: false,
}}
showMarkdownGuide={false}
onValueUpdate={(value) => {
field.onChange(value);
}}
/>
{!!hint && (
<div
role={!valid ? 'alert' : undefined}
className={classNames(
'flex items-center typo-caption1',
!valid ? 'text-status-error' : 'text-text-quaternary',
)}
>
{hint}
</div>
)}
</div>
);
}}
/>
</InlineEditor>
);
};

export default InlineContentEditor;
Loading