All files / actions action.ts

100% Statements 25/25
100% Branches 14/14
100% Functions 3/3
100% Lines 23/23

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115                  94x   94x   94x                                                       94x         3240x 1316x     1924x       98x       98x           98x               98x 55x 55x 29x         98x     1924x                   94x         100x 100x 2x     98x         94x   94x        
/**
 * ## Specification for the HTML API for actions
 *
 * When using the HTML API, actions are always used with triggers. Please see
 * {@link Triggers | the documentation on triggers} for the required syntax.
 *
 * @module Actions
 */
 
import * as MH from "@lisn/globals/minification-helpers";
 
import { splitOn } from "@lisn/utils/text";
 
import { WidgetConfigValidator, fetchWidgetConfig } from "@lisn/widgets/widget";
 
/**
 * @interface
 */
export type Action = {
  do: () => void;
  undo: () => void;
  toggle: () => void;
};
 
export type ActionCreateFn<Config extends Record<string, unknown>> = (
  element: Element,
  args: string[],
  config?: Config,
) => Action | Promise<Action>;
 
/**
 * Registers the given action so that it can be parsed by
 * {@link Triggers.registerTrigger}.
 *
 * **IMPORTANT:** If an action by that name is already registered, the current
 * call does nothing, even if the remaining arguments differ.
 *
 * @param name      The name of the action. Should be in kebab-case.
 * @param newAction Called for every action specification for a trigger
 *                  parsed by {@link Triggers.registerTrigger}
 */
export const registerAction = <Config extends Record<string, unknown>>(
  name: string,
  newAction: ActionCreateFn<Config>,
  configValidator?: null | WidgetConfigValidator<Config>,
) => {
  if (registeredActions.has(name)) {
    return;
  }
 
  const newActionFromSpec = async (
    element: Element,
    argsAndOptions: string,
  ) => {
    const thisConfigValidator = MH.isFunction(configValidator)
      ? await configValidator(element)
      : configValidator;
 
    const args: string[] = [];
 
    // In general, if an action accepts a boolean *option* (not argument), it
    // may not be followed by a =value. So we pass the full string to the
    // fetchWidgetConfig which will parse such boolean options if they are
    // defined in the config validator.
    const config = thisConfigValidator
      ? await fetchWidgetConfig(
          argsAndOptions,
          thisConfigValidator,
          ARG_SEP_CHAR,
        )
      : undefined;
 
    for (const entry of splitOn(argsAndOptions, ARG_SEP_CHAR, true)) {
      if (entry) {
        if (!MH.includes(entry, "=") && !(config && entry in config)) {
          args.push(entry);
        }
      }
    }
 
    return newAction(element, args, config);
  };
 
  registeredActions.set(name, newActionFromSpec);
};
 
/**
 * Returns an {@link Action} registered under the given name and instantiated
 * with the given element and arguments and options parsed from the given string.
 *
 * @throws {@link Errors.LisnUsageError | LisnUsageError}
 *                If the given spec is not valid.
 */
export const fetchAction = async (
  element: Element,
  name: string,
  argsAndOptions?: string,
): Promise<Action> => {
  const newActionFromSpec = registeredActions.get(name);
  if (!newActionFromSpec) {
    throw MH.usageError(`Unknown action '${name}'`);
  }
 
  return await newActionFromSpec(element, argsAndOptions ?? "");
};
 
// --------------------
 
const ARG_SEP_CHAR = ",";
 
const registeredActions = MH.newMap<
  string,
  (element: Element, spec: string) => Action | Promise<Action>
>();