Files
frontend-app-discussions/src/discussions/utils.js
sundasnoreen12 db883ca7cd feat: added draft functionality for comment and responses (#727)
* feat: added draft functionality for comment and responses

* fix: fixed comment update issue:

* test: added draft test case

* test: added mock conditions for tinymce

* refactor: refactor code

* test: added test cases

* refactor: refactor hook file

* refactor: fixed review issues

* refactor: memoize function

* refactor: refactor code

* test: added update comment test case

* refactor: refactor remove hook method

* test: fixed test cases issue
2024-07-24 17:24:23 +05:00

326 lines
8.5 KiB
JavaScript

import { useCallback, useContext, useMemo } from 'react';
import {
CheckCircle, CheckCircleOutline, Delete, Edit, InsertLink,
Institution, Lock, LockOpen, Pin, Report, School,
Verified, VerifiedOutline,
} from '@openedx/paragon/icons';
import { getIn } from 'formik';
import { uniqBy } from 'lodash';
import { useSelector } from 'react-redux';
import {
generatePath, matchPath, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { DENIED, LOADED } from '../components/NavigationBar/data/slice';
import {
ContentActions, Routes, ThreadType,
} from '../data/constants';
import { ContentSelectors } from './data/constants';
import PostCommentsContext from './post-comments/postCommentsContext';
import messages from './messages';
/**
* Get HTTP Error status from generic error.
* @param error Generic caught error.
* @returns {number|null}
*/
export const getHttpErrorStatus = error => error?.customAttributes?.httpErrorStatus ?? error?.response?.status;
/**
* Return true if a field has been modified and is no longer valid
* @param {string} field Name of field
* @param {{}} errors formik error object
* @param {{}} touched formik touched object
* @returns {boolean}
*/
export function isFormikFieldInvalid(field, {
errors,
touched,
}) {
return Boolean(getIn(touched, field) && getIn(errors, field));
}
/**
* Hook to return the path for the current comments page
* @returns {string}
*/
export function useCommentsPagePath() {
const location = useLocation();
const { params: { page } } = matchPath({ path: Routes.COMMENTS.PAGE }, location.pathname);
return Routes.COMMENTS.PAGES[page];
}
/**
* Check if the provided comment or post supports the provided option.
* @param {{editableFields:[string]}} content
* @param {ContentActions} action
* @returns {boolean}
*/
export function checkPermissions(content, action) {
if (content.editableFields.includes(action)) {
return true;
}
// For delete action we check `content.canDelete`
if (action === ContentActions.DELETE) {
return true;
}
return false;
}
/**
* List of all possible actions for comments or posts.
*
* * `id` is a unique id for each action.
* * `action` is the action being performed. One action can
* have multiple mutually-exclusive entries (such as close/open)..
* * `icon` is the icon component to show for the action.
* * `label` is the translatable label message that can be passed to intl.
* * `conditions` is the an object where the key and value represent the key and value that should match
* in the content/post.
* e.g. for {pinned:false} the action will show up if the content/post has post.pinned==false
*/
export const ACTIONS_LIST = [
{
id: 'copy-link',
action: ContentActions.COPY_LINK,
icon: InsertLink,
label: messages.copyLink,
},
{
id: 'edit',
action: ContentActions.EDIT_CONTENT,
icon: Edit,
label: messages.editAction,
},
{
id: 'pin',
action: ContentActions.PIN,
icon: Pin,
label: messages.pinAction,
conditions: { pinned: false },
},
{
id: 'unpin',
action: ContentActions.PIN,
icon: Pin,
label: messages.unpinAction,
conditions: { pinned: true },
},
{
id: 'endorse',
action: ContentActions.ENDORSE,
icon: VerifiedOutline,
label: messages.endorseAction,
conditions: {
endorsed: false,
postType: ThreadType.DISCUSSION,
},
},
{
id: 'unendorse',
action: ContentActions.ENDORSE,
icon: Verified,
label: messages.unendorseAction,
conditions: {
endorsed: true,
postType: ThreadType.DISCUSSION,
},
},
{
id: 'answer',
action: ContentActions.ENDORSE,
icon: CheckCircleOutline,
label: messages.markAnsweredAction,
conditions: {
endorsed: false,
postType: ThreadType.QUESTION,
},
},
{
id: 'unanswer',
action: ContentActions.ENDORSE,
icon: CheckCircle,
label: messages.unmarkAnsweredAction,
conditions: {
endorsed: true,
postType: ThreadType.QUESTION,
},
},
{
id: 'close',
action: ContentActions.CLOSE,
icon: Lock,
label: messages.closeAction,
conditions: { closed: false },
},
{
id: 'reopen',
action: ContentActions.CLOSE,
icon: LockOpen,
label: messages.reopenAction,
conditions: { closed: true },
},
{
id: 'report',
action: ContentActions.REPORT,
icon: Report,
label: messages.reportAction,
conditions: { abuseFlagged: false },
},
{
id: 'unreport',
action: ContentActions.REPORT,
icon: Report,
label: messages.unreportAction,
conditions: { abuseFlagged: true },
},
{
id: 'delete',
action: ContentActions.DELETE,
icon: Delete,
label: messages.deleteAction,
conditions: { canDelete: true },
},
];
export function useActions(contentType, id) {
const { postType } = useContext(PostCommentsContext);
const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
const checkConditions = useCallback((item, conditions) => (
conditions
? Object.keys(conditions)
.map(key => item[key] === conditions[key])
.every(condition => condition === true)
: true
), []);
const actions = useMemo(() => ACTIONS_LIST.filter(
({
action,
conditions = null,
}) => checkPermissions(content, action) && checkConditions(content, conditions),
), [content]);
return actions;
}
export const formikCompatibleHandler = (formikHandler, name) => (value) => formikHandler({
target: {
name,
value,
},
});
/**
* A wrapper for the generatePath function that generates a new path that keeps the existing
* query parameters intact
* @param path
* @param params
* @return {function(*): *&{pathname: *}}
*/
export const discussionsPath = (path, params) => {
const pathname = generatePath(path, params);
return (location) => ({ ...location, pathname });
};
/**
* Helper function to make a postMessage call
* @param {string} type message type
* @param {object} payload data to send in message
*/
export function postMessageToParent(type, payload = {}) {
if (window.parent !== window) {
const messageTargets = [
getConfig().LEARNING_BASE_URL,
getConfig().LMS_BASE_URL,
];
messageTargets.forEach(target => {
window.parent.postMessage(
{
type,
payload,
},
target,
);
});
}
}
export const isPostPreviewAvailable = (htmlNode) => {
const containsImage = htmlNode.match(/(<img((?:\\.|.)*)>)/);
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)/)
|| htmlNode.match(/(\[mathjax](.+?))+/)
|| htmlNode.match(/(\[mathjaxinline](.+?))+/)
|| htmlNode.match(/(\\\[(.+?))+/)
|| htmlNode.match(/(\\\((.+?))+/);
if (containsImage || isLatex || htmlNode === '') {
return false;
}
return true;
};
/**
* Helper function to filter posts
* @param {array} posts arrays of posts
* @param {string} filterBy name of post object attribute. un will use for reverse
* condition. like pinned attribute for pinned post and unpinned for non pinned posts.
*/
export const filterPosts = (posts, filterBy) => uniqBy(posts, 'id').filter(
post => (filterBy.startsWith('un') ? !post[filterBy.slice(2)] : post[filterBy]),
);
export function handleKeyDown(event) {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
}
export function isLastElementOfList(list, element) {
return list[list.length - 1] === element;
}
export function truncatePath(path) {
return path.substring(0, path.lastIndexOf('/'));
}
export function getAuthorLabel(intl, authorLabel) {
const authorLabelMappings = {
Staff: {
icon: Institution,
authorLabelMessage: intl.formatMessage(messages.authorLabelStaff),
},
Moderator: {
icon: School,
authorLabelMessage: intl.formatMessage(messages.authorLabelModerator),
},
'Community TA': {
icon: School,
authorLabelMessage: intl.formatMessage(messages.authorLabelTA),
},
};
return authorLabelMappings[authorLabel] || {};
}
export const isCourseStatusValid = (courseStatus) => [DENIED, LOADED].includes(courseStatus);
export const extractContent = (content) => {
if (typeof content === 'object') {
return content.target.getContent();
}
return content;
};