@serverkgg/bridge 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverkgg/bridge",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
5
5
  "homepage": "https://serverk.gg",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -4,8 +4,17 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { BridgeKind, BridgeSetupStepKind } from "../core";
6
6
  import { GuideOpenTab } from "../guides";
7
- import type { BridgeSetupModule } from "../modules";
8
- import { runValidate, setupReportLines } from "./validate";
7
+ import type { BridgeDriver, BridgeSetupModule } from "../modules";
8
+ import {
9
+ BridgeControl,
10
+ type BridgeFormSection,
11
+ BridgeFormTarget,
12
+ BridgeLayout,
13
+ BridgePlace,
14
+ type BridgeTab,
15
+ type BridgeTableSection,
16
+ } from "../panel";
17
+ import { placementReportLines, runValidate, setupReportLines } from "./validate";
9
18
 
10
19
  const text = (value: string) => ({
11
20
  ar: value,
@@ -202,3 +211,186 @@ describe("the setup report", () => {
202
211
  ]);
203
212
  });
204
213
  });
214
+
215
+ describe("the placement report", () => {
216
+ const rosterTable = (place?: BridgePlace): BridgeTableSection => ({
217
+ layout: BridgeLayout.Table,
218
+ id: "online",
219
+ module: "players",
220
+ place,
221
+ columns: [
222
+ {
223
+ key: "name",
224
+ label: text("Name"),
225
+ },
226
+ ],
227
+ });
228
+
229
+ const settingsForm = (): BridgeFormSection => ({
230
+ layout: BridgeLayout.Form,
231
+ id: "world",
232
+ target: BridgeFormTarget.Variables,
233
+ fields: [
234
+ {
235
+ key: "WORLD_NAME",
236
+ control: BridgeControl.Text,
237
+ label: text("World name"),
238
+ },
239
+ ],
240
+ });
241
+
242
+ const driverWithTabs = (tabs: BridgeTab[]): BridgeDriver => ({
243
+ install: {
244
+ kind: BridgeKind.Install,
245
+ run: async () => {
246
+ return;
247
+ },
248
+ },
249
+ lifecycle: {
250
+ kind: BridgeKind.Lifecycle,
251
+ command: async () => [
252
+ "run",
253
+ ],
254
+ },
255
+ panel: {
256
+ tabs,
257
+ },
258
+ });
259
+
260
+ test("says nothing about a roster table that declares where it belongs", () => {
261
+ expect(
262
+ placementReportLines(
263
+ driverWithTabs([
264
+ {
265
+ id: "roster",
266
+ title: text("Roster"),
267
+ sections: [
268
+ rosterTable(BridgePlace.Players),
269
+ ],
270
+ },
271
+ ]),
272
+ ),
273
+ ).toEqual([
274
+ ' placement: tab "roster" is fully placed, it will not appear in the sidebar',
275
+ ]);
276
+ });
277
+
278
+ test("warns that the platform absorbs an unplaced players collection by convention", () => {
279
+ expect(
280
+ placementReportLines(
281
+ driverWithTabs([
282
+ {
283
+ id: "roster",
284
+ title: text("Roster"),
285
+ sections: [
286
+ rosterTable(),
287
+ ],
288
+ },
289
+ ]),
290
+ ),
291
+ ).toEqual([
292
+ ' warning: tab "roster" section "online" is the players collection with no place — the platform absorbs it by convention today; declare `place: players` to make it explicit',
293
+ ]);
294
+ });
295
+
296
+ test("warns about a tab that takes the name of a platform sidebar entry", () => {
297
+ expect(
298
+ placementReportLines(
299
+ driverWithTabs([
300
+ {
301
+ id: "analytics",
302
+ title: text("Analytics"),
303
+ sections: [
304
+ settingsForm(),
305
+ ],
306
+ },
307
+ ]),
308
+ ),
309
+ ).toEqual([
310
+ ` warning: tab "analytics" takes the name of a platform sidebar entry — rename it, the platform's own entry owns that noun`,
311
+ ]);
312
+ });
313
+
314
+ test("leaves a players tab alone while it is the roster tab", () => {
315
+ expect(
316
+ placementReportLines(
317
+ driverWithTabs([
318
+ {
319
+ id: "players",
320
+ title: text("Players"),
321
+ sections: [
322
+ rosterTable(BridgePlace.Players),
323
+ ],
324
+ },
325
+ ]),
326
+ ),
327
+ ).toEqual([
328
+ ' placement: tab "players" is fully placed, it will not appear in the sidebar',
329
+ ]);
330
+ });
331
+
332
+ test("warns about a players tab that holds no roster", () => {
333
+ expect(
334
+ placementReportLines(
335
+ driverWithTabs([
336
+ {
337
+ id: "players",
338
+ title: text("Players"),
339
+ sections: [
340
+ settingsForm(),
341
+ ],
342
+ },
343
+ ]),
344
+ ),
345
+ ).toEqual([
346
+ ` warning: tab "players" takes the name of a platform sidebar entry — rename it, the platform's own entry owns that noun`,
347
+ ]);
348
+ });
349
+
350
+ test("says nothing about a tab that declares no sections at all", () => {
351
+ expect(
352
+ placementReportLines(
353
+ driverWithTabs([
354
+ {
355
+ id: "empty",
356
+ title: text("Empty"),
357
+ sections: [],
358
+ },
359
+ ]),
360
+ ),
361
+ ).toEqual([]);
362
+ });
363
+
364
+ test("still warns about an empty players tab, which holds no roster", () => {
365
+ expect(
366
+ placementReportLines(
367
+ driverWithTabs([
368
+ {
369
+ id: "players",
370
+ title: text("Players"),
371
+ sections: [],
372
+ },
373
+ ]),
374
+ ),
375
+ ).toEqual([
376
+ ` warning: tab "players" takes the name of a platform sidebar entry — rename it, the platform's own entry owns that noun`,
377
+ ]);
378
+ });
379
+
380
+ test("says nothing about a tab that keeps a leftover section", () => {
381
+ expect(
382
+ placementReportLines(
383
+ driverWithTabs([
384
+ {
385
+ id: "world",
386
+ title: text("World"),
387
+ sections: [
388
+ rosterTable(BridgePlace.Players),
389
+ settingsForm(),
390
+ ],
391
+ },
392
+ ]),
393
+ ),
394
+ ).toEqual([]);
395
+ });
396
+ });
@@ -15,7 +15,15 @@ import {
15
15
  readPackageDocument,
16
16
  } from "../manifest";
