@rozenite/controls-plugin 1.4.0

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.
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { buildActionRegistry, getActionRegistryKey, serializeSections } from '../shared/serialization';
3
+ import { createSection } from '../shared/types';
4
+
5
+ describe('controls serialization', () => {
6
+ it('omits callbacks from snapshots', () => {
7
+ const sections = [
8
+ createSection({
9
+ id: 'diagnostics',
10
+ title: 'Diagnostics',
11
+ items: [
12
+ {
13
+ id: 'status',
14
+ type: 'text',
15
+ title: 'Status',
16
+ value: 'ready',
17
+ },
18
+ {
19
+ id: 'enabled',
20
+ type: 'toggle',
21
+ title: 'Enabled',
22
+ value: true,
23
+ validate: vi.fn(() => ({ valid: true as const })),
24
+ onUpdate: vi.fn(),
25
+ },
26
+ {
27
+ id: 'reset',
28
+ type: 'button',
29
+ title: 'Reset',
30
+ onPress: vi.fn(),
31
+ },
32
+ {
33
+ id: 'environment',
34
+ type: 'select',
35
+ title: 'Environment',
36
+ value: 'staging',
37
+ options: [
38
+ { label: 'Local', value: 'local' },
39
+ { label: 'Staging', value: 'staging' },
40
+ ],
41
+ validate: vi.fn(() => ({ valid: true as const })),
42
+ onUpdate: vi.fn(),
43
+ },
44
+ {
45
+ id: 'release-label',
46
+ type: 'input',
47
+ title: 'Release Label',
48
+ value: 'build-001',
49
+ placeholder: 'build-001',
50
+ applyLabel: 'Apply',
51
+ validate: vi.fn(() => ({ valid: true as const })),
52
+ onUpdate: vi.fn(),
53
+ },
54
+ ],
55
+ }),
56
+ ];
57
+
58
+ expect(serializeSections(sections)).toEqual([
59
+ {
60
+ id: 'diagnostics',
61
+ title: 'Diagnostics',
62
+ description: undefined,
63
+ items: [
64
+ {
65
+ id: 'status',
66
+ type: 'text',
67
+ title: 'Status',
68
+ value: 'ready',
69
+ },
70
+ {
71
+ id: 'enabled',
72
+ type: 'toggle',
73
+ title: 'Enabled',
74
+ value: true,
75
+ description: undefined,
76
+ disabled: undefined,
77
+ },
78
+ {
79
+ id: 'reset',
80
+ type: 'button',
81
+ title: 'Reset',
82
+ actionLabel: undefined,
83
+ description: undefined,
84
+ disabled: undefined,
85
+ },
86
+ {
87
+ id: 'environment',
88
+ type: 'select',
89
+ title: 'Environment',
90
+ value: 'staging',
91
+ options: [
92
+ { label: 'Local', value: 'local' },
93
+ { label: 'Staging', value: 'staging' },
94
+ ],
95
+ description: undefined,
96
+ disabled: undefined,
97
+ },
98
+ {
99
+ id: 'release-label',
100
+ type: 'input',
101
+ title: 'Release Label',
102
+ value: 'build-001',
103
+ placeholder: 'build-001',
104
+ applyLabel: 'Apply',
105
+ description: undefined,
106
+ disabled: undefined,
107
+ },
108
+ ],
109
+ },
110
+ ]);
111
+ });
112
+
113
+ it('builds an action registry for interactive items', async () => {
114
+ const onUpdateToggle = vi.fn();
115
+ const onPress = vi.fn();
116
+ const onUpdateSelect = vi.fn();
117
+ const onUpdateInput = vi.fn();
118
+ const validateToggle = vi.fn(() => ({ valid: true as const }));
119
+ const validateSelect = vi.fn(() => ({ valid: true as const }));
120
+ const validateInput = vi.fn(() => ({ valid: true as const }));
121
+
122
+ const sections = [
123
+ createSection({
124
+ id: 'controls',
125
+ title: 'Controls',
126
+ items: [
127
+ {
128
+ id: 'flag',
129
+ type: 'toggle',
130
+ title: 'Flag',
131
+ value: false,
132
+ validate: validateToggle,
133
+ onUpdate: onUpdateToggle,
134
+ },
135
+ {
136
+ id: 'refresh',
137
+ type: 'button',
138
+ title: 'Refresh',
139
+ onPress,
140
+ },
141
+ {
142
+ id: 'environment',
143
+ type: 'select',
144
+ title: 'Environment',
145
+ value: 'local',
146
+ options: [
147
+ { label: 'Local', value: 'local' },
148
+ { label: 'Staging', value: 'staging' },
149
+ ],
150
+ validate: validateSelect,
151
+ onUpdate: onUpdateSelect,
152
+ },
153
+ {
154
+ id: 'release-label',
155
+ type: 'input',
156
+ title: 'Release Label',
157
+ value: 'build-001',
158
+ placeholder: 'build-001',
159
+ applyLabel: 'Apply',
160
+ validate: validateInput,
161
+ onUpdate: onUpdateInput,
162
+ },
163
+ ],
164
+ }),
165
+ ];
166
+
167
+ const registry = buildActionRegistry(sections);
168
+
169
+ const toggleEntry = registry.get(getActionRegistryKey('controls', 'flag'));
170
+ const buttonEntry = registry.get(getActionRegistryKey('controls', 'refresh'));
171
+ const selectEntry = registry.get(
172
+ getActionRegistryKey('controls', 'environment')
173
+ );
174
+ const inputEntry = registry.get(
175
+ getActionRegistryKey('controls', 'release-label')
176
+ );
177
+
178
+ expect(toggleEntry?.type).toBe('toggle');
179
+ expect(buttonEntry?.type).toBe('button');
180
+ expect(selectEntry?.type).toBe('select');
181
+ expect(inputEntry?.type).toBe('input');
182
+
183
+ if (toggleEntry?.type === 'toggle') {
184
+ expect(toggleEntry.validate?.(true)).toEqual({ valid: true });
185
+ await toggleEntry.onUpdate(true);
186
+ }
187
+
188
+ if (buttonEntry?.type === 'button') {
189
+ await buttonEntry.onPress();
190
+ }
191
+
192
+ if (selectEntry?.type === 'select') {
193
+ expect(selectEntry.validate?.('staging')).toEqual({ valid: true });
194
+ await selectEntry.onUpdate('staging');
195
+ }
196
+
197
+ if (inputEntry?.type === 'input') {
198
+ expect(inputEntry.validate?.('build-002')).toEqual({ valid: true });
199
+ await inputEntry.onUpdate('build-002');
200
+ }
201
+
202
+ expect(validateToggle).toHaveBeenCalledWith(true);
203
+ expect(onUpdateToggle).toHaveBeenCalledWith(true);
204
+ expect(onPress).toHaveBeenCalledTimes(1);
205
+ expect(validateSelect).toHaveBeenCalledWith('staging');
206
+ expect(onUpdateSelect).toHaveBeenCalledWith('staging');
207
+ expect(validateInput).toHaveBeenCalledWith('build-002');
208
+ expect(onUpdateInput).toHaveBeenCalledWith('build-002');
209
+ });
210
+ });
@@ -0,0 +1,215 @@
1
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
+ import { useEffect, useMemo, useRef } from 'react';
3
+ import type {
4
+ ControlsEventMap,
5
+ ControlsInvokeActionEvent,
6
+ ControlsUpdateRequestEvent,
7
+ } from '../shared/messaging';
8
+ import type { RozeniteControlsPluginOptions } from '../shared/types';
9
+ import {
10
+ buildActionRegistry,
11
+ getActionRegistryKey,
12
+ serializeSections,
13
+ validateValue,
14
+ } from '../shared/serialization';
15
+
16
+ export const useRozeniteControlsPlugin = ({
17
+ sections,
18
+ }: RozeniteControlsPluginOptions) => {
19
+ const client = useRozeniteDevToolsClient<ControlsEventMap>({
20
+ pluginId: '@rozenite/controls-plugin',
21
+ });
22
+
23
+ const snapshot = useMemo(() => serializeSections(sections), [sections]);
24
+ const actionRegistry = useMemo(() => buildActionRegistry(sections), [sections]);
25
+ const actionRegistryRef = useRef(actionRegistry);
26
+
27
+ useEffect(() => {
28
+ actionRegistryRef.current = actionRegistry;
29
+ }, [actionRegistry]);
30
+
31
+ useEffect(() => {
32
+ if (!client) {
33
+ return;
34
+ }
35
+
36
+ client.send('snapshot', {
37
+ type: 'snapshot',
38
+ sections: snapshot,
39
+ });
40
+ }, [client, snapshot]);
41
+
42
+ useEffect(() => {
43
+ if (!client) {
44
+ return;
45
+ }
46
+
47
+ const handleUpdateRequest = async ({
48
+ requestId,
49
+ sectionId,
50
+ itemId,
51
+ value,
52
+ }: ControlsUpdateRequestEvent) => {
53
+ const key = getActionRegistryKey(sectionId, itemId);
54
+ const entry = actionRegistryRef.current.get(key);
55
+
56
+ if (!entry || entry.type === 'button') {
57
+ client.send('update-result', {
58
+ type: 'update-result',
59
+ requestId,
60
+ sectionId,
61
+ itemId,
62
+ status: 'error',
63
+ message: 'Update target not found.',
64
+ });
65
+ return;
66
+ }
67
+
68
+ try {
69
+ if (entry.type === 'toggle') {
70
+ if (typeof value !== 'boolean') {
71
+ client.send('update-result', {
72
+ type: 'update-result',
73
+ requestId,
74
+ sectionId,
75
+ itemId,
76
+ status: 'error',
77
+ message: 'Invalid toggle value.',
78
+ });
79
+ return;
80
+ }
81
+
82
+ const result = validateValue(entry.validate, value);
83
+ if (!result.valid) {
84
+ client.send('update-result', {
85
+ type: 'update-result',
86
+ requestId,
87
+ sectionId,
88
+ itemId,
89
+ status: 'error',
90
+ message: result.message,
91
+ });
92
+ return;
93
+ }
94
+
95
+ await entry.onUpdate(value);
96
+ client.send('update-result', {
97
+ type: 'update-result',
98
+ requestId,
99
+ sectionId,
100
+ itemId,
101
+ status: 'ok',
102
+ });
103
+ return;
104
+ }
105
+
106
+ if (typeof value !== 'string') {
107
+ client.send('update-result', {
108
+ type: 'update-result',
109
+ requestId,
110
+ sectionId,
111
+ itemId,
112
+ status: 'error',
113
+ message: `Invalid ${entry.type} value.`,
114
+ });
115
+ return;
116
+ }
117
+
118
+ const result = validateValue(entry.validate, value);
119
+ if (!result.valid) {
120
+ client.send('update-result', {
121
+ type: 'update-result',
122
+ requestId,
123
+ sectionId,
124
+ itemId,
125
+ status: 'error',
126
+ message: result.message,
127
+ });
128
+ return;
129
+ }
130
+
131
+ await entry.onUpdate(value);
132
+ client.send('update-result', {
133
+ type: 'update-result',
134
+ requestId,
135
+ sectionId,
136
+ itemId,
137
+ status: 'ok',
138
+ });
139
+ } catch (error) {
140
+ console.warn(
141
+ `[Rozenite] Controls Plugin: Update failed for ${sectionId}/${itemId}.`,
142
+ error
143
+ );
144
+ client.send('update-result', {
145
+ type: 'update-result',
146
+ requestId,
147
+ sectionId,
148
+ itemId,
149
+ status: 'error',
150
+ message: 'Update failed on the device.',
151
+ });
152
+ }
153
+ };
154
+
155
+ const handleInvokeAction = async ({
156
+ sectionId,
157
+ itemId,
158
+ action,
159
+ }: ControlsInvokeActionEvent) => {
160
+ if (action !== 'press') {
161
+ console.warn(
162
+ `[Rozenite] Controls Plugin: Unsupported action "${action}" for ${sectionId}/${itemId}.`
163
+ );
164
+ return;
165
+ }
166
+
167
+ const key = getActionRegistryKey(sectionId, itemId);
168
+ const entry = actionRegistryRef.current.get(key);
169
+
170
+ if (!entry) {
171
+ console.warn(
172
+ `[Rozenite] Controls Plugin: Action target not found for ${sectionId}/${itemId}.`
173
+ );
174
+ return;
175
+ }
176
+
177
+ try {
178
+ if (entry.type !== 'button') {
179
+ console.warn(
180
+ `[Rozenite] Controls Plugin: Invalid press action payload for ${sectionId}/${itemId}.`
181
+ );
182
+ return;
183
+ }
184
+
185
+ await entry.onPress();
186
+ } catch (error) {
187
+ console.warn(
188
+ `[Rozenite] Controls Plugin: Action failed for ${sectionId}/${itemId}.`,
189
+ error
190
+ );
191
+ }
192
+ };
193
+
194
+ const subscriptions = [
195
+ client.onMessage('get-snapshot', () => {
196
+ client.send('snapshot', {
197
+ type: 'snapshot',
198
+ sections: snapshot,
199
+ });
200
+ }),
201
+ client.onMessage('update-request', (event: ControlsUpdateRequestEvent) => {
202
+ void handleUpdateRequest(event);
203
+ }),
204
+ client.onMessage('invoke-action', (event: ControlsInvokeActionEvent) => {
205
+ void handleInvokeAction(event);
206
+ }),
207
+ ];
208
+
209
+ return () => {
210
+ subscriptions.forEach((subscription) => subscription.remove());
211
+ };
212
+ }, [client, snapshot]);
213
+
214
+ return client;
215
+ };
@@ -0,0 +1,45 @@
1
+ import type { ControlsSectionSnapshot } from './types';
2
+
3
+ export type ControlsSnapshotEvent = {
4
+ type: 'snapshot';
5
+ sections: ControlsSectionSnapshot[];
6
+ };
7
+
8
+ export type ControlsGetSnapshotEvent = {
9
+ type: 'get-snapshot';
10
+ };
11
+
12
+ export type ControlsUpdateRequestEvent = {
13
+ type: 'update-request';
14
+ requestId: string;
15
+ sectionId: string;
16
+ itemId: string;
17
+ value: boolean | string;
18
+ };
19
+
20
+ export type ControlsUpdateResultEvent = {
21
+ type: 'update-result';
22
+ requestId: string;
23
+ sectionId: string;
24
+ itemId: string;
25
+ status: 'ok' | 'error';
26
+ message?: string;
27
+ };
28
+
29
+ export type ControlsInvokeActionEvent = {
30
+ type: 'invoke-action';
31
+ sectionId: string;
32
+ itemId: string;
33
+ action: 'press';
34
+ };
35
+
36
+ export type ControlsEvent =
37
+ | ControlsSnapshotEvent
38
+ | ControlsGetSnapshotEvent
39
+ | ControlsUpdateRequestEvent
40
+ | ControlsUpdateResultEvent
41
+ | ControlsInvokeActionEvent;
42
+
43
+ export type ControlsEventMap = {
44
+ [K in ControlsEvent['type']]: Extract<ControlsEvent, { type: K }>;
45
+ };
@@ -0,0 +1,153 @@
1
+ import type {
2
+ ControlsButtonItem,
3
+ ControlsInputItem,
4
+ ControlsItem,
5
+ ControlsItemSnapshot,
6
+ ControlsMutableItemBase,
7
+ ControlsSelectItem,
8
+ ControlsSection,
9
+ ControlsSectionSnapshot,
10
+ ControlsToggleItem,
11
+ ControlsValidationResult,
12
+ } from './types';
13
+
14
+ export type ActionRegistryEntry =
15
+ | {
16
+ type: 'toggle';
17
+ validate?: ControlsToggleItem['validate'];
18
+ onUpdate: ControlsToggleItem['onUpdate'];
19
+ }
20
+ | {
21
+ type: 'button';
22
+ onPress: ControlsButtonItem['onPress'];
23
+ }
24
+ | {
25
+ type: 'select';
26
+ validate?: ControlsSelectItem['validate'];
27
+ onUpdate: ControlsSelectItem['onUpdate'];
28
+ }
29
+ | {
30
+ type: 'input';
31
+ validate?: ControlsInputItem['validate'];
32
+ onUpdate: ControlsInputItem['onUpdate'];
33
+ };
34
+
35
+ const validateValue = <TValue>(
36
+ validate: ControlsMutableItemBase<TValue>['validate'],
37
+ value: TValue
38
+ ): ControlsValidationResult => {
39
+ if (!validate) {
40
+ return { valid: true };
41
+ }
42
+
43
+ return validate(value);
44
+ };
45
+
46
+ const toSnapshotItem = (item: ControlsItem): ControlsItemSnapshot => {
47
+ if (item.type === 'text') {
48
+ return item;
49
+ }
50
+
51
+ if (item.type === 'toggle') {
52
+ return {
53
+ id: item.id,
54
+ type: item.type,
55
+ title: item.title,
56
+ value: item.value,
57
+ description: item.description,
58
+ disabled: item.disabled,
59
+ };
60
+ }
61
+
62
+ if (item.type === 'button') {
63
+ return {
64
+ id: item.id,
65
+ type: item.type,
66
+ title: item.title,
67
+ actionLabel: item.actionLabel,
68
+ description: item.description,
69
+ disabled: item.disabled,
70
+ };
71
+ }
72
+
73
+ if (item.type === 'select') {
74
+ return {
75
+ id: item.id,
76
+ type: item.type,
77
+ title: item.title,
78
+ value: item.value,
79
+ options: item.options,
80
+ description: item.description,
81
+ disabled: item.disabled,
82
+ };
83
+ }
84
+
85
+ return {
86
+ id: item.id,
87
+ type: item.type,
88
+ title: item.title,
89
+ value: item.value,
90
+ placeholder: item.placeholder,
91
+ applyLabel: item.applyLabel,
92
+ description: item.description,
93
+ disabled: item.disabled,
94
+ };
95
+ };
96
+
97
+ export const serializeSections = (
98
+ sections: ControlsSection[]
99
+ ): ControlsSectionSnapshot[] =>
100
+ sections.map((section) => ({
101
+ id: section.id,
102
+ title: section.title,
103
+ description: section.description,
104
+ items: section.items.map(toSnapshotItem),
105
+ }));
106
+
107
+ export const buildActionRegistry = (sections: ControlsSection[]) => {
108
+ const registry = new Map<string, ActionRegistryEntry>();
109
+
110
+ sections.forEach((section) => {
111
+ section.items.forEach((item) => {
112
+ const key = `${section.id}:${item.id}`;
113
+
114
+ if (item.type === 'toggle') {
115
+ registry.set(key, {
116
+ type: 'toggle',
117
+ validate: item.validate,
118
+ onUpdate: item.onUpdate,
119
+ });
120
+ }
121
+
122
+ if (item.type === 'button') {
123
+ registry.set(key, {
124
+ type: 'button',
125
+ onPress: item.onPress,
126
+ });
127
+ }
128
+
129
+ if (item.type === 'select') {
130
+ registry.set(key, {
131
+ type: 'select',
132
+ validate: item.validate,
133
+ onUpdate: item.onUpdate,
134
+ });
135
+ }
136
+
137
+ if (item.type === 'input') {
138
+ registry.set(key, {
139
+ type: 'input',
140
+ validate: item.validate,
141
+ onUpdate: item.onUpdate,
142
+ });
143
+ }
144
+ });
145
+ });
146
+
147
+ return registry;
148
+ };
149
+
150
+ export const getActionRegistryKey = (sectionId: string, itemId: string) =>
151
+ `${sectionId}:${itemId}`;
152
+
153
+ export { validateValue };