Skip to content

Additional data команд

Параметры команды действия («какое значение выставить», «какое состояние включить») — additional data. Декларации schema-first: тип объекта объявляет в model/schema.ts рядом со схемой настроек события, команды, zod-схемы values каждой команды и UI-мету полей; редактор и контроллер-синхронизация читают эти декларации, а не хардкодят команды.

Декларации типа

ts
/** UI-мета поля команды: чем рендерить, какие опции и границы. */
export interface IReactionFieldMeta {
  /** виджет редактора (dropdown/number/time/interval/виджеты таймеров) */
  widget: AdditionalDataType;
  /** локализованный заголовок поля */
  label: string;
  /** опции dropdown: Map/Record значение→лейбл (источник для UI и протокола контроллера) */
  options?: Map<string | number, string> | Record<string, string>;
  /**
   * источник опций dropdown — данные цели, а не статичная карта:
   * "targetStates" = состояния mode-объекта (settings.States, id→имя).
   * Взаимоисключающе с options; resolveFieldProps адаптера читает
   * дескриптор при построении полей.
   */
  optionsSource?: "targetStates";
  /**
   * граница может зависеть от настроек цели (например, Max ≥ Min из настроек);
   * any здесь присущ: мета живёт в общем интерфейсе, а поля настроек у каждого типа свои
   */
  min?: number | ((targetSettings: any) => number);
  max?: number | ((targetSettings: any) => number);
  /** максимум знаков после запятой для number-виджета */
  maxFractionDigits?: number;
  class?: string;
  /** конфигурация виджета-объекта таймера (props поля целиком) */
  timerSetup?: {
    fields: Record<string, string>;
    valueMap: Record<string, string | number>;
  };
}

/** Мета полей команды: ключи обязаны совпадать с ключами схемы values. */
export type TReactionFieldMetas<TSchema extends z.ZodTypeAny> = Record<
  keyof z.infer<TSchema>,
  IReactionFieldMeta
>;

/**
 * Как тип объекта описывает параметры своих команд для редактора логики.
 * Живёт в model/schema.ts типа (рядом с zod-схемой настроек) и отдаётся
 * редактору через IObjectReactable.actionDeclarations.
 */
export interface IActionDeclarations<TActions extends string = string> {
  /** Zod-схема значений каждой команды: по ней значения из БД проверяются
   *  при загрузке. Команды описываются через satisfies Record<ActionTypeX, ...> —
   *  пропущенная команда не скомпилируется. */
  valuesSchemas: Partial<Record<TActions, z.ZodTypeAny>>;
  /** Как рендерить каждое поле команды: виджет, лейбл, опции, границы. */
  fieldMeta: Partial<Record<TActions, Record<string, IReactionFieldMeta>>>;
  /** Начальные значения команды. Фабрика, а не объект: дефолты зависят
   *  от настроек целевого объекта. */
  defaults: Partial<Record<TActions, (targetSettings: any) => object>>;
  /** CSS-класс контейнера полей команды в редакторе. */
  containerClass?: string;
}

Дефолты — фабрики от настроек цели: например, CMD_CHANGE_VALUE у mode-object берёт состояние с индексом settings.Value цели, а не константу.

Адаптерный конвейер

Между декларациями и редактором стоит адаптер. Цель действия разрешается в два вида: editorObject (что показывать — для групп делегированный вид члена) и semanticObject (чей семантикой живут значения).

ts
/**
 * Разрешённая цель действия: одна запись БД, два вида.
 * Для групп — editorObject несёт делегированные перечисления первого члена
 * (редактор показывает команды члена), а semanticObject — реальный объект,
 * чьи настройки/семантика используются в рантайме.
 */
export type ResolvedActionTargetObject = {
  /** объект для UI schema/additionalData list; для групп — делегированный вид */
  editorObject: ReactableTargetObject | TDelegatedGroupView;
  /** реальный target object для runtime semantics */
  semanticObject: ReactableTargetObject;
};
ts
/**
 * Контракт адаптера additional data: перевод между тремя видами значений —
 * editor-state (что видит редактор), persisted (actionData.values в БД)
 * и runtime payload (плоский объект на контроллер). Generic-адаптер строит
 * его из деклараций типа; специализированные — для доменных кейсов.
 */
export type ActionAdditionalDataAdapter = {
  /** есть ли вообще additional data поля для этой action/target пары */
  hasFields: () => boolean;
  /** общий layout-класс контейнера для рендера всего блока */
  getContainerClass: () => string | undefined;
  /** строит editor-state из persisted values (values из actionData) */
  createEditorState: (
    persistedValues?: Record<string, unknown>, // actionData.values
    options?: { changedFieldIds?: string[] }, // changed fields для projection recalculation
  ) => ActionAdditionalDataEditorState;
  /** editor-state → actionData.values: только persistable значения */
  toPersistedValues: (editorValues: Record<string, unknown>) => Record<string, unknown>;
  /**
   * Дефолтные values команды в persisted-формате: для инициализации нового
   * экшена и editor state старого экшена без actionData. Конверсию legacy
   * data (IAdditionalDataUI[]) знает только DB-миграция BMSBUIL-1220.
   */
  getDefaultValues: () => Record<string, unknown> | undefined;
  /**
   * Нужен для рабочей отправки на контроллер:
   * сериализует actionData.values в runtime payload.
   */
  toRuntimePayload: (
    persistedValues: Record<string, unknown>, // actionData.values
  ) => Record<string, string | number> | undefined; // сериализует actionData.values в payload на контроллер
};