17
17
  import type { BridgeDriver, BridgeSetupModule } from "../modules";
18
- import { type BridgeSetupStep, BridgeSetupStepKind, isBridgeEventName } from "../protocol";
18
+ import {
19
+ BridgeLayout,
20
+ BridgePlace,
21
+ type BridgeSection,
22
+ type BridgeSetupStep,
23
+ BridgeSetupStepKind,
24
+ type BridgeTab,
25
+ isBridgeEventName,
26
+ } from "../protocol";
19
27
  import { validateDriver, validatePortActivation } from "../runtime/bridgeDriver";
20
28
  import { directorySource } from "./directorySource";
21
29
 
@@ -193,6 +201,79 @@ const reportActionTargets = (driver: BridgeDriver) => {
193
201
  }
194
202
  };
195
203
 
204
+ const PLATFORM_TAB_IDS: ReadonlySet<string> = new Set([
205
+ "overview",
206
+ "console",
207
+ "files",
208
+ "backups",
209
+ "schedules",
210
+ "analytics",
211
+ "feed",
212
+ "access",
213
+ "integrations",
214
+ "needs",
215
+ "setup",
216
+ "change-game",
217
+ ]);
218
+
219
+ const ROSTER_TAB_ID = "players";
220
+
221
+ const ROSTER_MODULE = "players";
222
+
223
+ const isCollectionSection = (section: BridgeSection) => {
224
+ return section.layout === BridgeLayout.Table || section.layout === BridgeLayout.Cards;
225
+ };
226
+
227
+ const placeOf = (section: BridgeSection) => {
228
+ return "place" in section ? section.place : undefined;
229
+ };
230
+
231
+ const isRosterTab = (tab: BridgeTab) => {
232
+ if (tab.sections.some((section) => isCollectionSection(section) && section.module === ROSTER_MODULE)) {
233
+ return true;
234
+ }
235
+
236
+ return tab.sections.length > 0 && tab.sections.every((section) => placeOf(section) === BridgePlace.Players);
237
+ };
238
+
239
+ export const placementReportLines = (driver: BridgeDriver): string[] => {
240
+ const lines: string[] = [];
241
+
242
+ for (const tab of driver.panel?.tabs ?? []) {
243
+ for (const section of tab.sections) {
244
+ if (!isCollectionSection(section) || section.module !== ROSTER_MODULE) {
245
+ continue;
246
+ }
247
+
248
+ if (placeOf(section) === BridgePlace.Players) {
249
+ continue;
250
+ }
251
+
252
+ lines.push(
253
+ ` warning: tab "${tab.id}" section "${section.id}" is the players collection with no place — the platform absorbs it by convention today; declare \`place: players\` to make it explicit`,
254
+ );
255
+ }
256
+
257
+ if (PLATFORM_TAB_IDS.has(tab.id) || (tab.id === ROSTER_TAB_ID && !isRosterTab(tab))) {
258
+ lines.push(
259
+ ` warning: tab "${tab.id}" takes the name of a platform sidebar entry — rename it, the platform's own entry owns that noun`,
260
+ );
261
+ }
262
+
263
+ if (tab.sections.length > 0 && tab.sections.every((section) => placeOf(section) !== undefined)) {
264
+ lines.push(` placement: tab "${tab.id}" is fully placed, it will not appear in the sidebar`);
265
+ }
266
+ }
267
+
268
+ return lines;
269
+ };
270
+
271
+ const reportPlacement = (driver: BridgeDriver) => {
272
+ for (const line of placementReportLines(driver)) {
273
+ console.log(line);
274
+ }
275
+ };
276
+
196
277
  const openTargetOf = (target: GuideOpenTarget) => {
197
278
  if (target.tab === GuideOpenTab.Panel) {
198
279
  return `panel tab "${target.tabId}"`;
@@ -286,6 +367,7 @@ const validateGamePackage = async (source: PackageSource, manifest: Manifest, di
286
367
 
287
368
  reportEvents(driver);
288
369
  reportActionTargets(driver);
370
+ reportPlacement(driver);
289
371
  };
290
372
 
291
373
  export const runValidate = async (args: string[]) => {
@@ -12,6 +12,7 @@ export const GUIDE_VARIABLES = {
12
12
  "server.code": GuideVariableFormat.Code,
13
13
  "server.address": GuideVariableFormat.Code,
14
14
  "server.host": GuideVariableFormat.Code,
15
+ "server.ip": GuideVariableFormat.Code,
15
16
  "server.port": GuideVariableFormat.Code,
16
17
  "server.game": GuideVariableFormat.Text,
17
18
  "server.region": GuideVariableFormat.Text,
@@ -5,6 +5,7 @@ import type { BridgeControl } from "./bridgeControl";
5
5
  import type { BridgeFormTarget } from "./bridgeFormTarget";
6
6
  import type { BridgeIcon } from "./bridgeIcon";
7
7
  import type { BridgeLayout } from "./bridgeLayout";
8
+ import type { BridgePlace } from "./bridgePlace";
8
9
  import type { BridgeUploadMode } from "./bridgeUploadMode";
9
10
 
10
11
  export interface BridgeVisibleWhen {
@@ -44,6 +45,7 @@ export interface BridgeSectionAction {
44
45
  confirm?: BridgeConfirm;
45
46
  confirmText?: LocalizedText;
46
47
  download?: boolean;
48
+ offline?: boolean;
47
49
  fields?: BridgeField[];
48
50
  }
49
51
 
@@ -74,6 +76,7 @@ export interface BridgeActionsSection {
74
76
  id: string;
75
77
  title?: LocalizedText;
76
78
  help?: LocalizedText;
79
+ place?: BridgePlace;
77
80
  module: string;
78
81
  actions: BridgeSectionAction[];
79
82
  visibleWhen?: BridgeVisibleWhen;
@@ -84,6 +87,7 @@ export interface BridgeFormSection {
84
87
  layout: BridgeLayout.Form;
85
88
  id: string;
86
89
  title?: LocalizedText;
90
+ help?: LocalizedText;
87
91
  target: BridgeFormTarget;
88
92
  module?: string;
89
93
  preview?: BridgePreviewRef;
@@ -106,6 +110,8 @@ export interface BridgeTableSection {
106
110
  layout: BridgeLayout.Table;
107
111
  id: string;
108
112
  title?: LocalizedText;
113
+ help?: LocalizedText;
114
+ place?: BridgePlace;
109
115
  module: string;
110
116
  restartHint?: boolean;
111
117
  columns: BridgeColumn[];
@@ -121,6 +127,8 @@ export interface BridgeCardsSection {
121
127
  layout: BridgeLayout.Cards;
122
128
  id: string;
123
129
  title?: LocalizedText;
130
+ help?: LocalizedText;
131
+ place?: BridgePlace;
124
132
  module: string;
125
133
  titleKey: string;
126
134
  subtitleKey?: string;
@@ -134,6 +142,7 @@ export interface BridgeCatalogSection {
134
142
  layout: BridgeLayout.Catalog;
135
143
  id: string;
136
144
  title?: LocalizedText;
145
+ help?: LocalizedText;
137
146
  module: string;
138
147
  restartHint?: boolean;
139
148
  reinstall?: boolean;
@@ -149,6 +158,8 @@ export interface BridgeDetailSection {
149
158
  layout: BridgeLayout.Detail;
150
159
  id: string;
151
160
  title?: LocalizedText;
161
+ help?: LocalizedText;
162
+ place?: BridgePlace;
152
163
  module: string;
153
164
  actions?: BridgeSectionAction[];
154
165
  reinstall?: boolean;
@@ -0,0 +1,4 @@
1
+ export enum BridgePlace {
2
+ Overview = "overview",
3
+ Players = "players",
4
+ }
@@ -6,4 +6,5 @@ export * from "./bridgeFormTarget";
6
6
  export * from "./bridgeIcon";
7
7
  export * from "./bridgeLayout";
8
8
  export * from "./bridgePanel";
9
+ export * from "./bridgePlace";
9
10
  export * from "./bridgeUploadMode";
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  interface StubOptions {
17
17
  exposed?: boolean;
18
- port?: number;
18
+ port?: number | null;
19
19
  }
20
20
 
21
21
  const stubContext = (options: StubOptions = {}) => {
@@ -25,7 +25,11 @@ const stubContext = (options: StubOptions = {}) => {
25
25
  variable: (key: string) => {
26
26
  return key === RCON_ACCESS_VARIABLE && options.exposed === true ? "true" : null;
27
27
  },
28
- port: () => {
28
+ port: (key: string) => {
29
+ if (options.port === null) {
30
+ throw new Error(`this game declares no port named "${key}"`);
31
+ }
32
+
29
33
  return options.port ?? 12_500;
30
34
  },
31
35
  log: (message: string) => {
@@ -154,6 +158,20 @@ describe("createRconAccess", () => {
154
158
  ]);
155
159
  });
156
160
 
161
+ test("an inactive port leaves the port stat empty instead of failing the read", async () => {
162
+ const detail = await accessWith({
163
+ value: "secret",
164
+ pending: false,
165
+ }).read(
166
+ stubContext({
167
+ port: null,
168
+ }).context,
169
+ );
170
+
171
+ expect(detail?.stats.find((stat) => stat.key === "port")?.value).toBeNull();
172
+ expect(detail?.stats.find((stat) => stat.key === "password")?.value).toBe("secret");
173
+ });
174
+
157
175
  test("a server with no password yet has no detail", async () => {
158
176
  expect(await accessWith(null).read(stubContext().context)).toBeNull();
159
177
  });
@@ -127,6 +127,14 @@ const badgesFor = (exposed: boolean, pending: boolean): BridgeDetailBadge[] => {
127
127
  return badges;
128
128
  };
129
129
 
130
+ const declaredPort = (context: BridgeContext, key: string): number | null => {
131
+ try {
132
+ return context.port(key);
133
+ } catch {
134
+ return null;
135
+ }
136
+ };
137
+
130
138
  export const createRconAccess = (options: RconAccessOptions): BridgeDetailModule => {
131
139
  const protocol = options.protocol ?? DEFAULT_PROTOCOL;
132
140
  const portKey = options.portKey ?? RCON_ACCESS_PORT;
@@ -155,7 +163,7 @@ export const createRconAccess = (options: RconAccessOptions): BridgeDetailModule
155
163
  ar: `منفذ ${protocol.ar}`,
156
164
  en: `${protocol.en} port`,
157
165
  },
158
- value: context.port(portKey),
166
+ value: declaredPort(context, portKey),
159
167
  format: BridgeDetailFormat.Number,
160
168
  },
161
169
  {
@@ -9,8 +9,10 @@ import {
9
9
  type BridgeField,
10
10
  BridgeFormTarget,
11
11
  BridgeLayout,
12
+ BridgePlace,
12
13
  type BridgeSectionAction,
13
14
  type BridgeSectionUpload,
15
+ type BridgeTableSection,
14
16
  BridgeUploadMode,
15
17
  type BridgeVisibleWhen,
16
18
  } from "../panel";
@@ -1636,3 +1638,146 @@ describe("field patterns", () => {
1636
1638
  ).toThrow("is a secret control with options — a secret is typed, never chosen from a list");
1637
1639
  });
1638
1640
  });
1641
+
1642
+ describe("section placement", () => {
1643
+ const anyVisibleWhen: BridgeVisibleWhen = {
1644
+ variable: "MODPACK",
1645
+ empty: true,
1646
+ };
1647
+
1648
+ const driverWithRoster = (section: Partial<BridgeTableSection>): BridgeDriver => ({
1649
+ install: {
1650
+ kind: BridgeKind.Install,
1651
+ run: async () => {
1652
+ return;
1653
+ },
1654
+ },
1655
+ lifecycle: {
1656
+ kind: BridgeKind.Lifecycle,
1657
+ command: async () => [
1658
+ "run",
1659
+ ],
1660
+ },
1661
+ modules: {
1662
+ players: {
1663
+ kind: BridgeKind.Collection,
1664
+ list: async () => [],
1665
+ actions: {
1666
+ ban: async () => {
1667
+ return;
1668
+ },
1669
+ },
1670
+ },
1671
+ },
1672
+ panel: {
1673
+ tabs: [
1674
+ {
1675
+ id: "roster",
1676
+ title: text("Roster"),
1677
+ sections: [
1678
+ {
1679
+ layout: BridgeLayout.Table,
1680
+ id: "online",
1681
+ module: "players",
1682
+ columns: [
1683
+ {
1684
+ key: "name",
1685
+ label: text("Name"),
1686
+ },
1687
+ ],
1688
+ ...section,
1689
+ },
1690
+ ],
1691
+ },
1692
+ ],
1693
+ },
1694
+ });
1695
+
1696
+ const placedForm = (place: BridgePlace): BridgeDriver => {
1697
+ const driver = driverWith(anyVisibleWhen);
1698
+
1699
+ Object.assign(formSectionOf(driver), {
1700
+ place,
1701
+ });
1702
+
1703
+ return driver;
1704
+ };
1705
+
1706
+ test("accepts a table placed on the players page", () => {
1707
+ expect(() =>
1708
+ validateDriver(
1709
+ driverWithRoster({
1710
+ place: BridgePlace.Players,
1711
+ }),
1712
+ ),
1713
+ ).not.toThrow();
1714
+ });
1715
+
1716
+ test("rejects a form placed on the players page", () => {
1717
+ expect(() => validateDriver(placedForm(BridgePlace.Players))).toThrow(
1718
+ 'tab "version" section "version" places a form section on players, which that page cannot render',
1719
+ );
1720
+ });
1721
+
1722
+ test("rejects a form placed on the overview page", () => {
1723
+ expect(() => validateDriver(placedForm(BridgePlace.Overview))).toThrow(
1724
+ "places a form section on overview, which that page cannot render",
1725
+ );
1726
+ });
1727
+
1728
+ test("rejects a table placed on the overview page", () => {
1729
+ expect(() =>
1730
+ validateDriver(
1731
+ driverWithRoster({
1732
+ place: BridgePlace.Overview,
1733
+ }),
1734
+ ),
1735
+ ).toThrow('tab "roster" section "online" places a table section on overview, which that page cannot render');
1736
+ });
1737
+
1738
+ test("accepts an offline row action on a table", () => {
1739
+ expect(() =>
1740
+ validateDriver(
1741
+ driverWithRoster({
1742
+ place: BridgePlace.Players,
1743
+ actions: [
1744
+ {
1745
+ id: "ban",
1746
+ label: text("Ban"),
1747
+ offline: true,
1748
+ },
1749
+ ],
1750
+ }),
1751
+ ),
1752
+ ).not.toThrow();
1753
+ });
1754
+
1755
+ test("rejects an offline action on an actions section", () => {
1756
+ expect(() =>
1757
+ validateDriver(
1758
+ driverWithActions([
1759
+ {
1760
+ id: "say",
1761
+ label: text("Say"),
1762
+ offline: true,
1763
+ },
1764
+ ]),
1765
+ ),
1766
+ ).toThrow(
1767
+ 'tab "gameplay" section "broadcast" action "say" is offline-only, which only a table or cards row action can be',
1768
+ );
1769
+ });
1770
+
1771
+ test("rejects a form help that is missing a locale", () => {
1772
+ const driver = driverWith(anyVisibleWhen);
1773
+
1774
+ formSectionOf(driver).help = {
1775
+ ar: "",
1776
+ en: "The version the server runs",
1777
+ };
1778
+
1779
+ expect(() => validateDriver(driver)).toThrow(
1780
+ 'tab "version" section "version" help needs both arabic and english text',
1781
+ );
1782
+ });
1783
+ });
@@ -13,6 +13,7 @@ import {
13
13
  type BridgeField,
14
14
  BridgeFormTarget,
15
15
  BridgeLayout,
16
+ BridgePlace,
16
17
  type BridgeSection,
17
18
  type BridgeSectionAction,
18
19
  type BridgeTab,
@@ -333,12 +334,53 @@ const validateActions = (modules: Record<string, BridgePanelModule>, actions: Br
333
334
  }
334
335
  };
335
336
 
337
+ const PLACE_LAYOUTS: Record<BridgePlace, BridgeLayout[]> = {
338
+ [BridgePlace.Overview]: [
339
+ BridgeLayout.Actions,
340
+ BridgeLayout.Detail,
341
+ ],
342
+ [BridgePlace.Players]: [
343
+ BridgeLayout.Actions,
344
+ BridgeLayout.Cards,
345
+ BridgeLayout.Table,
346
+ ],
347
+ };
348
+
349
+ const validatePlace = (section: BridgeSection, where: string) => {
350
+ const place = "place" in section ? section.place : undefined;
351
+
352
+ if (place === undefined) {
353
+ return;
354
+ }
355
+
356
+ if (!(PLACE_LAYOUTS[place] ?? []).includes(section.layout)) {
357
+ fail(`${where} places a ${section.layout} section on ${place}, which that page cannot render`);
358
+ }
359
+ };
360
+
361
+ const validateOffline = (section: BridgeSection, where: string) => {
362
+ if (section.layout === BridgeLayout.Table || section.layout === BridgeLayout.Cards) {
363
+ return;
364
+ }
365
+
366
+ const actions = "actions" in section ? (section.actions ?? []) : [];
367
+
368
+ for (const action of actions) {
369
+ if (action.offline !== undefined) {
370
+ fail(`${where} action "${action.id}" is offline-only, which only a table or cards row action can be`);
371
+ }
372
+ }
373
+ };
374
+
336
375
  const validateSection = (modules: Record<string, BridgePanelModule>, section: BridgeSection, where: string) => {
337
376
  validateVisibleWhen(section.visibleWhen, where);
338
377
  validateText(section.title, "title", where);
378
+ validateText(section.help, "help", where);
379
+ validatePlace(section, where);
380
+ validateOffline(section, where);
339
381
 
340
- if (section.layout !== BridgeLayout.Form) {
341
- validateText(section.layout === BridgeLayout.Actions ? section.help : section.empty, "text", where);
382
+ if (section.layout !== BridgeLayout.Form && section.layout !== BridgeLayout.Actions) {
383
+ validateText(section.empty, "empty text", where);
342
384
  }
343
385
 
344
386
  switch (section.layout) {