@flamingo-stack/openframe-frontend-core 0.0.740 → 0.0.742
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/dist/build/export-map.json +1 -1
- package/dist/{chunk-BV5WZVOP.cjs → chunk-32Q7K5OP.cjs} +254 -254
- package/dist/{chunk-ZLTCQKTD.cjs → chunk-3LW43GIJ.cjs} +6 -6
- package/dist/{chunk-VA7OMDZO.cjs → chunk-4FWURR3W.cjs} +50 -50
- package/dist/{chunk-EKGL5K5K.cjs → chunk-4LDU32OB.cjs} +17 -17
- package/dist/{chunk-VC4IV3AY.cjs → chunk-4PKORVOX.cjs} +2804 -954
- package/dist/{chunk-3ZIQOYNX.cjs → chunk-54EA4RLX.cjs} +8 -8
- package/dist/{chunk-YXKB5WRE.cjs → chunk-5MH6BRPY.cjs} +36 -36
- package/dist/{chunk-PQ2M7P2U.cjs → chunk-5TCIRWHL.cjs} +9 -9
- package/dist/{chunk-WFPCCBOM.cjs → chunk-7AM3AFM4.cjs} +10 -10
- package/dist/{chunk-7ZFCR7UY.cjs → chunk-ABLUMGCI.cjs} +3 -3
- package/dist/{chunk-PX7FY655.cjs → chunk-ASMNNSUQ.cjs} +3 -3
- package/dist/{chunk-HF7Z2E4W.cjs → chunk-BI3XBCQX.cjs} +2 -2
- package/dist/{chunk-SA7LDEVJ.cjs → chunk-CSJO7ZGN.cjs} +3 -3
- package/dist/{chunk-VOX4E6AX.cjs → chunk-DGFDBPO7.cjs} +7 -5
- package/dist/{chunk-KHXZFLIN.cjs → chunk-DKOLQPE7.cjs} +8 -2
- package/dist/{chunk-BDA5F6RM.cjs → chunk-DNFSMVXJ.cjs} +16 -16
- package/dist/{chunk-B3XBJCOD.cjs → chunk-H3UA7UY5.cjs} +7 -7
- package/dist/{chunk-2Y5Y4FLM.cjs → chunk-HDBY2URA.cjs} +64 -64
- package/dist/{chunk-UW3YXDC4.cjs → chunk-HIRVNBX5.cjs} +20 -20
- package/dist/{chunk-LLAC2UZ3.cjs → chunk-ISJXKKYW.cjs} +28 -28
- package/dist/{chunk-FICJS5OA.cjs → chunk-KTPR66EE.cjs} +4 -4
- package/dist/{chunk-7UIQPMBJ.cjs → chunk-L34TKFTS.cjs} +21 -164
- package/dist/{chunk-AAFW4STC.cjs → chunk-LU7TD6QS.cjs} +5 -5
- package/dist/{chunk-3HPB4H4C.cjs → chunk-NTGIQP7O.cjs} +7 -7
- package/dist/{chunk-WBANGORJ.cjs → chunk-OBODY7F3.cjs} +3 -3
- package/dist/{chunk-GABDE3FM.cjs → chunk-OWGLRE74.cjs} +19 -19
- package/dist/{chunk-CWRVTQUO.cjs → chunk-PS6T65KA.cjs} +101 -101
- package/dist/{chunk-IFS4AH77.cjs → chunk-QYLIHZKZ.cjs} +10 -10
- package/dist/{chunk-GVTDVS5U.cjs → chunk-R34JD6NP.cjs} +3 -3
- package/dist/{chunk-VU4CKUJW.cjs → chunk-R6SCSV2I.cjs} +103 -103
- package/dist/{chunk-II4QMH26.cjs → chunk-STEJZPIO.cjs} +3 -3
- package/dist/{chunk-4RLGLB45.cjs → chunk-T433HSL6.cjs} +47 -47
- package/dist/{chunk-DSVNFRY5.cjs → chunk-U2O5YQRP.cjs} +38 -38
- package/dist/{chunk-O67V3HJR.cjs → chunk-URVIPSAJ.cjs} +24 -24
- package/dist/{chunk-HYBMADYO.cjs → chunk-UWHCRIQQ.cjs} +22 -22
- package/dist/{chunk-GSWQZX23.cjs → chunk-UY6A6TEY.cjs} +2 -2
- package/dist/{chunk-D5KEWWLW.cjs → chunk-UZLRSFGG.cjs} +32 -32
- package/dist/{chunk-DUVOQHQI.cjs → chunk-W2RM764D.cjs} +7 -7
- package/dist/{chunk-NTJE6SM6.cjs → chunk-WI2OTGKC.cjs} +2 -2
- package/dist/{chunk-SFY4UYPY.cjs → chunk-WIHHA54J.cjs} +44 -44
- package/dist/{chunk-YRE3O65E.cjs → chunk-XAA5EZ5T.cjs} +12 -12
- package/dist/{chunk-KLI4OSRA.cjs → chunk-YQGDXH3M.cjs} +2 -2
- package/dist/{chunk-SWR22DOZ.cjs → chunk-Z2DQYKZA.cjs} +89 -89
- package/dist/{chunk-5SVOGLNK.cjs → chunk-ZIFYSLIM.cjs} +6 -6
- package/dist/{cloud-tenants-PVXDI5MU.cjs → cloud-tenants-6ICAL3HO.cjs} +34 -34
- package/dist/components/case-studies/index.cjs +37 -37
- package/dist/components/chat/index.cjs +17 -17
- package/dist/components/contact/index.cjs +25 -25
- package/dist/components/docs/index.cjs +28 -28
- package/dist/components/embeds/index.cjs +25 -25
- package/dist/components/faq/index.cjs +26 -26
- package/dist/components/features/capability-explorer.d.ts +9 -2
- package/dist/components/features/capability-explorer.d.ts.map +1 -1
- package/dist/components/features/capability-explorer.js +113 -6
- package/dist/components/features/index.cjs +162 -24
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +1 -0
- package/dist/components/features/scripts/execution-cells.d.ts +65 -0
- package/dist/components/features/scripts/execution-cells.d.ts.map +1 -0
- package/dist/components/features/scripts/execution-cells.js +75 -0
- package/dist/components/features/scripts/execution-status.d.ts +48 -0
- package/dist/components/features/scripts/execution-status.d.ts.map +1 -0
- package/dist/components/features/scripts/execution-status.js +15 -0
- package/dist/components/features/scripts/index.d.ts +20 -0
- package/dist/components/features/scripts/index.d.ts.map +1 -0
- package/dist/components/features/scripts/index.js +59 -0
- package/dist/components/features/scripts/schedule-form/index.d.ts +18 -0
- package/dist/components/features/scripts/schedule-form/index.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/index.js +84 -0
- package/dist/components/features/scripts/schedule-form/schedule-enums.d.ts +24 -0
- package/dist/components/features/scripts/schedule-form/schedule-enums.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-enums.js +18 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-fields.d.ts +57 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-fields.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-fields.js +169 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-types.d.ts +138 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-types.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-form-types.js +72 -0
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts +54 -0
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-interval-input.js +60 -0
- package/dist/components/features/scripts/schedule-form/schedule-offline-fields.d.ts +29 -0
- package/dist/components/features/scripts/schedule-form/schedule-offline-fields.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-offline-fields.js +124 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-picker-card.d.ts +52 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-picker-card.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-picker-card.js +244 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-search-field.d.ts +35 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-search-field.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-script-search-field.js +58 -0
- package/dist/components/features/scripts/schedule-form/schedule-scripts-field.d.ts +25 -0
- package/dist/components/features/scripts/schedule-form/schedule-scripts-field.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-scripts-field.js +66 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing-fields.d.ts +77 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing-fields.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing-fields.js +262 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing.d.ts +288 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing.d.ts.map +1 -0
- package/dist/components/features/scripts/schedule-form/schedule-timing.js +165 -0
- package/dist/components/features/scripts/script-cells.d.ts +11 -0
- package/dist/components/features/scripts/script-cells.d.ts.map +1 -0
- package/dist/components/features/scripts/script-cells.js +19 -0
- package/dist/components/features/scripts/script-executions-table.d.ts +58 -0
- package/dist/components/features/scripts/script-executions-table.d.ts.map +1 -0
- package/dist/components/features/scripts/script-executions-table.js +88 -0
- package/dist/components/features/scripts/script-schedules-table.d.ts +38 -0
- package/dist/components/features/scripts/script-schedules-table.d.ts.map +1 -0
- package/dist/components/features/scripts/script-schedules-table.js +71 -0
- package/dist/components/features/scripts/script-shell-badge.d.ts +9 -0
- package/dist/components/features/scripts/script-shell-badge.d.ts.map +1 -0
- package/dist/components/features/scripts/script-shell-badge.js +15 -0
- package/dist/components/features/scripts/scripts-table-columns.d.ts +162 -0
- package/dist/components/features/scripts/scripts-table-columns.d.ts.map +1 -0
- package/dist/components/features/scripts/scripts-table-columns.js +69 -0
- package/dist/components/features/scripts/scripts-table-shared.d.ts +19 -0
- package/dist/components/features/scripts/scripts-table-shared.d.ts.map +1 -0
- package/dist/components/features/scripts/scripts-table-shared.js +35 -0
- package/dist/components/features/scripts/scripts-table.d.ts +40 -0
- package/dist/components/features/scripts/scripts-table.d.ts.map +1 -0
- package/dist/components/features/scripts/scripts-table.js +84 -0
- package/dist/components/features/scripts/scripts-tabs.d.ts +5 -0
- package/dist/components/features/scripts/scripts-tabs.d.ts.map +1 -0
- package/dist/components/features/scripts/scripts-tabs.js +10 -0
- package/dist/components/features/scripts/shell-types.d.ts +13 -0
- package/dist/components/features/scripts/shell-types.d.ts.map +1 -0
- package/dist/components/features/scripts/shell-types.js +21 -0
- package/dist/components/help-center-pages/index.cjs +144 -144
- package/dist/components/index.cjs +224 -86
- package/dist/components/meeting-scheduler/index.cjs +74 -74
- package/dist/components/navigation/index.cjs +27 -27
- package/dist/components/onboarding-guides/index.cjs +27 -27
- package/dist/components/product-demo/fixtures/scripts.d.ts +34 -0
- package/dist/components/product-demo/fixtures/scripts.d.ts.map +1 -0
- package/dist/components/product-demo/fixtures/scripts.js +123 -0
- package/dist/components/product-demo/fixtures/shared.d.ts +8 -0
- package/dist/components/product-demo/fixtures/shared.d.ts.map +1 -1
- package/dist/components/product-demo/fixtures/shared.js +7 -1
- package/dist/components/product-demo/fixtures/software-update.d.ts.map +1 -1
- package/dist/components/product-demo/fixtures/software-update.js +1 -5
- package/dist/components/product-demo/index.cjs +16 -13
- package/dist/components/product-demo/product-screen.d.ts.map +1 -1
- package/dist/components/product-demo/product-screen.js +2 -1
- package/dist/components/product-demo/registry.d.ts.map +1 -1
- package/dist/components/product-demo/registry.js +2 -1
- package/dist/components/product-demo/screen-keys.cjs +2 -1
- package/dist/components/product-demo/screen-keys.d.ts +1 -0
- package/dist/components/product-demo/screen-keys.d.ts.map +1 -1
- package/dist/components/product-demo/screen-keys.js +2 -1
- package/dist/components/product-demo/screens/scripts.d.ts +11 -0
- package/dist/components/product-demo/screens/scripts.d.ts.map +1 -0
- package/dist/components/product-demo/screens/scripts.js +130 -0
- package/dist/components/related-content/index.cjs +12 -12
- package/dist/components/tickets/index.cjs +30 -30
- package/dist/components/ui/index.cjs +24 -24
- package/dist/{devices-TWEA7XOR.cjs → devices-OFP2IBKN.cjs} +30 -30
- package/dist/{embeddable-chat-6JKKFQEB.cjs → embeddable-chat-4C22MGNC.cjs} +92 -92
- package/dist/hooks/index.cjs +10 -10
- package/dist/index.cjs +169 -31
- package/dist/{knowledge-4GQDBNQB.cjs → knowledge-KKBCSNUA.cjs} +29 -29
- package/dist/{logs-EVU6GJHZ.cjs → logs-X2GRNO57.cjs} +46 -46
- package/dist/{policies-OZUEA5YS.cjs → policies-35BXKB6P.cjs} +31 -31
- package/dist/{remote-session-DNFZ5OLY.cjs → remote-session-N4PJB5FW.cjs} +15 -15
- package/dist/scripts-KQYVQOLU.cjs +2112 -0
- package/dist/{software-update-BLIVYOY2.cjs → software-update-ED33WSKT.cjs} +36 -40
- package/dist/{tickets-board-CKZRB6YV.cjs → tickets-board-O753S4ZB.cjs} +31 -31
- package/dist/utils/embed-authed-fetch.d.ts +33 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/embed-authed-fetch.js +3 -1
- package/dist/utils/index.cjs +3 -1
- package/dist/{video-file-player-ZH2ILAPO.cjs → video-file-player-QZ3YOLHO.cjs} +2 -2
- package/package.json +1 -1
- package/src/components/features/capability-explorer.tsx +134 -7
- package/src/components/features/index.ts +1 -0
- package/src/components/features/scripts/execution-cells.tsx +149 -0
- package/src/components/features/scripts/execution-status.ts +42 -0
- package/src/components/features/scripts/index.ts +42 -0
- package/src/components/features/scripts/schedule-form/index.ts +59 -0
- package/src/components/features/scripts/schedule-form/schedule-enums.ts +26 -0
- package/src/components/features/scripts/schedule-form/schedule-form-fields.tsx +249 -0
- package/src/components/features/scripts/schedule-form/schedule-form-types.ts +189 -0
- package/src/components/features/scripts/schedule-form/schedule-interval-input.tsx +121 -0
- package/src/components/features/scripts/schedule-form/schedule-offline-fields.tsx +218 -0
- package/src/components/features/scripts/schedule-form/schedule-script-picker-card.tsx +321 -0
- package/src/components/features/scripts/schedule-form/schedule-script-search-field.tsx +115 -0
- package/src/components/features/scripts/schedule-form/schedule-scripts-field.tsx +97 -0
- package/src/components/features/scripts/schedule-form/schedule-timing-fields.tsx +482 -0
- package/src/components/features/scripts/schedule-form/schedule-timing.ts +484 -0
- package/src/components/features/scripts/script-cells.tsx +29 -0
- package/src/components/features/scripts/script-executions-table.tsx +145 -0
- package/src/components/features/scripts/script-schedules-table.tsx +113 -0
- package/src/components/features/scripts/script-shell-badge.tsx +25 -0
- package/src/components/features/scripts/scripts-table-columns.ts +95 -0
- package/src/components/features/scripts/scripts-table-shared.tsx +43 -0
- package/src/components/features/scripts/scripts-table.tsx +124 -0
- package/src/components/features/scripts/scripts-tabs.ts +11 -0
- package/src/components/features/scripts/shell-types.ts +26 -0
- package/src/components/product-demo/fixtures/scripts.ts +186 -0
- package/src/components/product-demo/fixtures/shared.ts +7 -0
- package/src/components/product-demo/fixtures/software-update.ts +1 -7
- package/src/components/product-demo/product-screen.tsx +3 -1
- package/src/components/product-demo/registry.ts +1 -0
- package/src/components/product-demo/screen-keys.ts +1 -0
- package/src/components/product-demo/screens/scripts.tsx +192 -0
- package/src/utils/embed-authed-fetch.ts +42 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ScriptArgument } from '../../../platform/ScriptArguments';
|
|
3
|
+
import {
|
|
4
|
+
SCHEDULE_OFFLINE_BEHAVIOR,
|
|
5
|
+
SCHEDULE_TIME_REFERENCE,
|
|
6
|
+
type ScheduleOfflineBehavior,
|
|
7
|
+
type ScheduleTimeReference,
|
|
8
|
+
SCRIPT_SCHEDULE_TRIGGER,
|
|
9
|
+
type ScriptScheduleTrigger,
|
|
10
|
+
} from './schedule-enums';
|
|
11
|
+
import { DEFAULT_RECONNECT_WINDOW, type DurationUnit } from './schedule-timing';
|
|
12
|
+
|
|
13
|
+
/** One of a script's own environment variables. `value` is null for a secret: the server masks it on every read. */
|
|
14
|
+
export interface ScheduleFormEnvVar {
|
|
15
|
+
name: string;
|
|
16
|
+
value: string | null;
|
|
17
|
+
secret: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One card of "Scheduled Scripts": the picked script and what the schedule runs it with. */
|
|
21
|
+
export interface ScheduleFormScriptRow {
|
|
22
|
+
/** `''` = no script picked yet. */
|
|
23
|
+
scriptId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
/** The picked script's OWN platforms (UI ids) — a host checks them against the schedule's. */
|
|
26
|
+
supportedPlatforms: string[];
|
|
27
|
+
// 0 = no script picked yet, so the field is locked and empty; a real
|
|
28
|
+
// timeout only exists once a script is chosen (it seeds this).
|
|
29
|
+
timeoutSeconds: number;
|
|
30
|
+
args: ScriptArgument[];
|
|
31
|
+
envVars: ScriptArgument[];
|
|
32
|
+
/**
|
|
33
|
+
* The picked script's OWN defaults, carried with no control of their
|
|
34
|
+
* own: an override is written only for the half that differs from
|
|
35
|
+
* them, so a schedule keeps inheriting later edits to the script
|
|
36
|
+
* instead of freezing a copy the day it was saved.
|
|
37
|
+
*/
|
|
38
|
+
defaultArgs: string[];
|
|
39
|
+
defaultEnvVars: ScheduleFormEnvVar[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Everything the schedule form holds. The fields read it off the
|
|
44
|
+
* react-hook-form context; the host owns the form itself (its schema, its
|
|
45
|
+
* seeding, its submit), and checks its own form type against this one.
|
|
46
|
+
*/
|
|
47
|
+
export interface ScheduleFormData {
|
|
48
|
+
name: string;
|
|
49
|
+
/** "Note" in the UI. */
|
|
50
|
+
description: string;
|
|
51
|
+
trigger: ScriptScheduleTrigger;
|
|
52
|
+
scheduledDate: Date | null;
|
|
53
|
+
/** `HH:mm` on the 30-minute grid; `''` = the user hasn't picked a time yet. */
|
|
54
|
+
scheduledTime: string;
|
|
55
|
+
/** Which clock the pair above means — the "Timezone" control. Held even when the control is not rendered. */
|
|
56
|
+
timeReference: ScheduleTimeReference;
|
|
57
|
+
repeatEnabled: boolean;
|
|
58
|
+
/** `null` while the box is EMPTY — the user is mid-edit, not proposing zero. */
|
|
59
|
+
repeatInterval: number | null;
|
|
60
|
+
repeatUnit: DurationUnit;
|
|
61
|
+
/** The stored cadence the form was seeded with, carried with no control of its own. */
|
|
62
|
+
repeatSecondsStored: number | null;
|
|
63
|
+
/**
|
|
64
|
+
* The stored start the form was seeded with, carried with no control of its
|
|
65
|
+
* own: a start that has already passed stays legal for as long as the form
|
|
66
|
+
* still shows exactly it (see `isStartInPastAndChanged`).
|
|
67
|
+
*/
|
|
68
|
+
startAtStored: string | null;
|
|
69
|
+
offlineBehavior: ScheduleOfflineBehavior;
|
|
70
|
+
/** "Stop Retry after", as the `[interval] [unit]` pair. `null` while the box is empty. */
|
|
71
|
+
reconnectInterval: number | null;
|
|
72
|
+
reconnectUnit: DurationUnit;
|
|
73
|
+
/** The stored reconnect window the form was seeded with, carried with no control of its own. */
|
|
74
|
+
reconnectWindowSecondsStored: number | null;
|
|
75
|
+
/** UI platform ids the schedule targets. */
|
|
76
|
+
supportedPlatforms: string[];
|
|
77
|
+
/** Card order IS the run order. */
|
|
78
|
+
scripts: ScheduleFormScriptRow[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const EMPTY_SCHEDULE_SCRIPT_ROW: ScheduleFormScriptRow = {
|
|
82
|
+
scriptId: '',
|
|
83
|
+
name: '',
|
|
84
|
+
supportedPlatforms: [],
|
|
85
|
+
// 0 keeps the Timeout field locked and empty until a script is picked.
|
|
86
|
+
timeoutSeconds: 0,
|
|
87
|
+
args: [],
|
|
88
|
+
envVars: [],
|
|
89
|
+
defaultArgs: [],
|
|
90
|
+
defaultEnvVars: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const SCHEDULE_TRIGGER_OPTIONS = [
|
|
94
|
+
{
|
|
95
|
+
value: SCRIPT_SCHEDULE_TRIGGER.DATE_TIME,
|
|
96
|
+
label: 'Run on schedule',
|
|
97
|
+
description: 'Runs at the set date and time, whether or not the device is online.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
value: SCRIPT_SCHEDULE_TRIGGER.DEVICE_ONLINE,
|
|
101
|
+
label: 'Run when device comes online',
|
|
102
|
+
description: "Waits for the device to connect, then runs as soon as it's reachable.",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The "Timezone" dropdown (design node 793:61340), in the designer's own
|
|
108
|
+
* wording: the account's clock, or each device's.
|
|
109
|
+
*
|
|
110
|
+
* SERVER first — it is the backend's documented default for a null
|
|
111
|
+
* `timeReference`, and it is what every schedule authored before the field
|
|
112
|
+
* existed already means.
|
|
113
|
+
*/
|
|
114
|
+
export const SCHEDULE_TIME_REFERENCE_OPTIONS = [
|
|
115
|
+
{ value: SCHEDULE_TIME_REFERENCE.SERVER, label: 'Your account timezone' },
|
|
116
|
+
{ value: SCHEDULE_TIME_REFERENCE.DEVICE_LOCAL, label: 'Device local timezone' },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The two answers to "if device is offline at scheduled time" (design node
|
|
121
|
+
* 460:63425). SKIP first, because it is both the design's default and the
|
|
122
|
+
* behavior every schedule authored before this field existed already has — the
|
|
123
|
+
* backend reads a null `offlineBehavior` as SKIP.
|
|
124
|
+
*/
|
|
125
|
+
export const SCHEDULE_OFFLINE_BEHAVIOR_OPTIONS = [
|
|
126
|
+
{
|
|
127
|
+
value: SCHEDULE_OFFLINE_BEHAVIOR.SKIP,
|
|
128
|
+
label: 'Skip this Run',
|
|
129
|
+
description: 'Next run stays on schedule. Missed runs are logged.',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
value: SCHEDULE_OFFLINE_BEHAVIOR.RETRY_ON_RECONNECT,
|
|
133
|
+
label: 'Run when device comes back online',
|
|
134
|
+
description: 'Executes on the next reconnect.',
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export const DEFAULT_SCHEDULE_FORM_VALUES: ScheduleFormData = {
|
|
139
|
+
name: '',
|
|
140
|
+
description: '',
|
|
141
|
+
trigger: SCRIPT_SCHEDULE_TRIGGER.DATE_TIME,
|
|
142
|
+
scheduledDate: null,
|
|
143
|
+
scheduledTime: '',
|
|
144
|
+
timeReference: SCHEDULE_TIME_REFERENCE.SERVER,
|
|
145
|
+
repeatEnabled: false,
|
|
146
|
+
repeatInterval: 1,
|
|
147
|
+
repeatUnit: 'day',
|
|
148
|
+
repeatSecondsStored: null,
|
|
149
|
+
startAtStored: null,
|
|
150
|
+
offlineBehavior: SCHEDULE_OFFLINE_BEHAVIOR.SKIP,
|
|
151
|
+
reconnectInterval: DEFAULT_RECONNECT_WINDOW.interval,
|
|
152
|
+
reconnectUnit: DEFAULT_RECONNECT_WINDOW.unit,
|
|
153
|
+
reconnectWindowSecondsStored: null,
|
|
154
|
+
supportedPlatforms: ['windows'],
|
|
155
|
+
scripts: [EMPTY_SCHEDULE_SCRIPT_ROW],
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A flag a host has asked about and may not have an answer to yet. `loading`
|
|
160
|
+
* is never read as `off`: the fields it decides are locked until it settles.
|
|
161
|
+
*/
|
|
162
|
+
export type ScheduleFormGate = 'on' | 'off' | 'loading';
|
|
163
|
+
|
|
164
|
+
/** One option of the Time dropdown: `value` is 24h `HH:mm`, `label` what the viewer reads. */
|
|
165
|
+
export interface ScheduleTimeSlot {
|
|
166
|
+
value: string;
|
|
167
|
+
label: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a card's "Select Script" slot is told. Searching the script library is
|
|
172
|
+
* the host's query, so the field is a slot: the product passes its data-bound
|
|
173
|
+
* search, a fixture passes a static one. Both draw `ScheduleScriptSearchField`.
|
|
174
|
+
*/
|
|
175
|
+
export interface ScheduleScriptSearchSlotProps {
|
|
176
|
+
/** The card's script: `''` while nothing is picked. */
|
|
177
|
+
scriptId: string;
|
|
178
|
+
/** Its name — the label shown when the script is not on the current search page. */
|
|
179
|
+
name: string;
|
|
180
|
+
/** UI platform ids the schedule targets — what a host narrows its search by. */
|
|
181
|
+
supportedPlatforms: string[];
|
|
182
|
+
/** Writes the card: the row a picked script seeds, or null when the field is cleared. */
|
|
183
|
+
onPick: (row: ScheduleFormScriptRow | null) => void;
|
|
184
|
+
disabled: boolean;
|
|
185
|
+
error: string | undefined;
|
|
186
|
+
invalid: boolean;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type RenderScheduleScriptSearch = (field: ScheduleScriptSearchSlotProps) => ReactNode;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Input } from '../../../ui/input';
|
|
5
|
+
|
|
6
|
+
interface ScheduleIntervalInputProps {
|
|
7
|
+
/** The form's value. `null` = the box is empty. */
|
|
8
|
+
value: number | null;
|
|
9
|
+
onChange: (next: number | null) => void;
|
|
10
|
+
/**
|
|
11
|
+
* The form's own blur handler. Load-bearing, not optional plumbing: react-hook-form
|
|
12
|
+
* learns a field was left ONLY through this, and `reValidateMode: 'onBlur'` is
|
|
13
|
+
* what re-runs the schema. Without it an error the user has already fixed sits
|
|
14
|
+
* on screen until the next Save.
|
|
15
|
+
*/
|
|
16
|
+
onBlur?: () => void;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
min?: number;
|
|
21
|
+
className?: string;
|
|
22
|
+
'aria-label'?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Marks this box as where `scrollToFirstInvalidField` should put focus when
|
|
25
|
+
* the error is carried by an ancestor rather than by the input itself.
|
|
26
|
+
*/
|
|
27
|
+
'data-invalid-focus'?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `[interval]` half of the form's two duration pairs (Repeat in, Stop Retry
|
|
32
|
+
* after).
|
|
33
|
+
*
|
|
34
|
+
* **What the user types owns the display; the form value never writes back into
|
|
35
|
+
* it while the field has focus.** That inversion is the whole component. Binding
|
|
36
|
+
* a number input straight to validated state reads as a bug rather than a rule:
|
|
37
|
+
* the previous version coerced an emptied box back to a floor on every
|
|
38
|
+
* keystroke, so the digit reappeared as fast as it was deleted and "1" could not
|
|
39
|
+
* be typed over. Any round trip through `Number()` has the same shape in
|
|
40
|
+
* smaller ways — a leading zero vanishes mid-word, "10" briefly becomes "1"
|
|
41
|
+
* while a character is being removed.
|
|
42
|
+
*
|
|
43
|
+
* So the raw string is local state, and the form is told the PARSED value as it
|
|
44
|
+
* changes. Keeping the form current (rather than committing on blur alone)
|
|
45
|
+
* matters for one case: submitting with Enter never fires a blur, and a form
|
|
46
|
+
* reading a stale interval would save a number the user cannot see.
|
|
47
|
+
*
|
|
48
|
+
* The reverse direction — form → box — is deliberately narrow. It runs only
|
|
49
|
+
* when the field is NOT focused, which is exactly the set of changes that come
|
|
50
|
+
* from somewhere else: the record arriving (`useSeedForm`), or the unit
|
|
51
|
+
* dropdown raising the interval to its floor. While the box has focus those
|
|
52
|
+
* cannot happen, and nothing else may repaint what is being typed.
|
|
53
|
+
*
|
|
54
|
+
* Validation timing is the form's business, not this component's: errors are
|
|
55
|
+
* held until the first Save (`showErrors`) and re-checked on blur
|
|
56
|
+
* (`reValidateMode`), so nothing here is judged mid-word.
|
|
57
|
+
*/
|
|
58
|
+
export function ScheduleIntervalInput({
|
|
59
|
+
value,
|
|
60
|
+
onChange,
|
|
61
|
+
onBlur,
|
|
62
|
+
disabled,
|
|
63
|
+
invalid,
|
|
64
|
+
error,
|
|
65
|
+
min,
|
|
66
|
+
className,
|
|
67
|
+
'aria-label': ariaLabel,
|
|
68
|
+
'data-invalid-focus': invalidFocus,
|
|
69
|
+
}: ScheduleIntervalInputProps) {
|
|
70
|
+
const [text, setText] = useState(() => (value === null ? '' : String(value)));
|
|
71
|
+
const focused = useRef(false);
|
|
72
|
+
|
|
73
|
+
// Adopts the FORM's value, never its own output — hence the functional
|
|
74
|
+
// `setText`, which keeps `text` out of the dependency list. Reacting to the
|
|
75
|
+
// text this effect just wrote is what would fight the user.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (focused.current) return;
|
|
78
|
+
const next = value === null ? '' : String(value);
|
|
79
|
+
setText(current => (current === next ? current : next));
|
|
80
|
+
}, [value]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Input
|
|
84
|
+
// Left as a number field so the stepper and the numeric keypad stay. The
|
|
85
|
+
// one cost is that a browser reports `''` for a half-typed exponent
|
|
86
|
+
// ("1e"), which reads here as a cleared box — it resolves itself on blur,
|
|
87
|
+
// and it is the browser's own behaviour for the type rather than
|
|
88
|
+
// something this component can improve on.
|
|
89
|
+
type="number"
|
|
90
|
+
min={min}
|
|
91
|
+
inputMode="numeric"
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
data-invalid-focus={invalidFocus || undefined}
|
|
94
|
+
className={className}
|
|
95
|
+
value={text}
|
|
96
|
+
onFocus={() => {
|
|
97
|
+
focused.current = true;
|
|
98
|
+
}}
|
|
99
|
+
onBlur={() => {
|
|
100
|
+
focused.current = false;
|
|
101
|
+
// Normalise what is shown to what was actually stored: "007" settles to
|
|
102
|
+
// "7". Only on the way out, never mid-word.
|
|
103
|
+
setText(value === null ? '' : String(value));
|
|
104
|
+
onBlur?.();
|
|
105
|
+
}}
|
|
106
|
+
onChange={e => {
|
|
107
|
+
const raw = e.target.value;
|
|
108
|
+
setText(raw);
|
|
109
|
+
if (raw.trim() === '') {
|
|
110
|
+
onChange(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const parsed = Number(raw);
|
|
114
|
+
onChange(Number.isFinite(parsed) ? parsed : null);
|
|
115
|
+
}}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
invalid={invalid}
|
|
118
|
+
error={error}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
'use no memo';
|
|
3
|
+
|
|
4
|
+
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
|
5
|
+
import { cn } from '../../../../utils/cn';
|
|
6
|
+
import { Label } from '../../../ui/label';
|
|
7
|
+
import { RadioGroupBlock } from '../../../ui/radio-group';
|
|
8
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select';
|
|
9
|
+
import { TruncateText } from '../../../ui/truncate-text';
|
|
10
|
+
import { type ScheduleFormData, SCHEDULE_OFFLINE_BEHAVIOR_OPTIONS } from './schedule-form-types';
|
|
11
|
+
import { ScheduleIntervalInput } from './schedule-interval-input';
|
|
12
|
+
import {
|
|
13
|
+
DURATION_UNIT_OPTIONS,
|
|
14
|
+
type DurationUnit,
|
|
15
|
+
isEventTrigger,
|
|
16
|
+
isRetryOnReconnect,
|
|
17
|
+
MIN_RECONNECT_MINUTES,
|
|
18
|
+
snapReconnectInterval,
|
|
19
|
+
} from './schedule-timing';
|
|
20
|
+
|
|
21
|
+
interface ReconnectWindowFieldsProps {
|
|
22
|
+
interval: number | null;
|
|
23
|
+
unit: DurationUnit;
|
|
24
|
+
onIntervalChange: (next: number | null) => void;
|
|
25
|
+
onIntervalBlur: () => void;
|
|
26
|
+
onUnitChange: (next: DurationUnit) => void;
|
|
27
|
+
disabled: boolean;
|
|
28
|
+
invalid: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Let the two controls share the width they are given instead of taking the
|
|
31
|
+
* design's fixed 120px each — what the full-width row below `md` needs, where
|
|
32
|
+
* the fixed pair plus its caption overflows a phone.
|
|
33
|
+
*/
|
|
34
|
+
fluid?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* "Stop Retry after [1] [Week]" — how long a queued run stays worth running.
|
|
40
|
+
*
|
|
41
|
+
* Presentational and stateless: the two form fields are registered ONCE by the
|
|
42
|
+
* block below and handed down, because this renders TWICE (see the two call
|
|
43
|
+
* sites). Two `Controller`s on one name would also work, but one registration
|
|
44
|
+
* with two views is the honest description of what this is.
|
|
45
|
+
*/
|
|
46
|
+
function ReconnectWindowFields({
|
|
47
|
+
interval,
|
|
48
|
+
unit,
|
|
49
|
+
onIntervalChange,
|
|
50
|
+
onIntervalBlur,
|
|
51
|
+
onUnitChange,
|
|
52
|
+
disabled,
|
|
53
|
+
invalid,
|
|
54
|
+
fluid = false,
|
|
55
|
+
className,
|
|
56
|
+
}: ReconnectWindowFieldsProps) {
|
|
57
|
+
const controlWidth = fluid ? 'flex-1 min-w-0' : 'w-[120px]';
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('flex items-center gap-[var(--spacing-system-xsf)]', className)}>
|
|
60
|
+
<span className={cn('shrink-0 text-h4', disabled ? 'text-ods-text-disabled' : 'text-ods-text-secondary')}>
|
|
61
|
+
Stop Retry after
|
|
62
|
+
</span>
|
|
63
|
+
<ScheduleIntervalInput
|
|
64
|
+
min={unit === 'minute' ? MIN_RECONNECT_MINUTES : 1}
|
|
65
|
+
aria-label="Stop retry after"
|
|
66
|
+
className={controlWidth}
|
|
67
|
+
value={interval}
|
|
68
|
+
onChange={onIntervalChange}
|
|
69
|
+
onBlur={onIntervalBlur}
|
|
70
|
+
// Where a failed Save should land. Without it the section marker below
|
|
71
|
+
// hands focus to its first focusable descendant, which is the "Skip
|
|
72
|
+
// this Run" radio — three controls away from the value to fix.
|
|
73
|
+
data-invalid-focus
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
invalid={invalid}
|
|
76
|
+
/>
|
|
77
|
+
{/* Switching to minutes drags the interval up to the floor with it: "1 Day"
|
|
78
|
+
would otherwise read "1 Minute", a value the user never typed and the
|
|
79
|
+
form cannot save. Coarser units clear the floor by construction. */}
|
|
80
|
+
<Select value={unit} onValueChange={(next: DurationUnit) => onUnitChange(next)} disabled={disabled}>
|
|
81
|
+
<SelectTrigger className={controlWidth} aria-label="Stop retry after unit">
|
|
82
|
+
<SelectValue />
|
|
83
|
+
</SelectTrigger>
|
|
84
|
+
<SelectContent>
|
|
85
|
+
{DURATION_UNIT_OPTIONS.map(opt => (
|
|
86
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
87
|
+
{opt.label}
|
|
88
|
+
</SelectItem>
|
|
89
|
+
))}
|
|
90
|
+
</SelectContent>
|
|
91
|
+
</Select>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* "If device is offline at scheduled time" — Skip this Run vs Run when device
|
|
98
|
+
* comes back online, plus the reconnect window the second one needs
|
|
99
|
+
* (design node 460:63425).
|
|
100
|
+
*
|
|
101
|
+
* Maps to `offlineBehavior` (`SKIP` | `RETRY_ON_RECONNECT`) and
|
|
102
|
+
* `reconnectWindowSeconds`. The window is submitted ONLY alongside
|
|
103
|
+
* RETRY_ON_RECONNECT — the schema documents it as "set only when offlineBehavior
|
|
104
|
+
* is RETRY_ON_RECONNECT" — which is also why it is required rather than
|
|
105
|
+
* optional-meaning-forever: nothing in the contract says a null window queues a
|
|
106
|
+
* run indefinitely, so the form never offers a reading it cannot back up.
|
|
107
|
+
*
|
|
108
|
+
* The block presupposes a scheduled time the device can be absent FOR, so it
|
|
109
|
+
* collapses for DEVICE_ONLINE — which fires on the reconnect already — and
|
|
110
|
+
* submits the SKIP default instead of what the collapsed controls hold.
|
|
111
|
+
* DEVICE_LOCAL keeps it: its scheduled moment is the device's own wall clock.
|
|
112
|
+
*
|
|
113
|
+
* It collapses with the `0fr → 1fr` grid-rows technique, staying MOUNTED so
|
|
114
|
+
* toggling the trigger back restores what was picked, `inert` to drop the
|
|
115
|
+
* collapsed controls out of the tab order, and a padding/negative-margin pair:
|
|
116
|
+
* the bottom padding is the room the absolutely-positioned error renders into,
|
|
117
|
+
* and the constant negative margin cancels it so a collapsed block leaves no
|
|
118
|
+
* hole.
|
|
119
|
+
*/
|
|
120
|
+
export function ScheduleOfflineFields({ showErrors, disabled = false }: { showErrors: boolean; disabled?: boolean }) {
|
|
121
|
+
const { control } = useFormContext<ScheduleFormData>();
|
|
122
|
+
const trigger = useWatch({ control, name: 'trigger' });
|
|
123
|
+
// The one reading with no offline moment to decide about — see the docstring.
|
|
124
|
+
const collapsed = isEventTrigger(trigger);
|
|
125
|
+
|
|
126
|
+
const { field: behaviorField } = useController({ control, name: 'offlineBehavior' });
|
|
127
|
+
const { field: intervalField, fieldState: intervalState } = useController({ control, name: 'reconnectInterval' });
|
|
128
|
+
const { field: unitField } = useController({ control, name: 'reconnectUnit' });
|
|
129
|
+
|
|
130
|
+
// The window belongs to the second option and only to it. Locked under SKIP
|
|
131
|
+
// rather than hidden: the design keeps it on the row so the choice reads as
|
|
132
|
+
// "skip, or retry for THIS long", and a click anywhere on that row selects the
|
|
133
|
+
// option and unlocks it — the controls themselves are interactive content,
|
|
134
|
+
// which a label's activation behavior skips, so using them never re-triggers
|
|
135
|
+
// the radio they sit inside.
|
|
136
|
+
const windowDisabled = disabled || !isRetryOnReconnect(behaviorField.value);
|
|
137
|
+
const intervalError = showErrors ? intervalState.error?.message : undefined;
|
|
138
|
+
|
|
139
|
+
// Switching TO minutes drags the interval up to the floor with it, the way the
|
|
140
|
+
// repeat pair does: "1 Day" would otherwise read "1 Minute", a value the user
|
|
141
|
+
// never typed and the form cannot save. A cleared field stays cleared.
|
|
142
|
+
const handleUnitChange = (next: DurationUnit) => {
|
|
143
|
+
unitField.onChange(next);
|
|
144
|
+
const snapped = snapReconnectInterval(intervalField.value, next);
|
|
145
|
+
if (snapped !== intervalField.value) intervalField.onChange(snapped);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const windowFieldsFor = (fluid: boolean) => (
|
|
149
|
+
<ReconnectWindowFields
|
|
150
|
+
interval={intervalField.value}
|
|
151
|
+
unit={unitField.value}
|
|
152
|
+
onIntervalChange={intervalField.onChange}
|
|
153
|
+
onIntervalBlur={intervalField.onBlur}
|
|
154
|
+
onUnitChange={handleUnitChange}
|
|
155
|
+
disabled={windowDisabled}
|
|
156
|
+
invalid={!!intervalError}
|
|
157
|
+
fluid={fluid}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const options = SCHEDULE_OFFLINE_BEHAVIOR_OPTIONS.map(option =>
|
|
162
|
+
isRetryOnReconnect(option.value)
|
|
163
|
+
? // Desktop only. Below `md` the caption plus two controls cannot share a
|
|
164
|
+
// row with the option's own label without squeezing it to nothing, so
|
|
165
|
+
// the same fields render full-width under the group instead.
|
|
166
|
+
{ ...option, trailing: <div className="hidden content-md:flex">{windowFieldsFor(false)}</div> }
|
|
167
|
+
: option,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
inert={collapsed}
|
|
173
|
+
style={{
|
|
174
|
+
gridTemplateRows: collapsed ? '0fr' : '1fr',
|
|
175
|
+
opacity: collapsed ? 0 : 1,
|
|
176
|
+
}}
|
|
177
|
+
className="mb-[calc(-1*var(--spacing-system-lf))] grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none"
|
|
178
|
+
>
|
|
179
|
+
<div className="min-h-0 overflow-hidden">
|
|
180
|
+
{/* The bottom padding is the room the error below renders into, and it
|
|
181
|
+
sits on a wrapper INSIDE the clipping box rather than on the box
|
|
182
|
+
itself: a box is never shorter than its own padding — border-box
|
|
183
|
+
included — so padding on the grid item would survive the 0fr track as
|
|
184
|
+
24px of residue that the negative margin above then spends cancelling
|
|
185
|
+
instead of closing the section gap. */}
|
|
186
|
+
<div className="pb-[var(--spacing-system-lf)]">
|
|
187
|
+
{/* `data-invalid` on the SECTION, not on the number inputs: one of
|
|
188
|
+
those two copies is always display:none, and an ancestor is matched
|
|
189
|
+
before either, so the marker is visible at every breakpoint. What
|
|
190
|
+
it cannot say on its own is WHERE to put focus — the first
|
|
191
|
+
focusable inside it is the "Skip this Run" radio — so the input
|
|
192
|
+
carries `data-invalid-focus` and the helper prefers it. */}
|
|
193
|
+
<div
|
|
194
|
+
className="relative flex flex-col gap-[var(--spacing-system-xxs)]"
|
|
195
|
+
data-invalid={intervalError ? true : undefined}
|
|
196
|
+
>
|
|
197
|
+
<Label className="text-h4">If device is offline at scheduled time</Label>
|
|
198
|
+
<RadioGroupBlock
|
|
199
|
+
variant="grouped"
|
|
200
|
+
value={behaviorField.value}
|
|
201
|
+
onValueChange={behaviorField.onChange}
|
|
202
|
+
disabled={disabled}
|
|
203
|
+
options={options}
|
|
204
|
+
/>
|
|
205
|
+
<div className="pt-[var(--spacing-system-xs)] content-md:hidden">{windowFieldsFor(true)}</div>
|
|
206
|
+
{intervalError && (
|
|
207
|
+
<div className="absolute bottom-0 left-0 right-0 translate-y-full">
|
|
208
|
+
<TruncateText variant="h6" className="text-ods-error">
|
|
209
|
+
{intervalError}
|
|
210
|
+
</TruncateText>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|