Files
frontend-app-authoring/src/utils.js
Yusuf Musleh 088a01d716 feat: Add lib v2/legacy tabs in studio home (#1050)
This PR adds a new configuration flag that shows/hides tabs in studio home along with some new functionality around to V1 and V2 Libraries.

When the new LIBRARY_MODE flag is set to "mixed" (default in dev) it will show "Libraries" and "Legacy Libraries" tabs that correspond to v1 and v2 tabs respectively.

When the new LIBRARY_MODE flag is set to "v1 only" (default in production) or "v2 only", only one tab "Libraries" is shown and only the respective libraries are fetched when the tab is clicked.

In addition to the above changes, the URL/route now updates when clicking on the tabs, and navigating to it directly would open up that tab as well as a new placeholder page that you will be redirected to when clicking on a v2 library if the library authoring MFE is not enabled.
2024-06-20 17:30:57 +05:30

328 lines
10 KiB
JavaScript

import { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useMediaQuery } from 'react-responsive';
import * as Yup from 'yup';
import { snakeCase } from 'lodash/string';
import moment from 'moment';
import { getConfig, getPath } from '@edx/frontend-platform';
import { RequestStatus } from './data/constants';
import { getCourseAppSettingValue, getLoadingStatus } from './pages-and-resources/data/selectors';
import { fetchCourseAppSettings, updateCourseAppSetting } from './pages-and-resources/data/thunks';
import { PagesAndResourcesContext } from './pages-and-resources/PagesAndResourcesProvider';
import {
hasValidDateFormat, hasValidTimeFormat, decodeDateTime, endOfDayTime, startOfDayTime,
} from './pages-and-resources/discussions/app-config-form/utils';
import { DATE_TIME_FORMAT } from './constants';
export const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);
};
export function useIsMobile() {
return useMediaQuery({ query: '(max-width: 767.98px)' });
}
export function useIsDesktop() {
return useMediaQuery({ query: '(min-width: 992px)' });
}
export function convertObjectToSnakeCase(obj, unpacked = false) {
return Object.keys(obj).reduce((snakeCaseObj, key) => {
const snakeCaseKey = snakeCase(key);
const value = unpacked ? obj[key] : { value: obj[key] };
return {
...snakeCaseObj,
[snakeCaseKey]: value,
};
}, {});
}
export function deepConvertingKeysToCamelCase(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => deepConvertingKeysToCamelCase(item));
}
const camelCaseObj = {};
Object.keys(obj).forEach((key) => {
const camelCaseKey = key.replace(/_([a-z])/g, (match, p1) => p1.toUpperCase());
camelCaseObj[camelCaseKey] = deepConvertingKeysToCamelCase(obj[key]);
});
return camelCaseObj;
}
export function deepConvertingKeysToSnakeCase(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => deepConvertingKeysToSnakeCase(item));
}
const snakeCaseObj = {};
Object.entries(obj).forEach(([key, value]) => {
const snakeCaseKey = key.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
snakeCaseObj[snakeCaseKey] = key === 'gradeCutoffs' ? value : deepConvertingKeysToSnakeCase(value);
});
return snakeCaseObj;
}
export function transformKeysToCamelCase(obj) {
return obj.key.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
}
export function parseArrayOrObjectValues(obj) {
const result = {};
Object.entries(obj).forEach(([key, value]) => {
try {
if (!Number.isNaN(Number(value))) {
result[key] = value;
} else {
result[key] = JSON.parse(value);
}
} catch (e) {
result[key] = value;
}
});
return result;
}
/**
* Create a correct inner path depend on config PUBLIC_PATH.
* @param {string} checkPath - the internal route path that is validated
* @returns {string} - the correct internal route path
*/
export const createCorrectInternalRoute = (checkPath) => {
let basePath = getPath(getConfig().PUBLIC_PATH);
if (basePath.endsWith('/')) {
basePath = basePath.slice(0, -1);
}
if (!checkPath.startsWith(basePath)) {
return `${basePath}${checkPath}`;
}
return checkPath;
};
export function getPagePath(courseId, isMfePageEnabled, urlParameter) {
if (isMfePageEnabled === 'true') {
if (urlParameter === 'tabs') {
return createCorrectInternalRoute(`/course/${courseId}/pages-and-resources`);
}
return createCorrectInternalRoute(`/course/${courseId}/${urlParameter}`);
}
return `${getConfig().STUDIO_BASE_URL}/${urlParameter}/${courseId}`;
}
export function useAppSetting(settingName) {
const dispatch = useDispatch();
const { courseId } = useContext(PagesAndResourcesContext);
const settingValue = useSelector(getCourseAppSettingValue(settingName));
const loadingStatus = useSelector(getLoadingStatus);
useEffect(() => {
if ([RequestStatus.DENIED, RequestStatus.FAILED].includes(loadingStatus)) {
return;
}
if (settingValue === undefined || settingValue === null) {
dispatch(fetchCourseAppSettings(courseId, [settingName]));
}
}, [courseId]);
const saveSetting = async (value) => dispatch(updateCourseAppSetting(courseId, settingName, value));
return [settingValue, saveSetting];
}
export const getLabelById = (options, id) => {
const foundOption = options.find((option) => option.id === id);
return foundOption ? foundOption.label : '';
};
/**
* Adds additional validation methods to Yup.
*/
export function setupYupExtensions() {
// Add a uniqueProperty method to arrays that allows validating that the specified property path is unique
// across all objects in the array.
// Credit: https://github.com/jquense/yup/issues/345#issuecomment-717400071
Yup.addMethod(Yup.array, 'uniqueProperty', function uniqueProperty(property, message) {
return this.test('unique', '', function testUniqueness(list) {
const errors = [];
list.forEach((item, index) => {
const propertyValue = item[property];
if (propertyValue && list.filter(entry => entry[property] === propertyValue).length > 1) {
errors.push(
this.createError({
path: `${this.path}[${index}].${property}`,
message,
}),
);
}
});
if (errors.length > 0) {
throw new Yup.ValidationError(errors);
}
return true;
});
});
Yup.addMethod(Yup.object, 'uniqueObjectProperty', function uniqueObjectProperty(propertyName, message) {
return this.test('unique', message, function testUniqueness(discussionTopic) {
if (!discussionTopic || !discussionTopic[propertyName]) {
return true;
}
const isDuplicate = this.parent.filter(topic => topic !== discussionTopic)
.some(topic => topic[propertyName]?.toLowerCase() === discussionTopic[propertyName].toLowerCase());
if (isDuplicate) {
throw this.createError({
path: `${this.path}.${propertyName}`,
error: message,
});
}
return true;
});
});
Yup.addMethod(Yup.string, 'compare', function compare(message, type) {
return this.test('isGreater', message, function isGreater() {
// This function compare 2 dates or 2 times. It return no error if dateInstance/timeInstance is empty
// of if startTime or endTime is not present for time comparison
// or startDate or endDate is not present for date comparison
if (!this.parent
|| (!(this.parent.startTime && this.parent.endTime) && type === 'time')
|| (!(this.parent.startDate && this.parent.endDate) && type === 'date')
) {
return true;
}
const startDateTime = decodeDateTime(this.parent.startDate, startOfDayTime(this.parent.startTime));
const endDateTime = decodeDateTime(this.parent.endDate, endOfDayTime(this.parent.endTime));
let isInvalidStartDateTime;
if (type === 'date') {
isInvalidStartDateTime = startDateTime.isAfter(endDateTime);
} else if (type === 'time') {
isInvalidStartDateTime = startDateTime.isSameOrAfter(endDateTime);
}
if (isInvalidStartDateTime) {
throw this.createError({
path: `${this.path}`,
error: message,
});
}
return true;
});
});
Yup.addMethod(Yup.string, 'checkFormat', function checkFormat(message, type) {
return this.test('isValidFormat', message, function isValidFormat() {
if (!this.originalValue) {
return true;
}
let isValid;
if (type === 'date') {
isValid = hasValidDateFormat(this.originalValue);
} else if (type === 'time') {
isValid = hasValidTimeFormat(this.originalValue);
}
if (!isValid) {
throw this.createError({
path: `${this.path}`,
error: message,
});
}
return true;
});
});
}
export const convertToDateFromString = (dateStr) => {
/**
* Convert UTC to local time for react-datepicker
* Note: react-datepicker has a bug where it only interacts with local time
* @param {string} dateStr - YYYY-MM-DDTHH:MM:SSZ
* @return {Date} date in local time
*/
if (!dateStr) {
return '';
}
const stripTimeZone = (stringValue) => stringValue.substring(0, 19);
return moment(stripTimeZone(String(dateStr))).toDate();
};
export const convertToStringFromDate = (date) => {
/**
* Convert local time to UTC from react-datepicker
* Note: react-datepicker has a bug where it only interacts with local time
* @param {Date} date - date in local time
* @return {string} YYYY-MM-DDTHH:MM:SSZ
*/
if (!date) {
return '';
}
return moment(date).format(DATE_TIME_FORMAT);
};
export const isValidDate = (date) => {
const formattedValue = convertToStringFromDate(date).split('T')[0];
return Boolean(formattedValue.length <= 10);
};
export const getFileSizeToClosestByte = (fileSize) => {
let divides = 0;
let size = fileSize;
while (size > 1000 && divides < 4) {
size /= 1000;
divides += 1;
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2);
return `${fileSizeFixedDecimal} ${units[divides]}`;
};
/**
* Constructs library authoring MFE URL with correct slashes
* @param {string} libraryAuthoringMfeUrl - the base library authoring MFE url
* @param {string} path - the library authoring MFE url path
* @returns {string} - the correct internal route path
*/
export const constructLibraryAuthoringURL = (libraryAuthoringMfeUrl, path) => {
// Remove '/' at the beginning of path if any
const trimmedPath = path.startsWith('/')
? path.slice(1, path.length)
: path;
let constructedUrl = libraryAuthoringMfeUrl;
// Remove trailing `/` from base if found
if (libraryAuthoringMfeUrl.endsWith('/')) {
constructedUrl = constructedUrl.slice(0, -1);
}
// Add the `/` and path to url
constructedUrl = `${constructedUrl}/${trimmedPath}`;
return constructedUrl;
};