Appearance
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-field —
CMD_CHANGE_FIELDу PID и SHIM: value-projection из настроек цели; - start-dim —
CMD_START_DIM: числовоеValueна контроллер без AddData-массива; - change-value —
CMD_CHANGE_VALUE: ссылочная модель состояний.