Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
export const initOnEvents = ['click', 'touchstart', 'keypress', 'keydown'];
|
||||
|
||||
export const getAudioContext = () => {
|
||||
let audioCtx;
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} catch {
|
||||
// AudioContext is not available.
|
||||
}
|
||||
return audioCtx;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getAlertAudio = async (baseUrl = '', requestContext) => {
|
||||
const audioCtx = getAudioContext();
|
||||
const playSound = audioBuffer => {
|
||||
window.playAudioAlert = () => {
|
||||
if (audioCtx) {
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioCtx.destination);
|
||||
source.loop = false;
|
||||
source.start();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (audioCtx) {
|
||||
const { type = 'dashboard', alertTone = 'ding' } = requestContext || {};
|
||||
const resourceUrl = `${baseUrl}/audio/${type}/${alertTone}.mp3`;
|
||||
const audioRequest = new Request(resourceUrl);
|
||||
|
||||
fetch(audioRequest)
|
||||
.then(response => response.arrayBuffer())
|
||||
.then(buffer => {
|
||||
audioCtx.decodeAudioData(buffer).then(playSound);
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
return new Promise(res => res());
|
||||
})
|
||||
.catch(() => {
|
||||
// error
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createConsumer } from '@rails/actioncable';
|
||||
|
||||
const PRESENCE_INTERVAL = 20000;
|
||||
const RECONNECT_INTERVAL = 1000;
|
||||
|
||||
class BaseActionCableConnector {
|
||||
static isDisconnected = false;
|
||||
|
||||
constructor(app, pubsubToken, websocketHost = '') {
|
||||
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
|
||||
|
||||
this.consumer = createConsumer(websocketURL);
|
||||
this.subscription = this.consumer.subscriptions.create(
|
||||
{
|
||||
channel: 'RoomChannel',
|
||||
pubsub_token: pubsubToken,
|
||||
account_id: app.$store.getters.getCurrentAccountId,
|
||||
user_id: app.$store.getters.getCurrentUserID,
|
||||
},
|
||||
{
|
||||
updatePresence() {
|
||||
this.perform('update_presence');
|
||||
},
|
||||
received: this.onReceived,
|
||||
disconnected: () => {
|
||||
BaseActionCableConnector.isDisconnected = true;
|
||||
this.onDisconnected();
|
||||
this.initReconnectTimer();
|
||||
},
|
||||
}
|
||||
);
|
||||
this.app = app;
|
||||
this.events = {};
|
||||
this.reconnectTimer = null;
|
||||
this.isAValidEvent = () => true;
|
||||
this.triggerPresenceInterval = () => {
|
||||
setTimeout(() => {
|
||||
this.subscription.updatePresence();
|
||||
this.triggerPresenceInterval();
|
||||
}, PRESENCE_INTERVAL);
|
||||
};
|
||||
this.triggerPresenceInterval();
|
||||
}
|
||||
|
||||
checkConnection() {
|
||||
const isConnectionActive = this.consumer.connection.isOpen();
|
||||
const isReconnected =
|
||||
BaseActionCableConnector.isDisconnected && isConnectionActive;
|
||||
if (isReconnected) {
|
||||
this.clearReconnectTimer();
|
||||
this.onReconnect();
|
||||
BaseActionCableConnector.isDisconnected = false;
|
||||
} else {
|
||||
this.initReconnectTimer();
|
||||
}
|
||||
}
|
||||
|
||||
clearReconnectTimer = () => {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
initReconnectTimer = () => {
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.checkConnection();
|
||||
}, RECONNECT_INTERVAL);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReconnect = () => {};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onDisconnected = () => {};
|
||||
|
||||
disconnect() {
|
||||
this.consumer.disconnect();
|
||||
}
|
||||
|
||||
onReceived = ({ event, data } = {}) => {
|
||||
if (this.isAValidEvent(data)) {
|
||||
if (this.events[event] && typeof this.events[event] === 'function') {
|
||||
this.events[event](data);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default BaseActionCableConnector;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
export class DuplicateContactException extends Error {
|
||||
constructor(data) {
|
||||
super('DUPLICATE_CONTACT');
|
||||
this.data = data;
|
||||
this.name = 'DuplicateContactException';
|
||||
}
|
||||
}
|
||||
export class ExceptionWithMessage extends Error {
|
||||
constructor(data) {
|
||||
super('ERROR_WITH_MESSAGE');
|
||||
this.data = data;
|
||||
this.name = 'ExceptionWithMessage';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const createEvent = ({ eventName, data = null }) => {
|
||||
let event;
|
||||
if (typeof window.CustomEvent === 'function') {
|
||||
event = new CustomEvent(eventName, { detail: data });
|
||||
} else {
|
||||
event = document.createEvent('CustomEvent');
|
||||
event.initCustomEvent(eventName, false, false, data);
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
export const dispatchWindowEvent = ({ eventName, data }) => {
|
||||
const event = createEvent({ eventName, data });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import format from 'date-fns/format';
|
||||
import isToday from 'date-fns/isToday';
|
||||
import isYesterday from 'date-fns/isYesterday';
|
||||
import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
|
||||
|
||||
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
|
||||
const unixDate = fromUnixTime(date);
|
||||
return format(unixDate, dateFormat);
|
||||
};
|
||||
|
||||
export const formatDate = ({ date, todayText, yesterdayText }) => {
|
||||
const dateValue = new Date(date);
|
||||
if (isToday(dateValue)) return todayText;
|
||||
if (isYesterday(dateValue)) return yesterdayText;
|
||||
return date;
|
||||
};
|
||||
|
||||
export const isTimeAfter = (h1, m1, h2, m2) => {
|
||||
if (h1 < h2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (h1 === h2) {
|
||||
return m1 >= m2;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Get start of day as a UNIX timestamp */
|
||||
export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
|
||||
|
||||
/** Get end of day as a UNIX timestamp */
|
||||
export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
|
||||
|
||||
export const generateRelativeTime = (value, unit, languageCode) => {
|
||||
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
|
||||
const rtf = new Intl.RelativeTimeFormat(code, {
|
||||
numeric: 'auto',
|
||||
});
|
||||
return rtf.format(value, unit);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
export const DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const fileSizeInMegaBytes = bytes => {
|
||||
return bytes / (1024 * 1024);
|
||||
};
|
||||
|
||||
export const checkFileSizeLimit = (file, maximumUploadLimit) => {
|
||||
const fileSize = file?.file?.size || file?.size;
|
||||
const fileSizeInMB = fileSizeInMegaBytes(fileSize);
|
||||
return fileSizeInMB <= maximumUploadLimit;
|
||||
};
|
||||
|
||||
export const resolveMaximumFileUploadSize = value => {
|
||||
const parsedValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
||||
return DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
}
|
||||
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a file type is allowed for a specific channel
|
||||
* @param {File} file - The file to validate
|
||||
* @param {Object} options - Validation options
|
||||
* @param {string} options.channelType - The channel type
|
||||
* @param {string} options.medium - The channel medium
|
||||
* @param {string} options.conversationType - The conversation type (for Instagram DM detection)
|
||||
* @param {boolean} options.isInstagramChannel - Whether it's an Instagram channel
|
||||
* @param {boolean} options.isOnPrivateNote - Whether composing a private note (uses broader file type list)
|
||||
* @returns {boolean} - True if file type is allowed, false otherwise
|
||||
*/
|
||||
export const isFileTypeAllowedForChannel = (file, options = {}) => {
|
||||
if (!file || file.size === 0) return false;
|
||||
|
||||
const {
|
||||
channelType: originalChannelType,
|
||||
medium,
|
||||
conversationType,
|
||||
isInstagramChannel,
|
||||
isOnPrivateNote,
|
||||
} = options;
|
||||
|
||||
// Use broader file types for private notes (matches file picker behavior)
|
||||
const allowedFileTypes = isOnPrivateNote
|
||||
? ALLOWED_FILE_TYPES
|
||||
: getAllowedFileTypesByChannel({
|
||||
channelType:
|
||||
isInstagramChannel || conversationType === 'instagram_direct_message'
|
||||
? INBOX_TYPES.INSTAGRAM
|
||||
: originalChannelType,
|
||||
medium,
|
||||
});
|
||||
|
||||
// Convert to array and validate
|
||||
const allowedTypesArray = allowedFileTypes.split(',').map(t => t.trim());
|
||||
const fileExtension = `.${file.name.split('.').pop()}`;
|
||||
|
||||
return allowedTypesArray.some(allowedType => {
|
||||
// Check for exact file extension match
|
||||
if (allowedType === fileExtension) return true;
|
||||
|
||||
// Check for wildcard MIME type (e.g., image/*)
|
||||
if (allowedType.endsWith('/*')) {
|
||||
const prefix = allowedType.slice(0, -2); // Remove '/*'
|
||||
return file.type.startsWith(prefix + '/');
|
||||
}
|
||||
|
||||
// Check for exact MIME type match
|
||||
return allowedType === file.type;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export const escapeHtml = (unsafe = '') => {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
export const afterSanitizeAttributes = currentNode => {
|
||||
if ('target' in currentNode) {
|
||||
currentNode.setAttribute('target', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export const domPurifyConfig = {
|
||||
hooks: {
|
||||
afterSanitizeAttributes,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const DYTE_MEETING_LINK = 'https://app.dyte.io/v2/meeting';
|
||||
|
||||
export const buildDyteURL = dyteAuthToken => {
|
||||
return `${DYTE_MEETING_LINK}?authToken=${dyteAuthToken}&showSetupScreen=true&disableVideoBackground=true`;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
export const isEnter = e => {
|
||||
return e.key === 'Enter';
|
||||
};
|
||||
|
||||
export const isEscape = e => {
|
||||
return e.key === 'Escape';
|
||||
};
|
||||
|
||||
export const hasPressedShift = e => {
|
||||
return e.shiftKey;
|
||||
};
|
||||
|
||||
export const hasPressedCommand = e => {
|
||||
return e.metaKey;
|
||||
};
|
||||
|
||||
export const hasPressedEnterAndNotCmdOrShift = e => {
|
||||
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
|
||||
};
|
||||
|
||||
export const hasPressedCommandAndEnter = e => {
|
||||
return hasPressedCommand(e) && isEnter(e);
|
||||
};
|
||||
|
||||
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||
export const keysToModifyInQWERTZ = new Set(['Alt+KeyP', 'Alt+KeyL']);
|
||||
|
||||
export const LAYOUT_QWERTY = 'QWERTY';
|
||||
export const LAYOUT_QWERTZ = 'QWERTZ';
|
||||
export const LAYOUT_AZERTY = 'AZERTY';
|
||||
|
||||
/**
|
||||
* Determines whether the active element is typeable.
|
||||
*
|
||||
* @param {KeyboardEvent} e - The keyboard event object.
|
||||
* @returns {boolean} `true` if the active element is typeable, `false` otherwise.
|
||||
*
|
||||
* @example
|
||||
* document.addEventListener('keydown', e => {
|
||||
* if (isActiveElementTypeable(e)) {
|
||||
* handleTypeableElement(e);
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export const isActiveElementTypeable = e => {
|
||||
/** @type {HTMLElement | null} */
|
||||
// @ts-ignore
|
||||
const activeElement = e.target || document.activeElement;
|
||||
|
||||
return !!(
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
activeElement?.tagName === 'NINJA-KEYS' ||
|
||||
activeElement?.tagName === 'TEXTAREA' ||
|
||||
activeElement?.contentEditable === 'true' ||
|
||||
activeElement?.className?.includes('ProseMirror')
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import mentionPlugin from './markdownIt/link';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const setImageHeight = inlineToken => {
|
||||
const imgSrc = inlineToken.attrGet('src');
|
||||
if (!imgSrc) return;
|
||||
const url = new URL(imgSrc);
|
||||
const height = url.searchParams.get('cw_image_height');
|
||||
if (!height) return;
|
||||
inlineToken.attrSet('style', `height: ${height};`);
|
||||
};
|
||||
|
||||
const processInlineToken = blockToken => {
|
||||
blockToken.children.forEach(inlineToken => {
|
||||
if (inlineToken.type === 'image') {
|
||||
setImageHeight(inlineToken);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const imgResizeManager = md => {
|
||||
// Custom rule for image resize in markdown
|
||||
// If the image url has a query param cw_image_height, then add a style attribute to the image
|
||||
md.core.ruler.after('inline', 'add-image-height', state => {
|
||||
state.tokens.forEach(blockToken => {
|
||||
if (blockToken.type === 'inline') {
|
||||
processInlineToken(blockToken);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createMarkdownInstance = (linkify = true) => {
|
||||
return MarkdownIt({
|
||||
html: false,
|
||||
xhtmlOut: true,
|
||||
breaks: true,
|
||||
langPrefix: 'language-',
|
||||
linkify,
|
||||
typographer: true,
|
||||
quotes: '\u201c\u201d\u2018\u2019',
|
||||
maxNesting: 20,
|
||||
})
|
||||
.use(mentionPlugin)
|
||||
.use(imgResizeManager)
|
||||
.use(mila, {
|
||||
attrs: {
|
||||
class: 'link',
|
||||
rel: 'noreferrer noopener nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
||||
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
|
||||
|
||||
class MessageFormatter {
|
||||
constructor(
|
||||
message,
|
||||
isATweet = false,
|
||||
isAPrivateNote = false,
|
||||
linkify = true
|
||||
) {
|
||||
this.message = message || '';
|
||||
this.isAPrivateNote = isAPrivateNote;
|
||||
this.isATweet = isATweet;
|
||||
this.linkify = linkify;
|
||||
this.md = createMarkdownInstance(linkify);
|
||||
}
|
||||
|
||||
formatMessage() {
|
||||
let updatedMessage = this.message;
|
||||
if (this.isATweet && !this.isAPrivateNote) {
|
||||
updatedMessage = updatedMessage.replace(
|
||||
TWITTER_USERNAME_REGEX,
|
||||
TWITTER_USERNAME_REPLACEMENT
|
||||
);
|
||||
updatedMessage = updatedMessage.replace(
|
||||
TWITTER_HASH_REGEX,
|
||||
TWITTER_HASH_REPLACEMENT
|
||||
);
|
||||
}
|
||||
return this.md.render(updatedMessage);
|
||||
}
|
||||
|
||||
get formattedMessage() {
|
||||
return this.formatMessage();
|
||||
}
|
||||
|
||||
get plainText() {
|
||||
const strippedOutHtml = new DOMParser().parseFromString(
|
||||
this.formattedMessage,
|
||||
'text/html'
|
||||
);
|
||||
return strippedOutHtml.body.textContent || '';
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageFormatter;
|
||||
@@ -0,0 +1,25 @@
|
||||
export const isAFormMessage = message => message.content_type === 'form';
|
||||
export const isASubmittedFormMessage = (message = {}) =>
|
||||
isAFormMessage(message) && !!message.content_attributes?.submitted_values;
|
||||
|
||||
export const MESSAGE_MAX_LENGTH = {
|
||||
GENERAL: 10000,
|
||||
// https://developers.facebook.com/docs/messenger-platform/reference/send-api#request
|
||||
FACEBOOK: 2000,
|
||||
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
|
||||
INSTAGRAM: 1000,
|
||||
// https://business-api.tiktok.com/portal/docs?id=1832184403754242
|
||||
TIKTOK: 6000,
|
||||
// https://www.twilio.com/docs/glossary/what-sms-character-limit
|
||||
TWILIO_SMS: 320,
|
||||
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
|
||||
TWILIO_WHATSAPP: 1600,
|
||||
// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#text-object
|
||||
WHATSAPP_CLOUD: 4096,
|
||||
// https://support.bandwidth.com/hc/en-us/articles/360010235373-What-are-Bandwidth-s-SMS-character-limits-and-concatenation-practices
|
||||
BANDWIDTH_SMS: 160,
|
||||
// https://core.telegram.org/bots/api#sendmessage
|
||||
TELEGRAM: 4096,
|
||||
LINE: 2000,
|
||||
EMAIL: 25000,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
fromUnixTime,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
getUnixTime,
|
||||
subDays,
|
||||
} from 'date-fns';
|
||||
|
||||
/**
|
||||
* Returns a key-value pair of timestamp and value for heatmap data
|
||||
*
|
||||
* @param {Array} data - An array of objects containing timestamp and value
|
||||
* @returns {Object} - An object with timestamp as keys and corresponding values as values
|
||||
*/
|
||||
export const flattenHeatmapData = data => {
|
||||
return data.reduce((acc, curr) => {
|
||||
acc[curr.timestamp] = curr.value;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter the given array to remove data outside the timeline
|
||||
*
|
||||
* @param {Array} data - An array of objects containing timestamp and value
|
||||
* @param {number} from - Unix timestamp
|
||||
* @param {number} to - Unix timestamp
|
||||
* @returns {Array} - An array of objects containing timestamp and value
|
||||
*/
|
||||
export const clampDataBetweenTimeline = (data, from, to) => {
|
||||
if (from === undefined && to === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter(el => {
|
||||
const { timestamp } = el;
|
||||
|
||||
const isWithinFrom = from === undefined || timestamp - from >= 0;
|
||||
const isWithinTo = to === undefined || to - timestamp > 0;
|
||||
|
||||
return isWithinFrom && isWithinTo;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an array of objects with timestamp and value as 0 for the last 7 days
|
||||
*
|
||||
* @returns {Array} - An array of objects containing timestamp and value
|
||||
*/
|
||||
export const generateEmptyHeatmapData = () => {
|
||||
const data = [];
|
||||
const today = new Date();
|
||||
|
||||
let timeMarker = getUnixTime(startOfDay(subDays(today, 6)));
|
||||
let endOfToday = getUnixTime(endOfDay(today));
|
||||
|
||||
const oneHour = 3600;
|
||||
|
||||
while (timeMarker <= endOfToday) {
|
||||
data.push({ value: 0, timestamp: timeMarker });
|
||||
timeMarker += oneHour;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles new data with existing heatmap data based on timestamps
|
||||
*
|
||||
* @param {Array} data - An array of objects containing timestamp and value
|
||||
* @param {Array} heatmapData - An array of objects containing timestamp, value and other properties
|
||||
* @returns {Array} - An array of objects with updated values
|
||||
*/
|
||||
export const reconcileHeatmapData = (data, dataFromStore) => {
|
||||
const parsedData = flattenHeatmapData(data);
|
||||
// make a copy of the data from store
|
||||
const heatmapData = dataFromStore.length
|
||||
? dataFromStore
|
||||
: generateEmptyHeatmapData();
|
||||
|
||||
return heatmapData.map(dataItem => {
|
||||
if (parsedData[dataItem.timestamp]) {
|
||||
dataItem.value = parsedData[dataItem.timestamp];
|
||||
}
|
||||
return dataItem;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Groups heatmap data by day
|
||||
*
|
||||
* @param {Array} heatmapData - An array of objects containing timestamp, value and other properties
|
||||
* @returns {Map} - A Map object with dates as keys and corresponding data objects as values
|
||||
*/
|
||||
export const groupHeatmapByDay = heatmapData => {
|
||||
return heatmapData.reduce((acc, data) => {
|
||||
const date = fromUnixTime(data.timestamp);
|
||||
const mapKey = startOfDay(date).toISOString();
|
||||
const dataToAppend = {
|
||||
...data,
|
||||
date: fromUnixTime(data.timestamp),
|
||||
hour: date.getHours(),
|
||||
};
|
||||
if (!acc.has(mapKey)) {
|
||||
acc.set(mapKey, []);
|
||||
}
|
||||
acc.get(mapKey).push(dataToAppend);
|
||||
return acc;
|
||||
}, new Map());
|
||||
};
|
||||
109
research/chatwoot/app/javascript/shared/helpers/Validators.js
Normal file
109
research/chatwoot/app/javascript/shared/helpers/Validators.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Checks if a string is a valid E.164 phone number format.
|
||||
* @param {string} value - The phone number to validate.
|
||||
* @returns {boolean} True if the number is in E.164 format, false otherwise.
|
||||
*/
|
||||
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
|
||||
|
||||
/**
|
||||
* Validates a phone number after removing the dial code.
|
||||
* @param {string} value - The full phone number including dial code.
|
||||
* @param {string} dialCode - The dial code to remove before validation.
|
||||
* @returns {boolean} True if the number (without dial code) is valid, false otherwise.
|
||||
*/
|
||||
export const isPhoneNumberValid = (value, dialCode) => {
|
||||
const number = value.replace(dialCode, '');
|
||||
return !!number.match(/^[0-9]{1,14}$/);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string is either a valid E.164 phone number or empty.
|
||||
* @param {string} value - The phone number to validate.
|
||||
* @returns {boolean} True if the number is in E.164 format or empty, false otherwise.
|
||||
*/
|
||||
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
|
||||
|
||||
/**
|
||||
* Validates a phone number with dial code, requiring at least 5 digits.
|
||||
* @param {string} value - The full phone number including dial code.
|
||||
* @returns {boolean} True if the number is valid, false otherwise.
|
||||
*/
|
||||
export const isPhoneNumberValidWithDialCode = value => {
|
||||
const number = value.replace(/^\+/, ''); // Remove the '+' sign
|
||||
return !!number.match(/^[1-9]\d{4,}$/); // Validate the phone number with minimum 5 digits
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string starts with a plus sign.
|
||||
* @param {string} value - The string to check.
|
||||
* @returns {boolean} True if the string starts with '+', false otherwise.
|
||||
*/
|
||||
export const startsWithPlus = value => value.startsWith('+');
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid URL (starts with 'http') or is empty.
|
||||
* @param {string} [value=''] - The string to check.
|
||||
* @returns {boolean} True if the string is a valid URL or empty, false otherwise.
|
||||
*/
|
||||
export const shouldBeUrl = (value = '') =>
|
||||
value ? value.startsWith('http') : true;
|
||||
|
||||
/**
|
||||
* Validates a password for complexity requirements.
|
||||
* @param {string} value - The password to validate.
|
||||
* @returns {boolean} True if the password meets all requirements, false otherwise.
|
||||
*/
|
||||
export const isValidPassword = value => {
|
||||
const containsUppercase = /[A-Z]/.test(value);
|
||||
const containsLowercase = /[a-z]/.test(value);
|
||||
const containsNumber = /[0-9]/.test(value);
|
||||
const containsSpecialCharacter = /[!@#$%^&*()_+\-=[\]{}|'"/\\.,`<>:;?~]/.test(
|
||||
value
|
||||
);
|
||||
return (
|
||||
containsUppercase &&
|
||||
containsLowercase &&
|
||||
containsNumber &&
|
||||
containsSpecialCharacter
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string consists only of digits.
|
||||
* @param {string} value - The string to check.
|
||||
* @returns {boolean} True if the string contains only digits, false otherwise.
|
||||
*/
|
||||
export const isNumber = value => /^\d+$/.test(value);
|
||||
|
||||
/**
|
||||
* Validates a domain name.
|
||||
* @param {string} value - The domain name to validate.
|
||||
* @returns {boolean} True if the domain is valid or empty, false otherwise.
|
||||
*/
|
||||
export const isDomain = value => {
|
||||
if (value !== '') {
|
||||
const domainRegex = /^([\p{L}0-9]+(-[\p{L}0-9]+)*\.)+[a-z]{2,}$/gmu;
|
||||
return domainRegex.test(value);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a RegExp object from a string representation of a regular expression.
|
||||
* @param {string} regexPatternValue - The string representation of the regex (e.g., '/pattern/flags').
|
||||
* @returns {RegExp} A RegExp object created from the input string.
|
||||
*/
|
||||
export const getRegexp = regexPatternValue => {
|
||||
let lastSlash = regexPatternValue.lastIndexOf('/');
|
||||
return new RegExp(
|
||||
regexPatternValue.slice(1, lastSlash),
|
||||
regexPatternValue.slice(lastSlash + 1)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
|
||||
* @param {string} value - The slug to validate.
|
||||
* @returns {boolean} True if the slug is valid, false otherwise.
|
||||
*/
|
||||
export const isValidSlug = value => /^[a-zA-Z0-9-]+$/.test(value);
|
||||
43
research/chatwoot/app/javascript/shared/helpers/cache.js
Normal file
43
research/chatwoot/app/javascript/shared/helpers/cache.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LocalStorage } from './localStorage';
|
||||
|
||||
// Default cache expiry is 24 hours
|
||||
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const getFromCache = (key, expiry = DEFAULT_EXPIRY) => {
|
||||
try {
|
||||
const cached = LocalStorage.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, timestamp } = cached;
|
||||
const isExpired = Date.now() - timestamp > expiry;
|
||||
|
||||
if (isExpired) {
|
||||
LocalStorage.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setCache = (key, data) => {
|
||||
try {
|
||||
const cacheData = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
LocalStorage.set(key, cacheData);
|
||||
} catch (error) {
|
||||
// Ignore cache errors
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCache = key => {
|
||||
try {
|
||||
LocalStorage.remove(key);
|
||||
} catch (error) {
|
||||
// Ignore cache errors
|
||||
}
|
||||
};
|
||||
38
research/chatwoot/app/javascript/shared/helpers/clipboard.js
Normal file
38
research/chatwoot/app/javascript/shared/helpers/clipboard.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Writes a text string to the system clipboard.
|
||||
*
|
||||
* @async
|
||||
* @param {string} data text to be written to the clipboard
|
||||
* @throws {Error} unable to copy text to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async data => {
|
||||
try {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles OTP paste events by extracting numeric digits from clipboard data.
|
||||
*
|
||||
* @param {ClipboardEvent} event - The paste event from the clipboard
|
||||
* @param {number} maxLength - Maximum number of digits to extract (default: 6)
|
||||
* @returns {string|null} - Extracted numeric string or null if invalid
|
||||
*/
|
||||
export const handleOtpPaste = (event, maxLength = 6) => {
|
||||
if (!event?.clipboardData) return null;
|
||||
|
||||
const pastedData = event.clipboardData
|
||||
.getData('text')
|
||||
.replace(/\D/g, '') // Remove all non-digit characters
|
||||
.slice(0, maxLength); // Limit to maxLength digits
|
||||
|
||||
// Only return if we have the exact expected length
|
||||
return pastedData.length === maxLength ? pastedData : null;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { toHex, mix, getLuminance, getContrast } from 'color2k';
|
||||
|
||||
export const isWidgetColorLighter = color => {
|
||||
const colorToCheck = color.replace('#', '');
|
||||
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
|
||||
const c_g = parseInt(colorToCheck.substr(2, 2), 16);
|
||||
const c_b = parseInt(colorToCheck.substr(4, 2), 16);
|
||||
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
|
||||
return brightness > 225;
|
||||
};
|
||||
|
||||
export const adjustColorForContrast = (color, backgroundColor) => {
|
||||
const targetRatio = 3.1;
|
||||
const MAX_ITERATIONS = 20;
|
||||
let adjustedColor = color;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
|
||||
const currentRatio = getContrast(adjustedColor, backgroundColor);
|
||||
if (currentRatio >= targetRatio) {
|
||||
break;
|
||||
}
|
||||
const adjustmentDirection =
|
||||
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
|
||||
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
|
||||
}
|
||||
|
||||
return toHex(adjustedColor);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Document Helper - utilities for document display and formatting
|
||||
*/
|
||||
|
||||
// Constants for document processing
|
||||
const PDF_PREFIX = 'PDF:';
|
||||
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
|
||||
|
||||
/**
|
||||
* Checks if a document is a PDF based on its external link
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {boolean} True if the document is a PDF
|
||||
*/
|
||||
export const isPdfDocument = externalLink => {
|
||||
if (!externalLink) return false;
|
||||
return externalLink.startsWith(PDF_PREFIX);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the display link for documents
|
||||
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
|
||||
* For regular URLs: returns as-is
|
||||
*
|
||||
* @param {string} externalLink - The external link string
|
||||
* @returns {string} Formatted display link
|
||||
*/
|
||||
export const formatDocumentLink = externalLink => {
|
||||
if (!externalLink) return '';
|
||||
|
||||
if (isPdfDocument(externalLink)) {
|
||||
// Remove 'PDF:' prefix
|
||||
const fullName = externalLink.substring(PDF_PREFIX.length);
|
||||
// Remove timestamp suffix if present
|
||||
return fullName.replace(TIMESTAMP_PATTERN, '');
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
};
|
||||
47
research/chatwoot/app/javascript/shared/helpers/emoji.js
Normal file
47
research/chatwoot/app/javascript/shared/helpers/emoji.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Detects support for emoji character sets.
|
||||
*
|
||||
* Based on the Modernizr emoji detection.
|
||||
* https://github.com/Modernizr/Modernizr/blob/347ddb078116cee91b25b6e897e211b023f9dcb4/feature-detects/emoji.js
|
||||
*
|
||||
* @return {Boolean} true or false
|
||||
* @example
|
||||
*
|
||||
* hasEmojiSupport()
|
||||
* // => true|false
|
||||
*/
|
||||
export const hasEmojiSupport = () => {
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const offset = 12 * pixelRatio;
|
||||
const node = document.createElement('canvas');
|
||||
|
||||
// canvastext support
|
||||
if (
|
||||
!node.getContext ||
|
||||
!node.getContext('2d') ||
|
||||
typeof node.getContext('2d').fillText !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctx = node.getContext('2d');
|
||||
|
||||
ctx.fillStyle = '#f00';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '32px Arial';
|
||||
ctx.fillText('\ud83d\udc28', 0, 0); // U+1F428 KOALA
|
||||
return ctx.getImageData(offset, offset, 1, 1).data[0] !== 0;
|
||||
};
|
||||
|
||||
export const removeEmoji = text => {
|
||||
if (text) {
|
||||
return text
|
||||
.replace(
|
||||
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
|
||||
''
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
export const LocalStorage = {
|
||||
clearAll() {
|
||||
window.localStorage.clear();
|
||||
},
|
||||
|
||||
get(key) {
|
||||
let value = null;
|
||||
try {
|
||||
value = window.localStorage.getItem(key);
|
||||
return typeof value === 'string' ? JSON.parse(value) : value;
|
||||
} catch (error) {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
window.localStorage.setItem(key + ':ts', Date.now().toString());
|
||||
},
|
||||
|
||||
setFlag(store, accountId, key, expiry = 24 * 60 * 60 * 1000) {
|
||||
const storeName = accountId ? `${store}::${accountId}` : store;
|
||||
|
||||
const rawValue = window.localStorage.getItem(storeName);
|
||||
const parsedValue = rawValue ? JSON.parse(rawValue) : {};
|
||||
|
||||
parsedValue[key] = Date.now() + expiry;
|
||||
|
||||
window.localStorage.setItem(storeName, JSON.stringify(parsedValue));
|
||||
},
|
||||
|
||||
getFlag(store, accountId, key) {
|
||||
const storeName = store ? `${store}::${accountId}` : store;
|
||||
|
||||
const rawValue = window.localStorage.getItem(storeName);
|
||||
const parsedValue = rawValue ? JSON.parse(rawValue) : {};
|
||||
|
||||
return parsedValue[key] && parsedValue[key] > Date.now();
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.localStorage.removeItem(key + ':ts');
|
||||
},
|
||||
|
||||
updateJsonStore(storeName, key, value) {
|
||||
try {
|
||||
const storedValue = this.get(storeName) || {};
|
||||
storedValue[key] = value;
|
||||
this.set(storeName, storedValue);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error updating JSON store in localStorage', e);
|
||||
}
|
||||
},
|
||||
|
||||
getFromJsonStore(storeName, key) {
|
||||
try {
|
||||
const storedValue = this.get(storeName) || {};
|
||||
return storedValue[key] || null;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error getting value from JSON store in localStorage', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteFromJsonStore(storeName, key) {
|
||||
try {
|
||||
const storedValue = this.get(storeName);
|
||||
if (storedValue && key in storedValue) {
|
||||
delete storedValue[key];
|
||||
this.set(storeName, storedValue);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error deleting entry from JSON store in localStorage', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Process [@mention](mention://user/1/Pranav)
|
||||
const USER_MENTIONS_REGEX = /mention:\/\/(user|team)\/(\d+)\/(.+)/gm;
|
||||
|
||||
const buildMentionTokens = () => (state, silent) => {
|
||||
var label;
|
||||
var labelEnd;
|
||||
var labelStart;
|
||||
var pos;
|
||||
var res;
|
||||
var token;
|
||||
var href = '';
|
||||
var max = state.posMax;
|
||||
|
||||
if (state.src.charCodeAt(state.pos) !== 0x5b /* [ */) {
|
||||
return false;
|
||||
}
|
||||
|
||||
labelStart = state.pos + 1;
|
||||
labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true);
|
||||
|
||||
// parser failed to find ']', so it's not a valid link
|
||||
if (labelEnd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
label = state.src.slice(labelStart, labelEnd);
|
||||
pos = labelEnd + 1;
|
||||
|
||||
if (pos < max && state.src.charCodeAt(pos) === 0x28 /* ( */) {
|
||||
pos += 1;
|
||||
res = state.md.helpers.parseLinkDestination(state.src, pos, state.posMax);
|
||||
if (res.ok) {
|
||||
href = state.md.normalizeLink(res.str);
|
||||
if (state.md.validateLink(href)) {
|
||||
pos = res.pos;
|
||||
} else {
|
||||
href = '';
|
||||
}
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
if (!href.match(new RegExp(USER_MENTIONS_REGEX))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
state.pos = labelStart;
|
||||
state.posMax = labelEnd;
|
||||
|
||||
token = state.push('mention', '');
|
||||
token.href = href;
|
||||
token.content = label;
|
||||
}
|
||||
|
||||
state.pos = pos;
|
||||
state.posMax = max;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderMentions = () => (tokens, idx) => {
|
||||
return `<span class="prosemirror-mention-node">${tokens[idx].content}</span>`;
|
||||
};
|
||||
|
||||
export default function mentionPlugin(md) {
|
||||
md.renderer.rules.mention = renderMentions(md);
|
||||
md.inline.ruler.before('link', 'mention', buildMentionTokens(md));
|
||||
}
|
||||
3
research/chatwoot/app/javascript/shared/helpers/mitt.js
Normal file
3
research/chatwoot/app/javascript/shared/helpers/mitt.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import mitt from 'mitt';
|
||||
const emitter = mitt();
|
||||
export { emitter };
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Determine the best-matching locale from the list of locales allowed by the portal.
|
||||
*
|
||||
* The matching happens in the following order:
|
||||
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
|
||||
* (e.g., `fr` ➜ `fr`).
|
||||
* 2. Base language match – the base part of a compound locale (before the underscore)
|
||||
* matches (e.g., `fr_CA` ➜ `fr`).
|
||||
* 3. Variant match – when the base language is selected but a regional variant exists
|
||||
* in the portal list (e.g., `fr` ➜ `fr_BE`).
|
||||
*
|
||||
* If none of these rules find a match, the function returns `null`,
|
||||
* Don't show popular articles if locale doesn't match with allowed locales
|
||||
*
|
||||
* @export
|
||||
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
|
||||
* @param {string[]} allowedLocales Array of locales enabled for the portal.
|
||||
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
|
||||
*/
|
||||
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||
// Ensure inputs are valid
|
||||
if (
|
||||
!selectedLocale ||
|
||||
!Array.isArray(allowedLocales) ||
|
||||
!allowedLocales.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [lang] = selectedLocale.split('_');
|
||||
|
||||
const priorityMatches = [
|
||||
selectedLocale, // exact match
|
||||
lang, // base language match
|
||||
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
|
||||
];
|
||||
|
||||
// Return the first match that exists in the allowed list, or null
|
||||
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export const labelSanitizePattern = /[^a-zA-Z0-9_-]/g;
|
||||
export const spacesPattern = /\s+/g;
|
||||
|
||||
/**
|
||||
* Sanitizes a label by removing unwanted characters and replacing spaces with hyphens.
|
||||
*
|
||||
* @param {string | undefined | null} label - The label to sanitize.
|
||||
* @returns {string} The sanitized label.
|
||||
*
|
||||
* @example
|
||||
* const label = 'My Label 123';
|
||||
* const sanitizedLabel = sanitizeLabel(label); // 'my-label-123'
|
||||
*/
|
||||
export const sanitizeLabel = (label = '') => {
|
||||
if (!label) return '';
|
||||
|
||||
return label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(spacesPattern, '-')
|
||||
.replace(labelSanitizePattern, '');
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
clearAll() {
|
||||
window.sessionStorage.clear();
|
||||
},
|
||||
|
||||
get(key) {
|
||||
try {
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
return window.sessionStorage.getItem(key);
|
||||
}
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DuplicateContactException } from '../CustomErrors';
|
||||
|
||||
describe('DuplicateContactException', () => {
|
||||
it('returns correct exception', () => {
|
||||
const exception = new DuplicateContactException({
|
||||
attributes: ['email'],
|
||||
});
|
||||
expect(exception.message).toEqual('DUPLICATE_CONTACT');
|
||||
expect(exception.data).toEqual({
|
||||
attributes: ['email'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { dispatchWindowEvent } from '../CustomEventHelper';
|
||||
|
||||
describe('dispatchWindowEvent', () => {
|
||||
it('dispatches correct event', () => {
|
||||
window.dispatchEvent = vi.fn();
|
||||
dispatchWindowEvent({ eventName: 'chatwoot:ready' });
|
||||
expect(dispatchEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
formatDate,
|
||||
formatUnixDate,
|
||||
isTimeAfter,
|
||||
generateRelativeTime,
|
||||
} from '../DateHelper';
|
||||
|
||||
describe('#DateHelper', () => {
|
||||
it('should format unix date correctly without dateFormat', () => {
|
||||
expect(formatUnixDate(1576340626)).toEqual('Dec 14, 2019');
|
||||
});
|
||||
|
||||
it('should format unix date correctly without dateFormat', () => {
|
||||
expect(formatUnixDate(1608214031, 'MM/dd/yyyy')).toEqual('12/17/2020');
|
||||
});
|
||||
|
||||
it('should format date', () => {
|
||||
expect(
|
||||
formatDate({
|
||||
date: 'Dec 14, 2019',
|
||||
todayText: 'Today',
|
||||
yesterdayText: 'Yesterday',
|
||||
})
|
||||
).toEqual('Dec 14, 2019');
|
||||
});
|
||||
it('should format date as today ', () => {
|
||||
expect(
|
||||
formatDate({
|
||||
date: new Date(),
|
||||
todayText: 'Today',
|
||||
yesterdayText: 'Yesterday',
|
||||
})
|
||||
).toEqual('Today');
|
||||
});
|
||||
it('should format date as yesterday ', () => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
expect(
|
||||
formatDate({
|
||||
date: yesterday,
|
||||
todayText: 'Today',
|
||||
yesterdayText: 'Yesterday',
|
||||
})
|
||||
).toEqual('Yesterday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isTimeAfter', () => {
|
||||
it('return correct values', () => {
|
||||
expect(isTimeAfter(5, 30, 9, 30)).toEqual(false);
|
||||
expect(isTimeAfter(9, 30, 9, 30)).toEqual(true);
|
||||
expect(isTimeAfter(9, 29, 9, 30)).toEqual(false);
|
||||
expect(isTimeAfter(11, 59, 12, 0)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRelativeTime', () => {
|
||||
it('should return a string with the relative time', () => {
|
||||
const value = 1;
|
||||
const unit = 'second';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'in 1 second';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return a string with the relative time in a different language', () => {
|
||||
const value = 10;
|
||||
const unit = 'minute';
|
||||
const languageCode = 'de-DE';
|
||||
const expectedResult = 'in 10 Minuten';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return a string with the relative time for a different unit', () => {
|
||||
const value = 1;
|
||||
const unit = 'hour';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'in 1 hour';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should throw an error if the value is not a number', () => {
|
||||
const value = 1;
|
||||
const unit = 'day';
|
||||
const languageCode = 'en_US';
|
||||
const expectedResult = 'tomorrow';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should throw an error if the value is not a number', () => {
|
||||
const value = 1;
|
||||
const unit = 'day';
|
||||
const languageCode = 'en-US';
|
||||
const expectedResult = 'tomorrow';
|
||||
|
||||
const actualResult = generateRelativeTime(value, unit, languageCode);
|
||||
|
||||
expect(actualResult).toBe(expectedResult);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { removeEmoji } from '../emoji';
|
||||
|
||||
describe('#removeEmoji', () => {
|
||||
it('returns values without emoji', () => {
|
||||
expect(removeEmoji('😄Hi👋🏻 there❕')).toEqual('Hi there');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
formatBytes,
|
||||
fileSizeInMegaBytes,
|
||||
checkFileSizeLimit,
|
||||
resolveMaximumFileUploadSize,
|
||||
isFileTypeAllowedForChannel,
|
||||
} from '../FileHelper';
|
||||
|
||||
describe('#File Helpers', () => {
|
||||
describe('formatBytes', () => {
|
||||
it('should return zero bytes if 0 is passed', () => {
|
||||
expect(formatBytes(0)).toBe('0 Bytes');
|
||||
});
|
||||
it('should return in bytes if 1000 is passed', () => {
|
||||
expect(formatBytes(1000)).toBe('1000 Bytes');
|
||||
});
|
||||
it('should return in KB if 100000 is passed', () => {
|
||||
expect(formatBytes(10000)).toBe('9.77 KB');
|
||||
});
|
||||
it('should return in MB if 10000000 is passed', () => {
|
||||
expect(formatBytes(10000000)).toBe('9.54 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileSizeInMegaBytes', () => {
|
||||
it('should return zero if 0 is passed', () => {
|
||||
expect(fileSizeInMegaBytes(0)).toBe(0);
|
||||
});
|
||||
it('should return 19.07 if 20000000 is passed', () => {
|
||||
expect(fileSizeInMegaBytes(20000000)).toBeCloseTo(19.07, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkFileSizeLimit', () => {
|
||||
it('should return false if file with size 62208194 and file size limit 40 are passed', () => {
|
||||
expect(checkFileSizeLimit({ file: { size: 62208194 } }, 40)).toBe(false);
|
||||
});
|
||||
it('should return true if file with size 62208194 and file size limit 40 are passed', () => {
|
||||
expect(checkFileSizeLimit({ file: { size: 199154 } }, 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMaximumFileUploadSize', () => {
|
||||
it('should return default when value is undefined', () => {
|
||||
expect(resolveMaximumFileUploadSize(undefined)).toBe(
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
});
|
||||
|
||||
it('should return default when value is not a positive number', () => {
|
||||
expect(resolveMaximumFileUploadSize('not-a-number')).toBe(
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
expect(resolveMaximumFileUploadSize(-5)).toBe(
|
||||
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse numeric strings and numbers', () => {
|
||||
expect(resolveMaximumFileUploadSize('50')).toBe(50);
|
||||
expect(resolveMaximumFileUploadSize(75)).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileTypeAllowedForChannel', () => {
|
||||
describe('edge cases', () => {
|
||||
it('should return false for null file', () => {
|
||||
expect(isFileTypeAllowedForChannel(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined file', () => {
|
||||
expect(isFileTypeAllowedForChannel(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for file with zero size', () => {
|
||||
const file = { name: 'test.png', type: 'image/png', size: 0 };
|
||||
expect(isFileTypeAllowedForChannel(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wildcard MIME types', () => {
|
||||
it('should allow image/png when image/* is allowed', () => {
|
||||
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow image/jpeg when image/* is allowed', () => {
|
||||
const file = { name: 'test.jpg', type: 'image/jpeg', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow audio/mp3 when audio/* is allowed', () => {
|
||||
const file = { name: 'test.mp3', type: 'audio/mp3', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow video/mp4 when video/* is allowed', () => {
|
||||
const file = { name: 'test.mp4', type: 'video/mp4', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exact MIME types', () => {
|
||||
it('should allow application/pdf when explicitly allowed', () => {
|
||||
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow text/plain when explicitly allowed', () => {
|
||||
const file = { name: 'test.txt', type: 'text/plain', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file extensions', () => {
|
||||
it('should allow .3gpp extension when explicitly allowed', () => {
|
||||
const file = { name: 'test.3gpp', type: '', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Instagram special handling', () => {
|
||||
it('should use Instagram rules when isInstagramChannel is true', () => {
|
||||
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
isInstagramChannel: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should use Instagram rules when conversationType is instagram_direct_message', () => {
|
||||
const file = { name: 'test.png', type: 'image/png', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
conversationType: 'instagram_direct_message',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disallowed file types', () => {
|
||||
it('should reject executable files', () => {
|
||||
const file = {
|
||||
name: 'malware.exe',
|
||||
type: 'application/x-msdownload',
|
||||
size: 1000,
|
||||
};
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject unsupported file types', () => {
|
||||
const file = {
|
||||
name: 'test.xyz',
|
||||
type: 'application/x-unknown',
|
||||
size: 1000,
|
||||
};
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::WebWidget',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel-specific rules', () => {
|
||||
it('should allow WhatsApp-specific file types', () => {
|
||||
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::Whatsapp',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow Twilio WhatsApp-specific file types', () => {
|
||||
const file = { name: 'test.pdf', type: 'application/pdf', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private note file types', () => {
|
||||
it('should allow broader file types for private notes', () => {
|
||||
const file = {
|
||||
name: 'test.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1000,
|
||||
};
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::Line',
|
||||
isOnPrivateNote: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow CSV files in private notes', () => {
|
||||
const file = { name: 'data.csv', type: 'text/csv', size: 1000 };
|
||||
expect(
|
||||
isFileTypeAllowedForChannel(file, {
|
||||
channelType: 'Channel::Line',
|
||||
isOnPrivateNote: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
isEnter,
|
||||
isEscape,
|
||||
hasPressedShift,
|
||||
hasPressedCommand,
|
||||
isActiveElementTypeable,
|
||||
} from '../KeyboardHelpers';
|
||||
|
||||
describe('#KeyboardHelpers', () => {
|
||||
describe('#isEnter', () => {
|
||||
it('return correct values', () => {
|
||||
expect(isEnter({ key: 'Enter' })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isEscape', () => {
|
||||
it('return correct values', () => {
|
||||
expect(isEscape({ key: 'Escape' })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasPressedShift', () => {
|
||||
it('return correct values', () => {
|
||||
expect(hasPressedShift({ shiftKey: true })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasPressedCommand', () => {
|
||||
it('return correct values', () => {
|
||||
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActiveElementTypeable', () => {
|
||||
it('should return true if the active element is an input element', () => {
|
||||
const event = { target: document.createElement('input') };
|
||||
const result = isActiveElementTypeable(event);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the active element is a textarea element', () => {
|
||||
const event = { target: document.createElement('textarea') };
|
||||
const result = isActiveElementTypeable(event);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if the active element is a contentEditable element', () => {
|
||||
const element = document.createElement('div');
|
||||
element.contentEditable = 'true';
|
||||
const event = { target: element };
|
||||
const result = isActiveElementTypeable(event);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if the active element is not typeable', () => {
|
||||
const event = { target: document.createElement('div') };
|
||||
const result = isActiveElementTypeable(event);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the active element is null', () => {
|
||||
const event = { target: null };
|
||||
const result = isActiveElementTypeable(event);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import MessageFormatter from '../MessageFormatter';
|
||||
|
||||
describe('#MessageFormatter', () => {
|
||||
describe('content with links', () => {
|
||||
it('should format correctly', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">Chatwoot</a></p>'
|
||||
);
|
||||
});
|
||||
it('should format correctly', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. https://www.chatwoot.com';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
|
||||
);
|
||||
});
|
||||
it('should not convert template variables to links when linkify is disabled', () => {
|
||||
const message = 'Hey {{customer.name}}, check https://chatwoot.com';
|
||||
const formatter = new MessageFormatter(message, false, false, false);
|
||||
expect(formatter.formattedMessage).toMatch(
|
||||
'<p>Hey {{customer.name}}, check https://chatwoot.com</p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parses heading to strong', () => {
|
||||
it('should format correctly', () => {
|
||||
const message = '### opensource \n ## tool';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
`<h3>opensource</h3>
|
||||
<h2>tool</h2>`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content with image and has "cw_image_height" query at the end of URL', () => {
|
||||
it('should set image height correctly', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. ';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png?cw_image_height=24px" alt="" style="height: 24px;" /></p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set image height correctly if its original size', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. ';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png?cw_image_height=auto" alt="" style="height: auto;" /></p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set height', () => {
|
||||
const message =
|
||||
'Chatwoot is an opensource tool. ';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
'<p>Chatwoot is an opensource tool. <img src="http://chatwoot.com/chatwoot.png" alt="" /></p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tweets', () => {
|
||||
it('should return the same string if not tags or @mentions', () => {
|
||||
const message = 'Chatwoot is an opensource tool';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
|
||||
});
|
||||
|
||||
it('should add links to @mentions', () => {
|
||||
const message =
|
||||
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
|
||||
expect(
|
||||
new MessageFormatter(message, true, false).formattedMessage
|
||||
).toMatch(
|
||||
'<p><a href="http://twitter.com/chatwootapp" class="link" rel="noreferrer noopener nofollow" target="_blank">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername</p>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add links to #tags', () => {
|
||||
const message = '#chatwootapp is an opensource tool';
|
||||
expect(
|
||||
new MessageFormatter(message, true, false).formattedMessage
|
||||
).toMatch(
|
||||
'<p><a href="https://twitter.com/hashtag/chatwootapp" class="link" rel="noreferrer noopener nofollow" target="_blank">#chatwootapp</a> is an opensource tool</p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private notes', () => {
|
||||
it('should return the same string if not tags or @mentions', () => {
|
||||
const message = 'Chatwoot is an opensource tool';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(message);
|
||||
});
|
||||
|
||||
it('should add links to @mentions', () => {
|
||||
const message =
|
||||
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
|
||||
expect(
|
||||
new MessageFormatter(message, false, true).formattedMessage
|
||||
).toMatch(message);
|
||||
});
|
||||
|
||||
it('should add links to #tags', () => {
|
||||
const message = '#chatwootapp is an opensource tool';
|
||||
expect(
|
||||
new MessageFormatter(message, false, true).formattedMessage
|
||||
).toMatch(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain text content', () => {
|
||||
it('returns the plain text without HTML', () => {
|
||||
const message =
|
||||
'<b>Chatwoot is an opensource tool. https://www.chatwoot.com</b>';
|
||||
expect(new MessageFormatter(message).plainText).toMatch(
|
||||
'Chatwoot is an opensource tool. https://www.chatwoot.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sanitize', () => {
|
||||
it('sanitizes markup and removes all unnecessary elements', () => {
|
||||
const message =
|
||||
'[xssLink](javascript:alert(document.cookie))\n[normalLink](https://google.com)**I am a bold text paragraph**';
|
||||
expect(new MessageFormatter(message).formattedMessage).toMatch(
|
||||
`<p>[xssLink](javascript:alert(document.cookie))<br />
|
||||
<a href="https://google.com" class="link" rel="noreferrer noopener nofollow" target="_blank">normalLink</a><strong>I am a bold text paragraph</strong></p>`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isASubmittedFormMessage, isAFormMessage } from '../MessageTypeHelper';
|
||||
|
||||
describe('#isASubmittedFormMessage', () => {
|
||||
it('should return correct value', () => {
|
||||
expect(
|
||||
isASubmittedFormMessage({
|
||||
content_type: 'form',
|
||||
content_attributes: {
|
||||
submitted_values: [{ name: 'text', value: 'Text ' }],
|
||||
},
|
||||
})
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isAFormMessage', () => {
|
||||
it('should return correct value', () => {
|
||||
expect(
|
||||
isAFormMessage({
|
||||
content_type: 'form',
|
||||
})
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
groupHeatmapByDay,
|
||||
reconcileHeatmapData,
|
||||
flattenHeatmapData,
|
||||
clampDataBetweenTimeline,
|
||||
} from '../ReportsDataHelper';
|
||||
|
||||
describe('flattenHeatmapData', () => {
|
||||
it('should flatten heatmap data to key-value pairs', () => {
|
||||
const data = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614308400, value: 20 },
|
||||
];
|
||||
const expected = {
|
||||
1614265200: 10,
|
||||
1614308400: 20,
|
||||
};
|
||||
expect(flattenHeatmapData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const data = [];
|
||||
const expected = {};
|
||||
expect(flattenHeatmapData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle data with same timestamps', () => {
|
||||
const data = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614265200, value: 20 },
|
||||
];
|
||||
const expected = {
|
||||
1614265200: 20,
|
||||
};
|
||||
expect(flattenHeatmapData(data)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileHeatmapData', () => {
|
||||
it('should reconcile heatmap data with new data', () => {
|
||||
const data = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614308400, value: 20 },
|
||||
];
|
||||
const heatmapData = [
|
||||
{ timestamp: 1614265200, value: 5 },
|
||||
{ timestamp: 1614308400, value: 15 },
|
||||
{ timestamp: 1614387600, value: 25 },
|
||||
];
|
||||
const expected = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614308400, value: 20 },
|
||||
{ timestamp: 1614387600, value: 25 },
|
||||
];
|
||||
expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should reconcile heatmap data with new data and handle missing data', () => {
|
||||
const data = [{ timestamp: 1614308400, value: 20 }];
|
||||
const heatmapData = [
|
||||
{ timestamp: 1614265200, value: 5 },
|
||||
{ timestamp: 1614308400, value: 15 },
|
||||
{ timestamp: 1614387600, value: 25 },
|
||||
];
|
||||
const expected = [
|
||||
{ timestamp: 1614265200, value: 5 },
|
||||
{ timestamp: 1614308400, value: 20 },
|
||||
{ timestamp: 1614387600, value: 25 },
|
||||
];
|
||||
expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should replace empty heatmap data with a new array', () => {
|
||||
const data = [{ timestamp: 1614308400, value: 20 }];
|
||||
const heatmapData = [];
|
||||
expect(reconcileHeatmapData(data, heatmapData).length).toEqual(7 * 24);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupHeatmapByDay', () => {
|
||||
it('should group heatmap data by day', () => {
|
||||
const heatmapData = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614308400, value: 20 },
|
||||
{ timestamp: 1614387600, value: 30 },
|
||||
{ timestamp: 1614430800, value: 40 },
|
||||
{ timestamp: 1614499200, value: 50 },
|
||||
];
|
||||
|
||||
expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"2021-02-25T00:00:00.000Z" => [
|
||||
{
|
||||
"date": 2021-02-25T15:00:00.000Z,
|
||||
"hour": 15,
|
||||
"timestamp": 1614265200,
|
||||
"value": 10,
|
||||
},
|
||||
],
|
||||
"2021-02-26T00:00:00.000Z" => [
|
||||
{
|
||||
"date": 2021-02-26T03:00:00.000Z,
|
||||
"hour": 3,
|
||||
"timestamp": 1614308400,
|
||||
"value": 20,
|
||||
},
|
||||
],
|
||||
"2021-02-27T00:00:00.000Z" => [
|
||||
{
|
||||
"date": 2021-02-27T01:00:00.000Z,
|
||||
"hour": 1,
|
||||
"timestamp": 1614387600,
|
||||
"value": 30,
|
||||
},
|
||||
{
|
||||
"date": 2021-02-27T13:00:00.000Z,
|
||||
"hour": 13,
|
||||
"timestamp": 1614430800,
|
||||
"value": 40,
|
||||
},
|
||||
],
|
||||
"2021-02-28T00:00:00.000Z" => [
|
||||
{
|
||||
"date": 2021-02-28T08:00:00.000Z,
|
||||
"hour": 8,
|
||||
"timestamp": 1614499200,
|
||||
"value": 50,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should group empty heatmap data by day', () => {
|
||||
const heatmapData = [];
|
||||
const expected = new Map();
|
||||
expect(groupHeatmapByDay(heatmapData)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should group heatmap data with same timestamp in the same day', () => {
|
||||
const heatmapData = [
|
||||
{ timestamp: 1614265200, value: 10 },
|
||||
{ timestamp: 1614265200, value: 20 },
|
||||
];
|
||||
|
||||
expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(`
|
||||
Map {
|
||||
"2021-02-25T00:00:00.000Z" => [
|
||||
{
|
||||
"date": 2021-02-25T15:00:00.000Z,
|
||||
"hour": 15,
|
||||
"timestamp": 1614265200,
|
||||
"value": 10,
|
||||
},
|
||||
{
|
||||
"date": 2021-02-25T15:00:00.000Z,
|
||||
"hour": 15,
|
||||
"timestamp": 1614265200,
|
||||
"value": 20,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampDataBetweenTimeline', () => {
|
||||
const data = [
|
||||
{ timestamp: 1646054400, value: 'A' },
|
||||
{ timestamp: 1646054500, value: 'B' },
|
||||
{ timestamp: 1646054600, value: 'C' },
|
||||
{ timestamp: 1646054700, value: 'D' },
|
||||
{ timestamp: 1646054800, value: 'E' },
|
||||
];
|
||||
|
||||
it('should return empty array if data is empty', () => {
|
||||
expect(clampDataBetweenTimeline([], 1646054500, 1646054700)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if no data is within the timeline', () => {
|
||||
expect(clampDataBetweenTimeline(data, 1646054900, 1646055000)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the data as is no time limits are provider', () => {
|
||||
expect(clampDataBetweenTimeline(data)).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return all data if all data is within the timeline', () => {
|
||||
expect(clampDataBetweenTimeline(data, 1646054300, 1646054900)).toEqual(
|
||||
data
|
||||
);
|
||||
});
|
||||
|
||||
it('should return only data within the timeline', () => {
|
||||
expect(clampDataBetweenTimeline(data, 1646054500, 1646054700)).toEqual([
|
||||
{ timestamp: 1646054500, value: 'B' },
|
||||
{ timestamp: 1646054600, value: 'C' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array if from and to are the same', () => {
|
||||
expect(clampDataBetweenTimeline(data, 1646054500, 1646054500)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
shouldBeUrl,
|
||||
isPhoneNumberValidWithDialCode,
|
||||
isPhoneE164OrEmpty,
|
||||
isPhoneE164,
|
||||
startsWithPlus,
|
||||
isValidPassword,
|
||||
isPhoneNumberValid,
|
||||
isNumber,
|
||||
isDomain,
|
||||
getRegexp,
|
||||
isValidSlug,
|
||||
} from '../Validators';
|
||||
|
||||
describe('#shouldBeUrl', () => {
|
||||
it('should return correct url', () => {
|
||||
expect(shouldBeUrl('http')).toEqual(true);
|
||||
});
|
||||
it('should return wrong url', () => {
|
||||
expect(shouldBeUrl('')).toEqual(true);
|
||||
expect(shouldBeUrl('abc')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isPhoneE164', () => {
|
||||
it('should return correct phone number', () => {
|
||||
expect(isPhoneE164('+1234567890')).toEqual(true);
|
||||
});
|
||||
it('should return wrong phone number', () => {
|
||||
expect(isPhoneE164('1234567890')).toEqual(false);
|
||||
expect(isPhoneE164('12345678A9')).toEqual(false);
|
||||
expect(isPhoneE164('+12345678901234567890')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isPhoneE164OrEmpty', () => {
|
||||
it('should return correct phone number', () => {
|
||||
expect(isPhoneE164OrEmpty('+1234567890')).toEqual(true);
|
||||
expect(isPhoneE164OrEmpty('')).toEqual(true);
|
||||
});
|
||||
it('should return wrong phone number', () => {
|
||||
expect(isPhoneE164OrEmpty('1234567890')).toEqual(false);
|
||||
expect(isPhoneE164OrEmpty('12345678A9')).toEqual(false);
|
||||
expect(isPhoneE164OrEmpty('+12345678901234567890')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isPhoneNumberValid', () => {
|
||||
it('should return correct phone number', () => {
|
||||
expect(isPhoneNumberValid('1234567890', '+91')).toEqual(true);
|
||||
});
|
||||
it('should return wrong phone number', () => {
|
||||
expect(isPhoneNumberValid('12345A67890', '+1')).toEqual(false);
|
||||
expect(isPhoneNumberValid('12345A6789120', '+1')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isValidPassword', () => {
|
||||
it('should return correct password', () => {
|
||||
expect(isValidPassword('testPass4!')).toEqual(true);
|
||||
expect(isValidPassword('testPass4-')).toEqual(true);
|
||||
expect(isValidPassword('testPass4\\')).toEqual(true);
|
||||
expect(isValidPassword("testPass4'")).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return wrong password', () => {
|
||||
expect(isValidPassword('testpass4')).toEqual(false);
|
||||
expect(isValidPassword('testPass4')).toEqual(false);
|
||||
expect(isValidPassword('testpass4!')).toEqual(false);
|
||||
expect(isValidPassword('testPass!')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isNumber', () => {
|
||||
it('should return correct number', () => {
|
||||
expect(isNumber('123')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return wrong number', () => {
|
||||
expect(isNumber('123-')).toEqual(false);
|
||||
expect(isNumber('123./')).toEqual(false);
|
||||
expect(isNumber('string')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isDomain', () => {
|
||||
it('should return correct domain', () => {
|
||||
expect(isDomain('test.com')).toEqual(true);
|
||||
expect(isDomain('www.test.com')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return wrong domain', () => {
|
||||
expect(isDomain('test')).toEqual(false);
|
||||
expect(isDomain('test.')).toEqual(false);
|
||||
expect(isDomain('test.123')).toEqual(false);
|
||||
expect(isDomain('http://www.test.com')).toEqual(false);
|
||||
expect(isDomain('https://test.in')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isPhoneNumberValidWithDialCode', () => {
|
||||
it('should return correct phone number', () => {
|
||||
expect(isPhoneNumberValidWithDialCode('+123456789')).toEqual(true);
|
||||
expect(isPhoneNumberValidWithDialCode('+12345')).toEqual(true);
|
||||
});
|
||||
it('should return wrong phone number', () => {
|
||||
expect(isPhoneNumberValidWithDialCode('+123')).toEqual(false);
|
||||
expect(isPhoneNumberValidWithDialCode('+1234')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#startsWithPlus', () => {
|
||||
it('should return correct phone number', () => {
|
||||
expect(startsWithPlus('+123456789')).toEqual(true);
|
||||
});
|
||||
it('should return wrong phone number', () => {
|
||||
expect(startsWithPlus('123456789')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRegexp', () => {
|
||||
it('should create a correct RegExp object', () => {
|
||||
const regexPattern = '/^[a-z]+$/i';
|
||||
const regex = getRegexp(regexPattern);
|
||||
|
||||
expect(regex).toBeInstanceOf(RegExp);
|
||||
expect(regex.toString()).toBe(regexPattern);
|
||||
|
||||
expect(regex.test('abc')).toBe(true);
|
||||
expect(regex.test('ABC')).toBe(true);
|
||||
expect(regex.test('123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle regex with flags', () => {
|
||||
const regexPattern = '/hello/gi';
|
||||
const regex = getRegexp(regexPattern);
|
||||
|
||||
expect(regex).toBeInstanceOf(RegExp);
|
||||
expect(regex.toString()).toBe(regexPattern);
|
||||
|
||||
expect(regex.test('hello')).toBe(true);
|
||||
expect(regex.test('HELLO')).toBe(false);
|
||||
expect(regex.test('Hello World')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle regex with special characters', () => {
|
||||
const regexPattern = '/\\d{3}-\\d{2}-\\d{4}/';
|
||||
const regex = getRegexp(regexPattern);
|
||||
|
||||
expect(regex).toBeInstanceOf(RegExp);
|
||||
expect(regex.toString()).toBe(regexPattern);
|
||||
|
||||
expect(regex.test('123-45-6789')).toBe(true);
|
||||
expect(regex.test('12-34-5678')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isValidSlug', () => {
|
||||
it('should return true for valid slugs', () => {
|
||||
expect(isValidSlug('abc')).toEqual(true);
|
||||
expect(isValidSlug('abc-123')).toEqual(true);
|
||||
expect(isValidSlug('a-b-c')).toEqual(true);
|
||||
expect(isValidSlug('123')).toEqual(true);
|
||||
expect(isValidSlug('abc123-def')).toEqual(true);
|
||||
});
|
||||
it('should return false for invalid slugs', () => {
|
||||
expect(isValidSlug('abc_def')).toEqual(false);
|
||||
expect(isValidSlug('abc def')).toEqual(false);
|
||||
expect(isValidSlug('abc@def')).toEqual(false);
|
||||
expect(isValidSlug('abc.def')).toEqual(false);
|
||||
expect(isValidSlug('abc/def')).toEqual(false);
|
||||
expect(isValidSlug('abc!def')).toEqual(false);
|
||||
expect(isValidSlug('abc--def!')).toEqual(false);
|
||||
expect(isValidSlug('abc-def ')).toEqual(false);
|
||||
expect(isValidSlug(' abc-def')).toEqual(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getFromCache, setCache, clearCache } from '../cache';
|
||||
import { LocalStorage } from '../localStorage';
|
||||
|
||||
vi.mock('../localStorage');
|
||||
|
||||
describe('Cache Helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2023, 1, 1, 0, 0, 0));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getFromCache', () => {
|
||||
it('returns null when no data is cached', () => {
|
||||
LocalStorage.get.mockReturnValue(null);
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('returns cached data when not expired', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 1 hour ago
|
||||
const oneHourAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: oneHourAgo,
|
||||
});
|
||||
|
||||
// Default expiry is 24 hours
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toEqual({ foo: 'bar' });
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes and returns null when data is expired', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 25 hours ago (beyond the default 24-hour expiry)
|
||||
const twentyFiveHoursAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 25 * 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: twentyFiveHoursAgo,
|
||||
});
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('respects custom expiry time', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 2 hours ago
|
||||
const twoHoursAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 2 * 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: twoHoursAgo,
|
||||
});
|
||||
|
||||
// Set expiry to 1 hour
|
||||
const result = getFromCache('test-key', 60 * 60 * 1000);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.get.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCache', () => {
|
||||
it('stores data with timestamp', () => {
|
||||
const data = { name: 'test' };
|
||||
const expectedCacheData = {
|
||||
data,
|
||||
timestamp: new Date(2023, 1, 1, 0, 0, 0).getTime(),
|
||||
};
|
||||
|
||||
setCache('test-key', data);
|
||||
|
||||
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||
'test-key',
|
||||
expectedCacheData
|
||||
);
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.set.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => setCache('test-key', { foo: 'bar' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
it('removes cached data', () => {
|
||||
clearCache('test-key');
|
||||
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.remove.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => clearCache('test-key')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('copies plain text string to clipboard', async () => {
|
||||
const text = 'Hello World';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('copies empty string to clipboard', async () => {
|
||||
const text = '';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with number input', () => {
|
||||
it('converts number to string', async () => {
|
||||
await copyTextToClipboard(42);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('42');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts zero to string', async () => {
|
||||
await copyTextToClipboard(0);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('0');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with boolean input', () => {
|
||||
it('converts true to string', async () => {
|
||||
await copyTextToClipboard(true);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('true');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts false to string', async () => {
|
||||
await copyTextToClipboard(false);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('false');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with null/undefined input', () => {
|
||||
it('converts null to empty string', async () => {
|
||||
await copyTextToClipboard(null);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts undefined to empty string', async () => {
|
||||
await copyTextToClipboard(undefined);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('stringifies simple object with proper formatting', async () => {
|
||||
const obj = { name: 'John', age: 30 };
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
const expectedJson = JSON.stringify(obj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies nested object with proper formatting', async () => {
|
||||
const nestedObj = {
|
||||
severity: {
|
||||
user_id: 1181505,
|
||||
user_name: 'test',
|
||||
server_name: '[1253]test1253',
|
||||
},
|
||||
};
|
||||
await copyTextToClipboard(nestedObj);
|
||||
|
||||
const expectedJson = JSON.stringify(nestedObj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies array with proper formatting', async () => {
|
||||
const arr = [1, 2, { name: 'test' }];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
const expectedJson = JSON.stringify(arr, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty object', async () => {
|
||||
const obj = {};
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('{}');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty array', async () => {
|
||||
const arr = [];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('[]');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws error when clipboard API fails', async () => {
|
||||
const error = new Error('Clipboard access denied');
|
||||
mockWriteText.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard: Clipboard access denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles clipboard API not available', async () => {
|
||||
// Temporarily remove clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
delete navigator.clipboard;
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard:'
|
||||
);
|
||||
|
||||
// Restore clipboard API
|
||||
navigator.clipboard = originalClipboard;
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Date objects', async () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
await copyTextToClipboard(date);
|
||||
|
||||
const expectedJson = JSON.stringify(date, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles functions by converting to string', async () => {
|
||||
const func = () => 'test';
|
||||
await copyTextToClipboard(func);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOtpPaste', () => {
|
||||
// Helper function to create mock clipboard event
|
||||
const createMockPasteEvent = text => ({
|
||||
clipboardData: {
|
||||
getData: vi.fn().mockReturnValue(text),
|
||||
},
|
||||
});
|
||||
|
||||
describe('valid OTP paste scenarios', () => {
|
||||
it('extracts 6-digit OTP from clean numeric string', () => {
|
||||
const event = createMockPasteEvent('123456');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
expect(event.clipboardData.getData).toHaveBeenCalledWith('text');
|
||||
});
|
||||
|
||||
it('extracts 6-digit OTP from string with spaces', () => {
|
||||
const event = createMockPasteEvent('1 2 3 4 5 6');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
});
|
||||
|
||||
it('extracts 6-digit OTP from string with dashes', () => {
|
||||
const event = createMockPasteEvent('123-456');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
});
|
||||
|
||||
it('handles negative numbers by extracting digits only', () => {
|
||||
const event = createMockPasteEvent('-123456');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
});
|
||||
|
||||
it('handles decimal numbers by extracting digits only', () => {
|
||||
const event = createMockPasteEvent('123.456');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
});
|
||||
|
||||
it('extracts 6-digit OTP from mixed alphanumeric string', () => {
|
||||
const event = createMockPasteEvent('Your code is: 987654');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('987654');
|
||||
});
|
||||
|
||||
it('extracts first 6 digits when more than 6 digits present', () => {
|
||||
const event = createMockPasteEvent('12345678901234');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBe('123456');
|
||||
});
|
||||
|
||||
it('handles custom maxLength parameter', () => {
|
||||
const event = createMockPasteEvent('12345678');
|
||||
const result = handleOtpPaste(event, 8);
|
||||
|
||||
expect(result).toBe('12345678');
|
||||
});
|
||||
|
||||
it('extracts 4-digit OTP with custom maxLength', () => {
|
||||
const event = createMockPasteEvent('Your PIN: 9876');
|
||||
const result = handleOtpPaste(event, 4);
|
||||
|
||||
expect(result).toBe('9876');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid OTP paste scenarios', () => {
|
||||
it('returns null for insufficient digits', () => {
|
||||
const event = createMockPasteEvent('12345');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for text with no digits', () => {
|
||||
const event = createMockPasteEvent('Hello World');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
const event = createMockPasteEvent('');
|
||||
const result = handleOtpPaste(event);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when event is null', () => {
|
||||
const result = handleOtpPaste(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when event is undefined', () => {
|
||||
const result = handleOtpPaste(undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { toHex, getContrast } from 'color2k';
|
||||
import {
|
||||
isWidgetColorLighter,
|
||||
adjustColorForContrast,
|
||||
} from 'shared/helpers/colorHelper';
|
||||
|
||||
describe('#isWidgetColorLighter', () => {
|
||||
it('returns true if color is lighter', () => {
|
||||
expect(isWidgetColorLighter('#ffffff')).toEqual(true);
|
||||
});
|
||||
it('returns false if color is darker', () => {
|
||||
expect(isWidgetColorLighter('#000000')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#adjustColorForContrast', () => {
|
||||
const targetRatio = 3.1;
|
||||
|
||||
const getContrastRatio = (color1, color2) => {
|
||||
// getContrast from 'color2k'
|
||||
return getContrast(color1, color2);
|
||||
};
|
||||
|
||||
it('adjusts a color to meet the contrast ratio against a light background', () => {
|
||||
const color = '#ff0000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
});
|
||||
|
||||
it('adjusts a color to meet the contrast ratio against a dark background', () => {
|
||||
const color = '#00ff00';
|
||||
const backgroundColor = '#000000';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
});
|
||||
|
||||
it('returns a string representation of the color', () => {
|
||||
const color = '#00ff00';
|
||||
const backgroundColor = '#000000';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
|
||||
expect(typeof adjustedColor).toEqual('string');
|
||||
});
|
||||
|
||||
it('handles cases where the color already meets the contrast ratio', () => {
|
||||
const color = '#000000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
expect(adjustedColor).toEqual(toHex(color));
|
||||
});
|
||||
|
||||
it('does not modify a color that already exceeds the contrast ratio', () => {
|
||||
const color = '#000000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
|
||||
expect(adjustedColor).toEqual(toHex(color));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
isPdfDocument,
|
||||
formatDocumentLink,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
|
||||
describe('documentHelper', () => {
|
||||
describe('#isPdfDocument', () => {
|
||||
it('returns true for PDF documents', () => {
|
||||
expect(isPdfDocument('PDF:document.pdf')).toBe(true);
|
||||
expect(isPdfDocument('PDF:my-file_20241227123045.pdf')).toBe(true);
|
||||
expect(isPdfDocument('PDF:report with spaces_20241227123045.pdf')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for regular URLs', () => {
|
||||
expect(isPdfDocument('https://example.com')).toBe(false);
|
||||
expect(isPdfDocument('http://docs.example.com/file.pdf')).toBe(false);
|
||||
expect(isPdfDocument('ftp://files.example.com/document.pdf')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty or null values', () => {
|
||||
expect(isPdfDocument('')).toBe(false);
|
||||
expect(isPdfDocument(null)).toBe(false);
|
||||
expect(isPdfDocument(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for strings that contain PDF but do not start with PDF:', () => {
|
||||
expect(isPdfDocument('document PDF:file.pdf')).toBe(false);
|
||||
expect(isPdfDocument('My PDF:file.pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#formatDocumentLink', () => {
|
||||
describe('PDF documents', () => {
|
||||
it('removes PDF: prefix from PDF documents', () => {
|
||||
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
|
||||
expect(formatDocumentLink('PDF:my-file.pdf')).toBe('my-file.pdf');
|
||||
});
|
||||
|
||||
it('removes timestamp suffix from PDF documents', () => {
|
||||
expect(formatDocumentLink('PDF:document_20241227123045.pdf')).toBe(
|
||||
'document.pdf'
|
||||
);
|
||||
expect(formatDocumentLink('PDF:report_20231215094530.pdf')).toBe(
|
||||
'report.pdf'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles PDF documents with spaces in filename', () => {
|
||||
expect(formatDocumentLink('PDF:my document_20241227123045.pdf')).toBe(
|
||||
'my document.pdf'
|
||||
);
|
||||
expect(
|
||||
formatDocumentLink('PDF:Annual Report 2024_20241227123045.pdf')
|
||||
).toBe('Annual Report 2024.pdf');
|
||||
});
|
||||
|
||||
it('handles PDF documents without timestamp suffix', () => {
|
||||
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
|
||||
expect(formatDocumentLink('PDF:simple-file.pdf')).toBe(
|
||||
'simple-file.pdf'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles PDF documents with partial timestamp patterns', () => {
|
||||
expect(formatDocumentLink('PDF:document_202412.pdf')).toBe(
|
||||
'document_202412.pdf'
|
||||
);
|
||||
expect(formatDocumentLink('PDF:file_123.pdf')).toBe('file_123.pdf');
|
||||
});
|
||||
|
||||
it('handles edge cases with timestamp pattern', () => {
|
||||
expect(
|
||||
formatDocumentLink('PDF:doc_20241227123045_final_20241227123045.pdf')
|
||||
).toBe('doc_20241227123045_final.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regular URLs', () => {
|
||||
it('returns regular URLs unchanged', () => {
|
||||
expect(formatDocumentLink('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
|
||||
'http://docs.example.com/api'
|
||||
);
|
||||
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
|
||||
'https://github.com/user/repo'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles URLs with query parameters', () => {
|
||||
expect(formatDocumentLink('https://example.com?param=value')).toBe(
|
||||
'https://example.com?param=value'
|
||||
);
|
||||
expect(
|
||||
formatDocumentLink(
|
||||
'https://api.example.com/docs?version=v1&format=json'
|
||||
)
|
||||
).toBe('https://api.example.com/docs?version=v1&format=json');
|
||||
});
|
||||
|
||||
it('handles URLs with fragments', () => {
|
||||
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
|
||||
'https://example.com/docs#section1'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { LocalStorage } from '../localStorage';
|
||||
|
||||
// Mocking localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: key => store[key] || null,
|
||||
setItem: (key, value) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: key => delete store[key],
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
describe('LocalStorage utility', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('set and get methods', () => {
|
||||
LocalStorage.set('testKey', { a: 1 });
|
||||
expect(LocalStorage.get('testKey')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('remove method', () => {
|
||||
LocalStorage.set('testKey', 'testValue');
|
||||
LocalStorage.remove('testKey');
|
||||
expect(LocalStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('updateJsonStore method', () => {
|
||||
LocalStorage.updateJsonStore('testStore', 'testKey', 'testValue');
|
||||
expect(LocalStorage.get('testStore')).toEqual({ testKey: 'testValue' });
|
||||
});
|
||||
|
||||
it('getFromJsonStore method', () => {
|
||||
LocalStorage.set('testStore', { testKey: 'testValue' });
|
||||
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBe(
|
||||
'testValue'
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteFromJsonStore method', () => {
|
||||
LocalStorage.set('testStore', { testKey: 'testValue' });
|
||||
LocalStorage.deleteFromJsonStore('testStore', 'testKey');
|
||||
expect(LocalStorage.getFromJsonStore('testStore', 'testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('setFlag and getFlag methods', () => {
|
||||
const store = 'testStore';
|
||||
const accountId = '123';
|
||||
const key = 'flagKey';
|
||||
const expiry = 1000; // 1 second
|
||||
|
||||
// Set flag and verify it's set
|
||||
LocalStorage.setFlag(store, accountId, key, expiry);
|
||||
expect(LocalStorage.getFlag(store, accountId, key)).toBe(true);
|
||||
|
||||
// Wait for expiry and verify flag is not set
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
expect(LocalStorage.getFlag(store, accountId, key)).toBe(false);
|
||||
resolve();
|
||||
}, expiry + 100); // wait a bit more than expiry time to ensure the flag has expired
|
||||
});
|
||||
});
|
||||
|
||||
it('clearAll method', () => {
|
||||
LocalStorage.set('testKey1', 'testValue1');
|
||||
LocalStorage.set('testKey2', 'testValue2');
|
||||
LocalStorage.clearAll();
|
||||
expect(LocalStorage.get('testKey1')).toBeNull();
|
||||
expect(LocalStorage.get('testKey2')).toBeNull();
|
||||
});
|
||||
|
||||
it('set method with non-object value', () => {
|
||||
LocalStorage.set('testKey', 'testValue');
|
||||
expect(LocalStorage.get('testKey')).toBe('testValue');
|
||||
});
|
||||
|
||||
it('set and get methods with null value', () => {
|
||||
LocalStorage.set('testKey', null);
|
||||
expect(LocalStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('set and get methods with undefined value', () => {
|
||||
LocalStorage.set('testKey', undefined);
|
||||
expect(LocalStorage.get('testKey')).toBe('undefined');
|
||||
});
|
||||
|
||||
it('set and get methods with boolean value', () => {
|
||||
LocalStorage.set('testKey', true);
|
||||
expect(LocalStorage.get('testKey')).toBe(true);
|
||||
});
|
||||
|
||||
it('set and get methods with number value', () => {
|
||||
LocalStorage.set('testKey', 42);
|
||||
expect(LocalStorage.get('testKey')).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { emitter } from '../mitt';
|
||||
|
||||
describe('emitter', () => {
|
||||
it('should emit and listen to events correctly', () => {
|
||||
const mockCallback = vi.fn();
|
||||
|
||||
// Subscribe to an event
|
||||
emitter.on('event', mockCallback);
|
||||
|
||||
// Emit the event
|
||||
emitter.emit('event', 'data');
|
||||
|
||||
// Expect the callback to be called with the correct data
|
||||
expect(mockCallback).toHaveBeenCalledWith('data');
|
||||
|
||||
// Unsubscribe from the event
|
||||
emitter.off('event', mockCallback);
|
||||
|
||||
// Emit the event again
|
||||
emitter.emit('event', 'data');
|
||||
|
||||
// Expect the callback not to be called again
|
||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
|
||||
describe('portalHelper - getMatchingLocale', () => {
|
||||
it('returns exact match when present', () => {
|
||||
const result = getMatchingLocale('fr', ['en', 'fr']);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
|
||||
it('returns base language match when exact variant not present', () => {
|
||||
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
|
||||
expect(result).toBe('fr');
|
||||
});
|
||||
|
||||
it('returns variant match when base language not present', () => {
|
||||
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
|
||||
expect(result).toBe('fr_BE');
|
||||
});
|
||||
|
||||
it('returns null when no match found', () => {
|
||||
const result = getMatchingLocale('de', ['en', 'fr']);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid inputs', () => {
|
||||
expect(getMatchingLocale('', [])).toBeNull();
|
||||
expect(getMatchingLocale(null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { sanitizeLabel } from '../sanitizeData';
|
||||
|
||||
describe('sanitizeLabel', () => {
|
||||
it('should return an empty string when given an empty string', () => {
|
||||
const label = '';
|
||||
const sanitizedLabel = sanitizeLabel(label);
|
||||
expect(sanitizedLabel).toEqual('');
|
||||
});
|
||||
|
||||
it('should remove leading and trailing whitespace', () => {
|
||||
const label = ' My Label ';
|
||||
const sanitizedLabel = sanitizeLabel(label);
|
||||
expect(sanitizedLabel).toEqual('my-label');
|
||||
});
|
||||
|
||||
it('should convert all characters to lowercase', () => {
|
||||
const label = 'My Label';
|
||||
const sanitizedLabel = sanitizeLabel(label);
|
||||
expect(sanitizedLabel).toEqual('my-label');
|
||||
});
|
||||
|
||||
it('should replace spaces with hyphens', () => {
|
||||
const label = 'My Label 123';
|
||||
const sanitizedLabel = sanitizeLabel(label);
|
||||
expect(sanitizedLabel).toEqual('my-label-123');
|
||||
});
|
||||
|
||||
it('should remove any characters that are not alphanumeric, underscore, or hyphen', () => {
|
||||
const label = 'My_Label!123';
|
||||
const sanitizedLabel = sanitizeLabel(label);
|
||||
expect(sanitizedLabel).toEqual('my_label123');
|
||||
});
|
||||
|
||||
it('should handle null and undefined input', () => {
|
||||
const nullLabel = null;
|
||||
const undefinedLabel = undefined;
|
||||
|
||||
// @ts-ignore - intentionally passing null and undefined to test
|
||||
const sanitizedNullLabel = sanitizeLabel(nullLabel);
|
||||
const sanitizedUndefinedLabel = sanitizeLabel(undefinedLabel);
|
||||
expect(sanitizedNullLabel).toEqual('');
|
||||
expect(sanitizedUndefinedLabel).toEqual('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import SessionStorage from '../sessionStorage';
|
||||
|
||||
// Mocking sessionStorage
|
||||
const sessionStorageMock = (() => {
|
||||
let store = {};
|
||||
return {
|
||||
getItem: key => store[key] || null,
|
||||
setItem: (key, value) => {
|
||||
store[key] = String(value);
|
||||
},
|
||||
removeItem: key => delete store[key],
|
||||
clear: () => {
|
||||
store = {};
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
describe('SessionStorage utility', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('clearAll method', () => {
|
||||
it('should clear all items from sessionStorage', () => {
|
||||
sessionStorage.setItem('testKey1', 'testValue1');
|
||||
sessionStorage.setItem('testKey2', 'testValue2');
|
||||
|
||||
SessionStorage.clearAll();
|
||||
|
||||
expect(sessionStorage.getItem('testKey1')).toBeNull();
|
||||
expect(sessionStorage.getItem('testKey2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('get method', () => {
|
||||
it('should retrieve and parse JSON values correctly', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
sessionStorage.setItem('testKey', JSON.stringify(testObject));
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual(testObject);
|
||||
});
|
||||
|
||||
it('should return null for non-existent keys', () => {
|
||||
expect(SessionStorage.get('nonExistentKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle non-JSON values by returning the raw value', () => {
|
||||
sessionStorage.setItem('testKey', 'plain string value');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('plain string value');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON gracefully', () => {
|
||||
sessionStorage.setItem('testKey', '{malformed:json}');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBe('{malformed:json}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set method', () => {
|
||||
it('should store object values as JSON strings', () => {
|
||||
const testObject = { a: 1, b: 'test' };
|
||||
SessionStorage.set('testKey', testObject);
|
||||
|
||||
expect(sessionStorage.getItem('testKey')).toBe(
|
||||
JSON.stringify(testObject)
|
||||
);
|
||||
});
|
||||
|
||||
it('should store primitive values directly', () => {
|
||||
SessionStorage.set('stringKey', 'test string');
|
||||
expect(sessionStorage.getItem('stringKey')).toBe('test string');
|
||||
|
||||
SessionStorage.set('numberKey', 42);
|
||||
expect(sessionStorage.getItem('numberKey')).toBe('42');
|
||||
|
||||
SessionStorage.set('booleanKey', true);
|
||||
expect(sessionStorage.getItem('booleanKey')).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
SessionStorage.set('nullKey', null);
|
||||
|
||||
expect(sessionStorage.getItem('nullKey')).toBe('null');
|
||||
expect(SessionStorage.get('nullKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
SessionStorage.set('undefinedKey', undefined);
|
||||
|
||||
expect(sessionStorage.getItem('undefinedKey')).toBe('undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove method', () => {
|
||||
it('should remove an item from sessionStorage', () => {
|
||||
SessionStorage.set('testKey', 'testValue');
|
||||
expect(SessionStorage.get('testKey')).toBe('testValue');
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should do nothing when removing a non-existent key', () => {
|
||||
expect(() => {
|
||||
SessionStorage.remove('nonExistentKey');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration of methods', () => {
|
||||
it('should set, get, and remove values correctly', () => {
|
||||
SessionStorage.set('testKey', { value: 'test' });
|
||||
|
||||
expect(SessionStorage.get('testKey')).toEqual({ value: 'test' });
|
||||
|
||||
SessionStorage.remove('testKey');
|
||||
expect(SessionStorage.get('testKey')).toBeNull();
|
||||
});
|
||||
|
||||
it('should correctly handle impersonation flag (common use case)', () => {
|
||||
SessionStorage.set('impersonationUser', true);
|
||||
|
||||
expect(SessionStorage.get('impersonationUser')).toBe(true);
|
||||
|
||||
expect(sessionStorage.getItem('impersonationUser')).toBe('true');
|
||||
|
||||
SessionStorage.remove('impersonationUser');
|
||||
expect(SessionStorage.get('impersonationUser')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
messageStamp,
|
||||
messageTimestamp,
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
shortTimestamp,
|
||||
getDayDifferenceFromNow,
|
||||
hasOneDayPassed,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TZ = 'UTC';
|
||||
vi.useFakeTimers('modern');
|
||||
const mockDate = new Date(Date.UTC(2023, 4, 5));
|
||||
vi.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('#messageStamp', () => {
|
||||
it('returns correct value', () => {
|
||||
expect(messageStamp(1612971343)).toEqual('3:35 PM');
|
||||
expect(messageStamp(1612971343, 'LLL d, h:mm a')).toEqual(
|
||||
'Feb 10, 3:35 PM'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#messageTimestamp', () => {
|
||||
it('should return the message date in the specified format if the message was sent in the current year', () => {
|
||||
expect(messageTimestamp(1680777464)).toEqual('Apr 6, 2023');
|
||||
});
|
||||
it('should return the message date and time in a different format if the message was sent in a different year', () => {
|
||||
expect(messageTimestamp(1612971343)).toEqual('Feb 10 2021, 3:35 PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#dynamicTime', () => {
|
||||
it('returns correct value', () => {
|
||||
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
|
||||
expect(dynamicTime(1612971343)).toEqual('about 2 years ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#dateFormat', () => {
|
||||
it('returns correct value', () => {
|
||||
expect(dateFormat(1612971343)).toEqual('Feb 10, 2021');
|
||||
expect(dateFormat(1612971343, 'LLL d, yyyy')).toEqual('Feb 10, 2021');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#shortTimestamp', () => {
|
||||
// Test cases when withAgo is false or not provided
|
||||
it('returns correct value without ago', () => {
|
||||
expect(shortTimestamp('less than a minute ago')).toEqual('now');
|
||||
expect(shortTimestamp('1 minute ago')).toEqual('1m');
|
||||
expect(shortTimestamp('12 minutes ago')).toEqual('12m');
|
||||
expect(shortTimestamp('a minute ago')).toEqual('1m');
|
||||
expect(shortTimestamp('an hour ago')).toEqual('1h');
|
||||
expect(shortTimestamp('1 hour ago')).toEqual('1h');
|
||||
expect(shortTimestamp('2 hours ago')).toEqual('2h');
|
||||
expect(shortTimestamp('1 day ago')).toEqual('1d');
|
||||
expect(shortTimestamp('a day ago')).toEqual('1d');
|
||||
expect(shortTimestamp('3 days ago')).toEqual('3d');
|
||||
expect(shortTimestamp('a month ago')).toEqual('1mo');
|
||||
expect(shortTimestamp('1 month ago')).toEqual('1mo');
|
||||
expect(shortTimestamp('2 months ago')).toEqual('2mo');
|
||||
expect(shortTimestamp('a year ago')).toEqual('1y');
|
||||
expect(shortTimestamp('1 year ago')).toEqual('1y');
|
||||
expect(shortTimestamp('4 years ago')).toEqual('4y');
|
||||
});
|
||||
|
||||
// Test cases when withAgo is true
|
||||
it('returns correct value with ago', () => {
|
||||
expect(shortTimestamp('less than a minute ago', true)).toEqual('now');
|
||||
expect(shortTimestamp('1 minute ago', true)).toEqual('1m ago');
|
||||
expect(shortTimestamp('12 minutes ago', true)).toEqual('12m ago');
|
||||
expect(shortTimestamp('a minute ago', true)).toEqual('1m ago');
|
||||
expect(shortTimestamp('an hour ago', true)).toEqual('1h ago');
|
||||
expect(shortTimestamp('1 hour ago', true)).toEqual('1h ago');
|
||||
expect(shortTimestamp('2 hours ago', true)).toEqual('2h ago');
|
||||
expect(shortTimestamp('1 day ago', true)).toEqual('1d ago');
|
||||
expect(shortTimestamp('a day ago', true)).toEqual('1d ago');
|
||||
expect(shortTimestamp('3 days ago', true)).toEqual('3d ago');
|
||||
expect(shortTimestamp('a month ago', true)).toEqual('1mo ago');
|
||||
expect(shortTimestamp('1 month ago', true)).toEqual('1mo ago');
|
||||
expect(shortTimestamp('2 months ago', true)).toEqual('2mo ago');
|
||||
expect(shortTimestamp('a year ago', true)).toEqual('1y ago');
|
||||
expect(shortTimestamp('1 year ago', true)).toEqual('1y ago');
|
||||
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getDayDifferenceFromNow', () => {
|
||||
it('returns 0 for timestamps from today', () => {
|
||||
// Mock current date: May 5, 2023
|
||||
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
|
||||
const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
|
||||
|
||||
expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns 2 for timestamps from 2 days ago', () => {
|
||||
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
|
||||
const twoDaysAgoTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
|
||||
); // May 3, 2023
|
||||
|
||||
expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
|
||||
});
|
||||
|
||||
it('returns 7 for timestamps from a week ago', () => {
|
||||
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
|
||||
const weekAgoTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
|
||||
); // April 28, 2023
|
||||
|
||||
expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
|
||||
});
|
||||
|
||||
it('returns 30 for timestamps from a month ago', () => {
|
||||
const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
|
||||
const monthAgoTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
|
||||
); // April 5, 2023
|
||||
|
||||
expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
|
||||
});
|
||||
|
||||
it('handles edge case with different times on same day', () => {
|
||||
const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
|
||||
const morningTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
|
||||
); // May 5, 2023 12:00:01 AM
|
||||
|
||||
expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
|
||||
});
|
||||
|
||||
it('handles cross-month boundaries correctly', () => {
|
||||
const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
|
||||
const lastMonthTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
|
||||
); // April 30, 2023
|
||||
|
||||
expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
|
||||
});
|
||||
|
||||
it('handles cross-year boundaries correctly', () => {
|
||||
const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
|
||||
const lastYearTimestamp = Math.floor(
|
||||
new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
|
||||
); // December 31, 2022
|
||||
|
||||
expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasOneDayPassed', () => {
|
||||
beforeEach(() => {
|
||||
// Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
|
||||
const mockDate = new Date(1683288000 * 1000);
|
||||
vi.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
it('returns false for timestamps from today', () => {
|
||||
// Same day, different time - May 5, 2023 8:00 AM UTC
|
||||
const todayTimestamp = 1683273600;
|
||||
|
||||
expect(hasOneDayPassed(todayTimestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for timestamps from yesterday (less than 24 hours)', () => {
|
||||
// Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
|
||||
const yesterdayTimestamp = 1683230400;
|
||||
|
||||
expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for timestamps from exactly 1 day ago', () => {
|
||||
// Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
|
||||
const oneDayAgoTimestamp = 1683201600;
|
||||
|
||||
expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for timestamps from more than 1 day ago', () => {
|
||||
// 2 days ago - May 3, 2023 10:00 AM UTC
|
||||
const twoDaysAgoTimestamp = 1683108000;
|
||||
|
||||
expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for timestamps from a week ago', () => {
|
||||
// 7 days ago - April 28, 2023 8:00 AM UTC
|
||||
const weekAgoTimestamp = 1682668800;
|
||||
|
||||
expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for null timestamp (defensive check)', () => {
|
||||
expect(hasOneDayPassed(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for undefined timestamp (defensive check)', () => {
|
||||
expect(hasOneDayPassed(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for zero timestamp (defensive check)', () => {
|
||||
expect(hasOneDayPassed(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for empty string timestamp (defensive check)', () => {
|
||||
expect(hasOneDayPassed('')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles cross-month boundaries correctly', () => {
|
||||
// Set current time to May 1, 2023 12:00 PM UTC (1682942400)
|
||||
const mayFirst = new Date(1682942400 * 1000);
|
||||
vi.setSystemTime(mayFirst);
|
||||
|
||||
// April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
|
||||
const crossMonthTimestamp = 1682769600;
|
||||
|
||||
expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles cross-year boundaries correctly', () => {
|
||||
// Set current time to January 2, 2023 12:00 PM UTC (1672660800)
|
||||
const newYear = new Date(1672660800 * 1000);
|
||||
vi.setSystemTime(newYear);
|
||||
|
||||
// December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
|
||||
const crossYearTimestamp = 1672401600;
|
||||
|
||||
expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
|
||||
});
|
||||
});
|
||||
116
research/chatwoot/app/javascript/shared/helpers/timeHelper.js
Normal file
116
research/chatwoot/app/javascript/shared/helpers/timeHelper.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
format,
|
||||
isSameYear,
|
||||
fromUnixTime,
|
||||
formatDistanceToNow,
|
||||
differenceInDays,
|
||||
} from 'date-fns';
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp into a human-readable time format.
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @param {string} [dateFormat='h:mm a'] - Desired format of the time.
|
||||
* @returns {string} Formatted time string.
|
||||
*/
|
||||
export const messageStamp = (time, dateFormat = 'h:mm a') => {
|
||||
const unixTime = fromUnixTime(time);
|
||||
return format(unixTime, dateFormat);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides a formatted timestamp, adjusting the format based on the current year.
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @param {string} [dateFormat='MMM d, yyyy'] - Desired date format.
|
||||
* @returns {string} Formatted date string.
|
||||
*/
|
||||
export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
|
||||
const messageTime = fromUnixTime(time);
|
||||
const now = new Date();
|
||||
const messageDate = format(messageTime, dateFormat);
|
||||
if (!isSameYear(messageTime, now)) {
|
||||
return format(messageTime, 'LLL d y, h:mm a');
|
||||
}
|
||||
return messageDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @returns {string} Relative time string.
|
||||
*/
|
||||
export const dynamicTime = time => {
|
||||
const unixTime = fromUnixTime(time);
|
||||
return formatDistanceToNow(unixTime, { addSuffix: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp into a specified date format.
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @param {string} [dateFormat='MMM d, yyyy'] - Desired date format.
|
||||
* @returns {string} Formatted date string.
|
||||
*/
|
||||
export const dateFormat = (time, df = 'MMM d, yyyy') => {
|
||||
const unixTime = fromUnixTime(time);
|
||||
return format(unixTime, df);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a detailed time description into a shorter format, optionally appending 'ago'.
|
||||
* @param {string} time - Detailed time description (e.g., 'a minute ago').
|
||||
* @param {boolean} [withAgo=false] - Whether to append 'ago' to the result.
|
||||
* @returns {string} Shortened time description.
|
||||
*/
|
||||
export const shortTimestamp = (time, withAgo = false) => {
|
||||
// This function takes a time string and converts it to a short time string
|
||||
// with the following format: 1m, 1h, 1d, 1mo, 1y
|
||||
// The function also takes an optional boolean parameter withAgo
|
||||
// which will add the word "ago" to the end of the time string
|
||||
const suffix = withAgo ? ' ago' : '';
|
||||
const timeMappings = {
|
||||
'less than a minute ago': 'now',
|
||||
'a minute ago': `1m${suffix}`,
|
||||
'an hour ago': `1h${suffix}`,
|
||||
'a day ago': `1d${suffix}`,
|
||||
'a month ago': `1mo${suffix}`,
|
||||
'a year ago': `1y${suffix}`,
|
||||
};
|
||||
// Check if the time string is one of the specific cases
|
||||
if (timeMappings[time]) {
|
||||
return timeMappings[time];
|
||||
}
|
||||
const convertToShortTime = time
|
||||
.replace(/about|over|almost|/g, '')
|
||||
.replace(' minute ago', `m${suffix}`)
|
||||
.replace(' minutes ago', `m${suffix}`)
|
||||
.replace(' hour ago', `h${suffix}`)
|
||||
.replace(' hours ago', `h${suffix}`)
|
||||
.replace(' day ago', `d${suffix}`)
|
||||
.replace(' days ago', `d${suffix}`)
|
||||
.replace(' month ago', `mo${suffix}`)
|
||||
.replace(' months ago', `mo${suffix}`)
|
||||
.replace(' year ago', `y${suffix}`)
|
||||
.replace(' years ago', `y${suffix}`);
|
||||
return convertToShortTime;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the difference in days between now and a given timestamp.
|
||||
* @param {Date} now - Current date/time.
|
||||
* @param {number} timestampInSeconds - Unix timestamp in seconds.
|
||||
* @returns {number} Number of days difference.
|
||||
*/
|
||||
export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
|
||||
const date = new Date(timestampInSeconds * 1000);
|
||||
return differenceInDays(now, date);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if more than 24 hours have passed since a given timestamp.
|
||||
* Useful for determining if retry/refresh actions should be disabled.
|
||||
* @param {number} timestamp - Unix timestamp.
|
||||
* @returns {boolean} True if more than 24 hours have passed.
|
||||
*/
|
||||
export const hasOneDayPassed = timestamp => {
|
||||
if (!timestamp) return true; // Defensive check
|
||||
return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
export const set = (state, data) => {
|
||||
state.records = data;
|
||||
};
|
||||
|
||||
export const create = (state, data) => {
|
||||
state.records.push(data);
|
||||
};
|
||||
|
||||
export const setSingleRecord = (state, data) => {
|
||||
const recordIndex = state.records.findIndex(record => record.id === data.id);
|
||||
if (recordIndex > -1) {
|
||||
state.records[recordIndex] = data;
|
||||
} else {
|
||||
create(state, data);
|
||||
}
|
||||
};
|
||||
|
||||
export const update = (state, data) => {
|
||||
state.records.forEach((element, index) => {
|
||||
if (element.id === data.id) {
|
||||
state.records[index] = data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/* when you don't want to overwrite the whole object */
|
||||
export const updateAttributes = (state, data) => {
|
||||
state.records.forEach((element, index) => {
|
||||
if (element.id === data.id) {
|
||||
state.records[index] = { ...state.records[index], ...data };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePresence = (state, data) => {
|
||||
state.records.forEach((element, index) => {
|
||||
const availabilityStatus = data[element.id];
|
||||
state.records[index].availability_status = availabilityStatus || 'offline';
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSingleRecordPresence = (
|
||||
records,
|
||||
{ id, availabilityStatus }
|
||||
) => {
|
||||
const [selectedRecord] = records.filter(record => record.id === Number(id));
|
||||
if (selectedRecord) {
|
||||
selectedRecord.availability_status = availabilityStatus;
|
||||
}
|
||||
};
|
||||
|
||||
export const destroy = (state, id) => {
|
||||
state.records = state.records.filter(record => record.id !== id);
|
||||
};
|
||||
Reference in New Issue
Block a user