Skip to content

Commit 8cf9328

Browse files
djoseph-apphelixedx-requirements-botFaraz32123viv-helix
authored
feat: new pages for release notes (#6)
* chore: update browserslist DB (#5) Co-authored-by: Faraz32123 <122095701+Faraz32123@users.noreply.github.com> * chore: update browserslist DB (#8) Co-authored-by: Faraz32123 <122095701+Faraz32123@users.noreply.github.com> * feat: new pages for release notes * feat: add release notes feature with environment-based toggle and admin access control * feat: handle API errors in release notes component * fix: patch timezone bug and improve design elements * fix: tinymce rtl issue and insert image * feat: enhance release notes UI and layout * test: add tests to improve coverage * fix: addressed minor issues and suggestions * fix: update UI to align with Figma design * fix: resolve css linting warnings --------- Co-authored-by: edX requirements bot <49161187+edx-requirements-bot@users.noreply.github.com> Co-authored-by: Faraz32123 <122095701+Faraz32123@users.noreply.github.com> Co-authored-by: Vivek Ambaliya <vjagdishbhaiambaliya-apphelix@2u.com>
1 parent 8690cc8 commit 8cf9328

30 files changed

+3647
-17
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
4848
# Fallback in local style files
4949
PARAGON_THEME_URLS={}
5050
COURSE_TEAM_SUPPORT_EMAIL=''
51+
ENABLE_RELEASE_NOTES=false

.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
5151
# Fallback in local style files
5252
PARAGON_THEME_URLS={}
5353
COURSE_TEAM_SUPPORT_EMAIL=''
54+
ENABLE_RELEASE_NOTES=true

.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ ENABLE_GRADING_METHOD_IN_PROBLEMS=false
4242
LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
4343
PARAGON_THEME_URLS=
4444
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
45+
ENABLE_RELEASE_NOTES=false

src/editors/sharedComponents/TinyMceWidget/hooks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export const editorConfig = ({
401401
learningContextId,
402402
staticRootUrl,
403403
enableImageUpload,
404+
showImageButton,
404405
}) => {
405406
const lmsEndpointUrl = getConfig().LMS_BASE_URL;
406407
const studioEndpointUrl = getConfig().STUDIO_BASE_URL;
@@ -413,7 +414,9 @@ export const editorConfig = ({
413414
imageToolbar,
414415
quickbarsInsertToolbar,
415416
quickbarsSelectionToolbar,
416-
} = pluginConfig({ placeholder, editorType, enableImageUpload });
417+
} = pluginConfig({
418+
placeholder, editorType, enableImageUpload, showImageButton,
419+
});
417420
const isLocaleRtl = isRtl(getLocale());
418421
return {
419422
onInit: (_evt, editor) => {

src/editors/sharedComponents/TinyMceWidget/pluginConfig.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { buttons, plugins } from '../../data/constants/tinyMCE';
33

44
const mapToolbars = toolbars => toolbars.map(toolbar => toolbar.join(' ')).join(' | ');
55

6-
const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => {
7-
const image = enableImageUpload ? plugins.image : '';
6+
const pluginConfig = ({
7+
placeholder, editorType, enableImageUpload, showImageButton = false,
8+
}) => {
9+
const image = (enableImageUpload || showImageButton) ? plugins.image : '';
810
const imageTools = enableImageUpload ? plugins.imagetools : '';
11+
const imageButton = showImageButton ? 'image' : '';
912
const imageUploadButton = enableImageUpload ? buttons.imageUploadButton : '';
1013
const editImageSettings = enableImageUpload ? buttons.editImageSettings : '';
1114
const codePlugin = editorType === 'text' ? plugins.code : '';
@@ -55,7 +58,7 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => {
5558
buttons.outdent,
5659
buttons.indent,
5760
],
58-
[imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock],
61+
[imageButton, imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock],
5962
[buttons.table, buttons.emoticons, buttons.charmap, buttons.hr],
6063
[buttons.removeFormat, codeButton, buttons.a11ycheck, buttons.embediframe],
6164
]) : false,

src/release-notes/ReleaseNotes.jsx

Lines changed: 295 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,300 @@
1-
import React from 'react';
1+
import React, { useMemo, useEffect } from 'react';
22
import { StudioFooterSlot } from '@edx/frontend-component-footer';
3-
3+
import {
4+
Add as AddIcon, EditOutline, DeleteOutline, AccessTime as ClockIcon, Info,
5+
} from '@openedx/paragon/icons';
6+
import {
7+
Button,
8+
Layout,
9+
Container,
10+
Icon,
11+
IconButtonWithTooltip,
12+
OverlayTrigger,
13+
Tooltip,
14+
ModalDialog,
15+
Alert,
16+
} from '@openedx/paragon';
17+
import { useIntl } from '@edx/frontend-platform/i18n';
18+
import moment from 'moment';
19+
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
420
import Header from '../header';
21+
import SubHeader from '../generic/sub-header/SubHeader';
22+
import messages from './messages';
23+
import { useReleaseNotes } from './hooks';
24+
import DeleteModal from './delete-modal/DeleteModal';
25+
import ReleaseNoteForm from './update-form/ReleaseNoteForm';
26+
import ReleaseNotesSidebar from './sidebar/ReleaseNotesSidebar';
27+
import { REQUEST_TYPES } from '../course-updates/constants';
28+
import { groupNotesByDate } from './utils/groupNotes';
29+
import unsavedMessages from './update-form/unsaved-modal-messages';
30+
31+
const ReleaseNotes = () => {
32+
const intl = useIntl();
33+
const { administrator } = getAuthenticatedUser() || {};
34+
const isDirtyCheckRef = React.useRef(() => false);
35+
const showUnsavedModalRef = React.useRef(null);
36+
const [isUnsavedModalOpen, setIsUnsavedModalOpen] = React.useState(false);
37+
const {
38+
requestType,
39+
notes,
40+
notesInitialValues,
41+
isFormOpen,
42+
isDeleteModalOpen,
43+
closeForm,
44+
closeDeleteModal,
45+
handleUpdatesSubmit,
46+
handleOpenUpdateForm,
47+
handleDeleteUpdateSubmit,
48+
handleOpenDeleteForm,
49+
errors,
50+
savingStatuses,
51+
} = useReleaseNotes();
52+
53+
const confirmCloseIfDirty = React.useCallback(() => {
54+
const isDirty = isDirtyCheckRef.current();
55+
if (isDirty) {
56+
setIsUnsavedModalOpen(true);
57+
return;
58+
}
59+
closeForm();
60+
}, [closeForm]);
61+
62+
const handleLeaveEditor = () => {
63+
setIsUnsavedModalOpen(false);
64+
closeForm();
65+
};
66+
67+
const getTzName = (date) => {
68+
try {
69+
const { timeZone } = Intl.DateTimeFormat().resolvedOptions();
70+
const parts = new Intl.DateTimeFormat(undefined, { timeZone, timeZoneName: 'short' }).formatToParts(date);
71+
const shortName = (parts.find(p => p.type === 'timeZoneName') || {}).value;
72+
if (shortName && !/^GMT[+-]/i.test(shortName)) {
73+
return shortName;
74+
}
75+
if (timeZone) {
76+
const human = timeZone.split('/').pop().replace(/_/g, ' ');
77+
return `${human} Time`;
78+
}
79+
return '';
80+
} catch (e) {
81+
return '';
82+
}
83+
};
84+
85+
useEffect(() => {
86+
const handleBeforeUnload = (e) => {
87+
if (isFormOpen) {
88+
e.preventDefault();
89+
e.returnValue = '';
90+
}
91+
};
92+
93+
window.addEventListener('beforeunload', handleBeforeUnload);
94+
95+
return () => {
96+
window.removeEventListener('beforeunload', handleBeforeUnload);
97+
};
98+
}, [isFormOpen]);
99+
100+
const groups = useMemo(() => groupNotesByDate(notes, intl), [notes, intl]);
101+
102+
return (
103+
<>
104+
<Header isHiddenMainMenu />
105+
{errors.loadingNotes && (
106+
<Container size="xl" className="px-4 pt-4">
107+
<Alert variant="danger" icon={Info}>
108+
{intl.formatMessage(messages.errorLoadingPage)}
109+
</Alert>
110+
</Container>
111+
)}
112+
<Container size="xl" className="release-notes-page px-4 pt-4">
113+
<SubHeader
114+
title={intl.formatMessage(messages.headingTitle)}
115+
subtitle=""
116+
instruction=""
117+
headerActions={administrator && !errors.loadingNotes ? (
118+
<Button
119+
variant="primary"
120+
iconBefore={AddIcon}
121+
size="sm"
122+
className="new-post-button"
123+
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.add_new_update)}
124+
>
125+
{intl.formatMessage(messages.newPostButton)}
126+
</Button>
127+
) : null}
128+
/>
129+
130+
{!errors.loadingNotes && (
131+
groups.length > 0 ? (
132+
<Layout
133+
lg={[{ span: 9 }, { span: 3 }]}
134+
md={[{ span: 9 }, { span: 3 }]}
135+
xs={[{ span: 12 }, { span: 12 }]}
136+
>
137+
<Layout.Element>
138+
<article>
139+
<section className="release-notes-list pt-5">
140+
{groups.map((g) => (
141+
<div key={g.key} className="mb-4">
142+
{g.items.map((post) => (
143+
<div id={`note-${post.id}`} key={post.id} className="release-note-item mb-4 pb-4">
144+
<div className="d-flex justify-content-between align-items-start">
145+
<div>
146+
<h2 className="mb-4 pb-4">
147+
{post.published_at
148+
? moment(post.published_at).format('MMMM D, YYYY')
149+
: intl.formatMessage({ id: 'release-notes.unscheduled.label', defaultMessage: 'Unscheduled' })}
150+
</h2>
151+
{post.published_at && moment(post.published_at).isAfter(moment()) && (
152+
<OverlayTrigger
153+
placement="right"
154+
overlay={(
155+
<Tooltip className="scheduled-tooltip" id={`scheduled-tooltip-${post.id}`}>
156+
{intl.formatMessage(messages.scheduledTooltip, {
157+
date: `${moment(post.published_at).format('MMMM D, YYYY h:mm A')} ${getTzName(new Date(post.published_at))}`,
158+
})}
159+
</Tooltip>
160+
)}
161+
>
162+
<button
163+
type="button"
164+
className="btn-link d-inline-flex align-items-center text-muted small mr-2 p-0 border-0 text-decoration-none"
165+
aria-label={intl.formatMessage(messages.scheduledTooltip, {
166+
date: `${moment(post.published_at).format('MMMM D, YYYY h:mm A')} ${getTzName(new Date(post.published_at))}`,
167+
})}
168+
>
169+
<Icon
170+
className="mr-2 p-0 justify-content-start scheduled-icon"
171+
src={ClockIcon}
172+
alt={intl.formatMessage(messages.scheduledTooltip, {
173+
date: `${moment(post.published_at).format('MMMM D, YYYY h:mm A')} ${getTzName(new Date(post.published_at))}`,
174+
})}
175+
/>
176+
<span className="post-scheduled mt-0">{intl.formatMessage(messages.scheduledLabel)}</span>
177+
</button>
178+
</OverlayTrigger>
179+
)}
180+
<div className="d-flex align-items-center mb-1 justify-content-between">
181+
<h3 className="m-0">{post.title}</h3>
182+
{administrator && (
183+
<div className="ml-3 d-flex">
184+
<IconButtonWithTooltip
185+
tooltipContent={intl.formatMessage(messages.editButton)}
186+
src={EditOutline}
187+
iconAs={Icon}
188+
className="edit-notes-hover"
189+
onClick={() => handleOpenUpdateForm(REQUEST_TYPES.edit_update, post)}
190+
data-testid="release-note-edit-button"
191+
disabled={isFormOpen}
192+
/>
193+
<IconButtonWithTooltip
194+
tooltipContent={intl.formatMessage(messages.deleteButton)}
195+
src={DeleteOutline}
196+
iconAs={Icon}
197+
className="delete-notes-hover"
198+
onClick={() => handleOpenDeleteForm(post)}
199+
data-testid="release-note-delete-button"
200+
disabled={isFormOpen}
201+
/>
202+
</div>
203+
)}
204+
</div>
205+
{/* eslint-disable-next-line react/no-danger */}
206+
<div className="post-description" dangerouslySetInnerHTML={{ __html: post.description }} />
207+
{post.created_by && (
208+
<div className="mt-3">
209+
<small>
210+
{intl.formatMessage(messages.questionsContact, {
211+
email: post.created_by,
212+
})}
213+
</small>
214+
</div>
215+
)}
216+
</div>
5217

6-
const ReleaseNotes = () => (
7-
<>
8-
<Header isHiddenMainMenu />
9-
<main className="release-notes-page-content">
10-
<h1>Welcome to release-notes!</h1>
11-
</main>
12-
<StudioFooterSlot />
13-
</>
14-
);
218+
</div>
219+
</div>
220+
))}
221+
</div>
222+
))}
223+
</section>
224+
</article>
225+
</Layout.Element>
226+
<Layout.Element>
227+
<ReleaseNotesSidebar notes={notes} />
228+
</Layout.Element>
229+
</Layout>
230+
) : (
231+
<div className="text-center py-5">
232+
<span className="small">{intl.formatMessage(messages.noReleaseNotes)}</span>
233+
</div>
234+
)
235+
)}
236+
</Container>
237+
{isFormOpen && (
238+
<ModalDialog
239+
isOpen={isFormOpen}
240+
onClose={confirmCloseIfDirty}
241+
size="xl"
242+
>
243+
<ModalDialog.Header>
244+
<ModalDialog.Title>
245+
{requestType === REQUEST_TYPES.add_new_update
246+
? intl.formatMessage(messages.newPostButton)
247+
: intl.formatMessage(messages.editButton)}
248+
</ModalDialog.Title>
249+
</ModalDialog.Header>
250+
<ModalDialog.Body>
251+
{(errors.savingNote || errors.creatingNote) && (
252+
<Alert variant="danger" icon={Info} className="mb-3">
253+
{intl.formatMessage(messages.errorSavingPost)}
254+
</Alert>
255+
)}
256+
<ReleaseNoteForm
257+
initialValues={notesInitialValues}
258+
close={closeForm}
259+
onSubmit={handleUpdatesSubmit}
260+
savingStatuses={savingStatuses}
261+
isDirtyCheckRef={isDirtyCheckRef}
262+
showUnsavedModalRef={showUnsavedModalRef}
263+
externalUnsavedModalOpen={isUnsavedModalOpen}
264+
setExternalUnsavedModalOpen={setIsUnsavedModalOpen}
265+
/>
266+
</ModalDialog.Body>
267+
</ModalDialog>
268+
)}
269+
<DeleteModal
270+
isOpen={isDeleteModalOpen}
271+
close={closeDeleteModal}
272+
onDeleteSubmit={handleDeleteUpdateSubmit}
273+
errorDeleting={errors.deletingNote}
274+
/>
275+
{isUnsavedModalOpen && (
276+
<ModalDialog isOpen size="md" onClose={() => setIsUnsavedModalOpen(false)}>
277+
<ModalDialog.Header>
278+
<ModalDialog.Title>
279+
{intl.formatMessage(unsavedMessages.unsavedModalTitle)}
280+
</ModalDialog.Title>
281+
</ModalDialog.Header>
282+
<ModalDialog.Body>
283+
<p>{intl.formatMessage(unsavedMessages.unsavedModalDescription)}</p>
284+
</ModalDialog.Body>
285+
<ModalDialog.Footer>
286+
<Button variant="tertiary" onClick={() => setIsUnsavedModalOpen(false)}>
287+
{intl.formatMessage(unsavedMessages.keepEditingButton)}
288+
</Button>
289+
<Button variant="danger" onClick={handleLeaveEditor}>
290+
{intl.formatMessage(unsavedMessages.leaveEditorButton)}
291+
</Button>
292+
</ModalDialog.Footer>
293+
</ModalDialog>
294+
)}
295+
<StudioFooterSlot />
296+
</>
297+
);
298+
};
15299

16300
export default ReleaseNotes;

0 commit comments

Comments
 (0)