@applicaster/zapp-react-native-utils
Version:
Applicaster Zapp React Native utilities package
436 lines (383 loc) • 13.2 kB
text/typescript
/// <reference types="@applicaster/applicaster-types" />
import * as R from "ramda";
import { Platform } from "react-native";
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 the value of the "src" in the first media_item
* that has the matching key provided in args.
* Fallbacks: "image_base" key, or first media_item that has any "src"
* @param {Object} entry Single entry from a feed
* @param {Array} arg Array with a single element - the key of the media item
* from which the "src" should be retrieved
* @returns {?String} Value of "src", usually a URI
*/
export function imageSrcFromMediaItem(
entry: ZappEntry,
arg: string[] | unknown
) {
const args: unknown = R.unless(Array.isArray, Array)(arg || []);
const imageKey: string = args?.[0] || "image_base"; // always a single key in this function
const mediaGroup = R.path<ZappMediaGroup[]>(["media_group"], entry);
if (!mediaGroup) {
return undefined;
}
const hasTypeImageOrThumbnail = R.either(
R.propEq("type", "image"),
R.propEq("type", "thumbnail")
);
const pickMediaItemProp = R.prop<ZappMediaItem>("media_item");
const mediaItems = R.compose(
R.flatten,
R.map(pickMediaItemProp),
R.filter(hasTypeImageOrThumbnail)
)(mediaGroup);
if (!mediaItems) {
return undefined;
}
const src = R.compose(
R.prop("src"),
R.defaultTo(R.head(mediaItems)),
R.when(R.isNil, () => R.find(R.propEq("key", "image_base"), mediaItems)),
R.find(R.propEq("key", imageKey))
)(mediaItems);
// Special case for react native - uri cannot be an empty string (yellow warning).
// R.isEmpty is tailored specifically for checks like these,
// it returns false for undefined values.
return R.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 getAccesabilityProps = (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)
);