Выбор адаптера для пары (цель, команда): если тип цели объявил специализированный адаптер в actionDataAdapters — берётся он (analogOutputStartDim / modeObjectChangeValue / pidShimChangeField), иначе — generic-адаптер, собирающий поля из actionDeclarations типа.

Схема поля и projection

Адаптер строит из деклараций поля — схемы редактора (не persisted data). Вычисляемые части поля (значение, опции, границы) описываются projection-ом.

ts
/**
 * Schema одного поля нового additional-data editor-а.
 *
 * Важно: это не persisted data. Schema живет в adapter layer и описывает,
 * как из `actionData.values` собрать editor-state, какие projections применить,
 * и какой widget показать.
 */
export type ActionFieldDefinition = {
  /** id поля в editor-state; для полей без ключа (виджеты-объекты таймеров) — синтетический `__field_N` */
  id: string;
  valueType: ActionFieldValueType;
  widget: ActionFieldWidget;
  label: string;
  /** начальное значение, когда в actionData.values поля ещё нет */
  defaultValue: unknown;
  /** конфигурация виджета: опции dropdown, границы number или конфигурация виджета таймера */
  props?: TAdditionalFieldData;
  class?: string;
  persistence: FieldPersistence;
  /**
   * Projection описывает не только значение поля, но и editor metadata.
   *
   * `value` - вычисляет само значение поля.
   * Например, PID/SHIM CMD_CHANGE_FIELD подставляет value из выбранного target setting.
   *
   * `options` - вычисляет список вариантов для dropdown.
   * Например, ModeObject CMD_CHANGE_VALUE строит options из target.settings.States.
   *
   * `constraints` - вычисляет ограничения ввода: min/max/step и похожие props.
   * Например, numeric action field может брать min/max из scale settings target object.
   *
   * Эти части разделены, потому что могут зависеть от разных источников
   * и пересчитываться по разным правилам.
   */
  projection?: {
    value?: FieldProjection;
    options?: FieldProjection;
    constraints?: FieldProjection;
  };
};
ts
/**
 * Откуда projection engine может взять вычисляемые данные поля.
 *
 * `self` - объект-владелец trigger.
 * `target` - объект, на который указывает action.objectId.
 * `field` - значение другого поля внутри того же additional-data editor-state.
 */
export type ProjectionScope = "self" | "target" | "field";

/**
 * Когда projection engine должен пересчитывать вычисляемое значение/options/constraints.
 *
 * `snapshot` - вычислить при инициализации и дальше не поддерживать связь.
 * `on_dependency_change` - пересчитать только когда изменились поля из `dependsOn`.
 * `editor_live` - брать актуальные данные target/self пока открыт редактор.
 */
export type ProjectionPolicy = "snapshot" | "on_dependency_change" | "editor_live";

/**
 * Описание вычисляемой части поля.
 *
 * Примеры:
 * - options dropdown-а берутся из `target.settings.States`
 * - value поля пересчитывается от выбранного sibling field
 * - constraints number input-а могут быть вычислены из settings target object
 */
export type FieldProjection =
  | {
      source: {
        scope: Exclude<ProjectionScope, "field">;
        path: string;
      };
      policy: ProjectionPolicy;
      resolver?: string;
      dependsOn?: string[];
    }
  | {
      source: {
        scope: "field";
        fieldId: string;
      };
      policy: ProjectionPolicy;
      resolver?: string;
      dependsOn?: string[];
    };

Три вида значений

Editor-values — что видит и правит редактор (по одному значению на поле, включая virtual). Persisted — actionData.values в БД. Wire — плоский объект ActionCmdData, уходящий на контроллер (toRuntimePayload адаптера). Перевод editor → persisted:

Editor-values → persisted values (actionData.values): literal-поля пишутся по своему valueKey, reference — stable id, virtual — не пишутся вовсе. Команда с root-полем (виджет-таймер) — исключение: persisted values становятся самим объектом виджета, без обёртки в ключ поля.

Специализированные адаптеры

Generic-конвейер покрывает декларативные команды; когда wire-формат или семантика значений расходится с editor-values, команда получает специализированный адаптер. Существующие:

  • change-fieldCMD_CHANGE_FIELD у PID и SHIM: value-projection из настроек цели;
  • start-dimCMD_START_DIM: числовое Value на контроллер без AddData-массива;
  • change-valueCMD_CHANGE_VALUE: ссылочная модель состояний.

Mentioned In