UNPKG

@applicaster/zapp-react-native-utils

Version:

Applicaster Zapp React Native utilities package

465 lines (406 loc) • 14.4 kB
/// <reference types="@applicaster/applicaster-types" /> import * as R from "ramda"; import { Platform } from "react-native"; import { isFilledArray, isEmptyArray, } from "@applicaster/zapp-react-native-utils/arrayUtils"; import { isEmpty } from "@applicaster/zapp-react-native-utils/utils"; import { applyTransform } from "../transform"; import { ManifestField } from "../types"; import { isNilOrEmpty } from "../reactUtils/helpers"; export type PluginConfiguration = Record<string, string>; type TBooleanLike = boolean | string | number; type TAnyValue = unknown; type GetValue = { (key: string): string; (key: string): number; (key: string): null; }; declare type StringTransformations = | "default" | "uppercase" | "lowercase" | "toUpper" | "capitalise"; declare type Configuration = { target_screen_switch: boolean; target: string; display_mode: string; display_mode_tab_width: number; display_mode_tabs_alignment: string; text_label_data_key: string; text_label_custom_data_key: string; text_label_ios_font_family: string; text_label_default_font_color: string; text_label_active_font_color: string; text_label_selected_default_font_color: string; text_label_selected_active_font_color: string; text_label_android_font_family: string; text_label_font_size: number; text_label_line_height: number; text_label_ios_letter_spacing: number; text_label_android_letter_spacing: number; text_label_text_transform: StringTransformations; tab_bar_background_color: string; tab_bar_elevation: number; tab_bar_shadow_color: string; tab_bar_shadow_offset_width: number; tab_bar_shadow_offset_height: number; tab_bar_shadow_radius: number; tab_bar_padding_top: number; tab_bar_padding_right: number; tab_bar_padding_bottom: number; tab_bar_padding_left: number; tab_bar_gutter: number; tab_bar_border_bottom_width: number; tab_bar_border_bottom_color: string; tab_cell_background_color_default: string; tab_cell_background_color_active: string; tab_cell_background_color_selected_default: string; tab_cell_background_color_selected_active: string; tab_cell_padding_top: number; tab_cell_padding_right: number; tab_cell_padding_bottom: number; tab_cell_padding_left: number; tab_cell_border_radius: number; tab_cell_border_width: number; tab_cell_border_color_default: string; tab_cell_border_color_default_focused: string; tab_cell_border_color_active: string; tab_cell_border_color_active_focused: string; tab_cell_indicator_height: number; tab_cell_indicator_color: string; tab_cell_indicator_border_radius: number; sticky_tab_bar: boolean; components_container_background_color?: string; components_container_padding_top?: number; components_container_padding_bottom?: number; components_container_padding_left?: number; components_container_padding_right?: number; tablet_theme: boolean; tablet_components_container_padding_top?: number; tablet_components_container_padding_bottom?: number; tablet_components_container_padding_left?: number; tablet_components_container_padding_right?: number; }; export const getBoolFromConfigValue = (value: TBooleanLike): boolean => { if (typeof value === "boolean") { return value; } return value === "true" || value === "1" || value === 1; }; export const requiresAuthentication = (entry: ZappEntry): Nullable<boolean> => { const requires_authentication: TBooleanLike | undefined = R.path( ["extensions", "requires_authentication"], entry ); return !R.isNil(requires_authentication) ? getBoolFromConfigValue(requires_authentication) : null; }; /** * Flattens the manifest configuration fields which contains group into * a flat list of fields * @param fields to flatten * @returns flatten fields */ export function flattenFields( fields: ManifestField<TAnyValue>[] = [] ): ManifestField<TAnyValue>[] { return fields.reduce((acc, field) => { if (field.fields) { return [...acc, ...flattenFields(field.fields)]; } else { return [...acc, field]; } }, []); } /** * Retrieves all media items from the entry's media_group that are of type "image" or "thumbnail". * * @param {ZappEntry} entry - The entry object containing the media_group array. * @returns {Option<ZappMediaItem[]>} An array of media items of type "image" or "thumbnail", or undefined if no media_group is present. */ export function getMediaItems(entry: ZappEntry): Option<ZappMediaItem[]> { const mediaGroup = entry?.media_group || []; if (isEmptyArray(mediaGroup)) { return undefined; } const mediaItems = mediaGroup .filter((group) => ["image", "thumbnail"].includes(group.type)) .flatMap((group) => Array.isArray(group.media_item) ? group.media_item : [group.media_item] ); if (isFilledArray(mediaItems)) { return mediaItems; } return undefined; } /** * Retrieves the "src" value from a media item in the entry's media group, * based on a provided key, with fallback logic. * * Fallback order: * 1. Attempts to find a media item with the specified key (or "image_base" if none provided). * 2. If not found, attempts to find a media item with the key "image_base". * 3. If still not found, falls back to the first available media item. * * Special handling: If the resolved item's "src" is an empty string, returns undefined, * since empty URIs are invalid in some platforms (e.g., React Native). * * @param {ZappEntry} entry - The entry object containing a media group. * @param {string[] | unknown} arg - A single-element array containing the key to look up, or any unknown value. * @returns {?string} The "src" URI from the matched media item, or undefined if not found or empty. */ export function imageSrcFromMediaItem( entry: ZappEntry, arg: string[] | unknown ): Option<string> { const args: unknown = R.unless(Array.isArray, Array)(arg || []); const imageKey: string = args?.[0] || "image_base"; // always a single key in this function const mediaItems = getMediaItems(entry); if (!mediaItems) { return undefined; } // Try to find the item with the given key let foundItem = mediaItems.find((item) => item.key === imageKey); // If not found and key was not "image_base", try to find "image_base" if (!foundItem && imageKey !== "image_base") { foundItem = mediaItems.find((item) => item.key === "image_base"); } // If still not found, default to first item if (!foundItem) { foundItem = mediaItems[0]; } const src = foundItem?.src; // React Native quirk: empty string is invalid for URIs return isEmpty(src) ? undefined : src; } /** * map of type checks to apply to each manifest field in order to check if * the provided value is valid for that given manifest type */ const typeChecks = { switch: R.is(Boolean), checkbox: R.is(Boolean), number_input: R.is(Number), text_input: R.is(String), }; /** * This function checks if a provided value is valid for the provided type * curried function of the form isInvalidForType(type)(value); * returns true if the value is invalid, and false if the value is ok. * @param {*} type * @returns {Function} * @param {Any} value to check * @returns {boolean} */ function isInvalidForType(type: string): (arg0: any) => boolean { const typeCheck = typeChecks[type] || (() => true); return function (value: TAnyValue): boolean { return !typeCheck(value); }; } /** * Checks if a value is valid for a provided type. Checks if the the type is correct, * but also if the value is not empty, not null, and not NaN * curried function of the form isInvalidValue(type)(value). Returns true if the value * is invalid, and false otherwise * @param {String} type * @returns {Function} * @param {Any} value to check for * @returns {boolean} */ const isInvalid = (type: string, value: TAnyValue): boolean => { const validators = [R.isEmpty, R.isNil, Number.isNaN, isInvalidForType(type)]; return validators.some((valid) => valid(value)); }; function isInvalidValue(type: string): (arg0: TAnyValue) => boolean { return (value) => isInvalid(type, value); } /** * This function will sanitize a value from a provided configuration, by trying * to apply the relevant transform, and resolving to the default value * if the resulting value is invalid (null, empty, NaN, or incorrect type) * Curried function of the form castOrDefault(field)(configuration) * @param {Object} field plugin manifest field * @param {String} field.type type of the manifest field * @param {Any} field.initial_value default value provided in the manifest for that field * @param {String} field.key key of the manifest field * @returns {Function} * @param {Object} configuration retrieved from the server */ function castOrDefault<T>({ type, initial_value, key, }: ManifestField<T>): ( configuration: PluginConfiguration, skipDefaults: boolean ) => T { const transform = applyTransform(type); return function ( configuration: PluginConfiguration, skipDefaults: boolean ): T { const value = transform(configuration?.[key]); if (isInvalidValue(type)(value) && !skipDefaults) { return transform(initial_value); } return value; }; } /** * This function takes a section of configuration and checks if the relevant section * in the manifest has remapped keys. if it does, it updates to the new expected configuration * This helps not breaking the layout when a plugin configuration changes its keys * @param {Object} config key value pair of configuration for a section in a plugin * @param {Object} remappedKeys dictionary of keys to be remapped * @returns */ const applyRemappedKeys = ( config: PluginConfiguration, remappedKeys: PluginConfiguration ) => { const oldKeys = R.keys(remappedKeys || {}); const configKeys = R.keys(config); if ( !R.compose(R.length, R.intersection(oldKeys))(configKeys) || !R.length(oldKeys) ) { return config; } // need to update keys return R.reduce( (updatedConfig, oldKey: string) => { if (R.has(oldKey, config) && !R.has(remappedKeys[oldKey], config)) { return R.assoc(remappedKeys[oldKey], config[oldKey], updatedConfig); } return updatedConfig; }, config, oldKeys ); }; /** * This function updates locally the configuration for a cell style with * the updated keys if needed * @param {Object} manifest plugin manifest * @param {Object} config plugin configuration for the given cell style * @returns {Object} remapped config */ type TRemapUpdatedKeys = ( manifest: ManifestField<TAnyValue>, config: PluginConfiguration ) => PluginConfiguration; export const remapUpdatedKeys = R.curry<TRemapUpdatedKeys>( (manifest, config) => { return R.reduce( (updatedConfig, configSection: string) => { const remappedKeys = R.pathOr<string[] | null>( null, [configSection, "updated_keys"], manifest ); updatedConfig[configSection] = remappedKeys ? applyRemappedKeys(config[configSection], remappedKeys) : config[configSection]; return updatedConfig; }, {}, R.keys(config) ); } ); /** * Flattens the manifest configuration fields which contains group into * a flat list of fields and applies defaults/casts * @param fields * @param configuration * @param skipDefaults */ export function flattenAndPopulateFields( fields: ManifestField<TAnyValue>[], configuration: PluginConfiguration, skipDefaults?: boolean ): { [key: string]: any } { return fields.reduce((acc, field) => { if (field.fields) { Object.assign( acc, flattenAndPopulateFields(field.fields, configuration, skipDefaults) ); } else { acc[field.key] = castOrDefault(field)(configuration, skipDefaults); } return acc; }, {}); } /** * This function is casting default styles to expected format * @param {object} fields - array of configuration fields form manifest * @returns array of {key, value, mapper:(function)} */ type TPopulateConfigurationValues = ( fields: ManifestField<TAnyValue>[], configuration: PluginConfiguration, skipDefaults?: boolean ) => PluginConfiguration; export const populateConfigurationValues = R.curry<TPopulateConfigurationValues>( (fields, configuration, skipDefaults = false) => flattenAndPopulateFields(fields, configuration, skipDefaults) ); export const getAccessibilityProps = (item: ZappEntry) => ({ accessible: item?.extensions?.accessibility, accessibilityLabel: item?.extensions?.accessibility?.label || item?.title, accessibilityHint: item?.extensions?.accessibility?.hint, }); export const getPlayerControlsAccessibilityProps = ( icon: string, value: GetValue ) => { return { accessible: true, accessibilityLabel: value(`accessibility_${icon}_label`), accessibilityHint: value(`accessibility_${icon}_hint`), }; }; export const getAllAccessibilityProps = R.pickBy((_, key) => key.includes("accessibility") ); const os = R.compose(R.toLower, R.prop("OS"))(Platform); export const castOrDefaultValue = (mapper, defaultValue) => R.ifElse(isNilOrEmpty, R.always(defaultValue), mapper); export const castIfDefined = (mapper) => R.unless(R.isNil, mapper); export const getStyleForPlatform = R.curry( ( configuration: Partial<Configuration>, [label, key]: [string, string] ): any => R.prop(`${label}_${os}_${key}`)(configuration) ); export const capitalise = R.replace(/^./, R.toUpper); export const applyStringTransformation = ( text: string, transformation: StringTransformations ) => { const transformationMethod = R.cond([ [R.equals("default"), R.always(R.identity)], [R.equals("uppercase"), R.always(R.toUpper)], [R.equals("lowercase"), R.always(R.toLower)], [R.equals("capitalise"), R.always(capitalise)], ])(transformation); return transformationMethod(text); }; const allBetweenParenthesesRegex = /(\()(.*?)(?=\))/g; export const getOpacityFromHexColor = R.compose( parseFloat, R.trim, R.last, R.split(","), // Positive Lookbehinds are not supported in Android JS env therefor we need to remove "(" manually. R.replace("(", ""), R.head, R.match(allBetweenParenthesesRegex) );