Files
frontend-app-discussions/src/discussions/utils.js

298 lines
7.7 KiB
JavaScript

/* eslint-disable import/prefer-default-export */
import { getIn } from 'formik';
import { uniqBy } from 'lodash';
import { generatePath, useRouteMatch } from 'react-router';
import { getConfig } from '@edx/frontend-platform';
import {
CheckCircle,
CheckCircleOutline,
Delete, Edit, Pin, QuestionAnswer, Report, Verified, VerifiedOutline,
} from '@edx/paragon/icons';
import { InsertLink } from '../components/icons';
import { ContentActions, Routes, ThreadType } from '../data/constants';
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 { params } = useRouteMatch(Routes.COMMENTS.PAGE);
return Routes.COMMENTS.PAGES[params.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: QuestionAnswer,
label: messages.closeAction,
conditions: { closed: false },
},
{
id: 'reopen',
action: ContentActions.CLOSE,
icon: QuestionAnswer,
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(content) {
const checkConditions = (item, conditions) => (
conditions
? Object.keys(conditions)
.map(key => item[key] === conditions[key])
.every(condition => condition === true)
: true
);
return ACTIONS_LIST.filter(
({
action,
conditions = null,
}) => checkPermissions(content, action) && checkConditions(content, conditions),
);
}
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]),
);
/**
* Helper function to make a check if date is in given range
* @param {Date} date this date to be checked in range
* @param {Date} start start date
* @param {Date} end end date
*/
export function dateInDateRange(date, start, end) {
return date >= start && date <= end;
}
/**
* Helper function to make a check if date is in given range
* @param {array} blackoutDateRanges start date
* @return Boolean
*/
export function inBlackoutDateRange(blackoutDateRanges) {
const now = new Date();
return blackoutDateRanges.some(
(blackoutDateRange) => dateInDateRange(now, new Date(blackoutDateRange.start), new Date(blackoutDateRange.end)),
);
}
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();
}
}