Skip to content

Commit 19559e3

Browse files
committed
chore: update example
1 parent eecb73e commit 19559e3

File tree

3 files changed

+186
-60
lines changed

3 files changed

+186
-60
lines changed

example/src/components/maptConfigDialog/MapConfigDialog.tsx

Lines changed: 93 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
1-
import React from 'react';
1+
import React, { useEffect, useState } from 'react';
22
import {
3-
Alert,
43
Modal,
54
Pressable,
65
ScrollView,
76
StyleSheet,
87
Text,
98
TextInput,
109
View,
10+
Alert,
1111
} from 'react-native';
12-
import { useForm, Controller, type FieldValues } from 'react-hook-form';
13-
import { validate } from 'superstruct';
12+
import { type Struct, validate } from 'superstruct';
1413
import { useAppTheme } from '../../hooks/useAppTheme';
14+
import {
15+
formatSuperstructError,
16+
parseWithUndefined,
17+
stringifyWithUndefined,
18+
} from './utils';
1519

1620
type Props<T> = {
1721
visible: boolean;
1822
title: string;
1923
initialData: T;
2024
onClose: () => void;
2125
onSave: (updated: T) => void;
22-
validator?: any;
26+
validator?: Struct<T, any>;
2327
};
2428

25-
export default function MapConfigDialog<T extends FieldValues>({
29+
export default function MapConfigDialog<T>({
2630
visible,
2731
title,
2832
initialData,
@@ -32,74 +36,94 @@ export default function MapConfigDialog<T extends FieldValues>({
3236
}: Props<T>) {
3337
const theme = useAppTheme();
3438
const styles = getThemedStyles(theme);
35-
const { control, handleSubmit, reset } = useForm<T>();
3639

37-
const onSubmit = (data: T) => {
38-
if (validator) {
39-
const [err, value] = validate(data, validator);
40-
if (err) {
41-
Alert.alert(
42-
'Input error',
43-
`${err.path?.join('.') || '(root)'}: ${err.message}`
44-
);
45-
return;
40+
const [text, setText] = useState(() => stringifyWithUndefined(initialData));
41+
const [isValid, setIsValid] = useState(true);
42+
const [error, setError] = useState<string | null>(null);
43+
44+
useEffect(() => {
45+
setText(stringifyWithUndefined(initialData));
46+
setIsValid(true);
47+
setError(null);
48+
}, [initialData]);
49+
50+
const handleChange = (value: string) => {
51+
setText(value);
52+
try {
53+
const parsed = parseWithUndefined(value);
54+
55+
if (validator) {
56+
const [err] = validate(parsed, validator);
57+
if (err) {
58+
setIsValid(false);
59+
setError(formatSuperstructError(err, validator));
60+
return;
61+
}
4662
}
47-
onSave(value as T);
48-
} else {
49-
onSave(data);
63+
64+
setIsValid(true);
65+
setError(null);
66+
} catch (e: any) {
67+
setIsValid(false);
68+
setError(e.message);
5069
}
51-
onClose();
5270
};
5371

54-
React.useEffect(() => {
55-
reset(initialData);
56-
}, [initialData, reset]);
72+
const handleSave = () => {
73+
if (!isValid) {
74+
Alert.alert('Invalid JSON', error ?? 'Please fix JSON before saving.');
75+
return;
76+
}
77+
78+
try {
79+
const parsed = parseWithUndefined(text);
80+
81+
if (validator) {
82+
const [err, value] = validate(parsed, validator);
83+
if (err) {
84+
Alert.alert(
85+
'Validation Error',
86+
formatSuperstructError(err, validator)
87+
);
88+
return;
89+
}
90+
onSave(value as T);
91+
} else {
92+
onSave(parsed as T);
93+
}
94+
95+
onClose();
96+
} catch (e: any) {
97+
Alert.alert('Invalid JSON', e.message);
98+
}
99+
};
57100

58101
return (
59102
<Modal visible={visible} transparent animationType="fade">
60103
<View style={styles.overlay}>
61104
<View style={styles.dialog}>
62105
<Text style={styles.title}>{title}</Text>
63106
<ScrollView contentContainerStyle={styles.scroll}>
64-
{Object.entries(initialData).map(([key, _]) => (
65-
<View key={key} style={styles.field}>
66-
<Text style={styles.label}>{key}</Text>
67-
<Controller
68-
control={control}
69-
name={key as any}
70-
render={({ field: { onChange, value } }) => (
71-
<TextInput
72-
style={styles.input}
73-
spellCheck={false}
74-
autoCapitalize={'none'}
75-
autoCorrect={false}
76-
value={
77-
typeof value === 'object'
78-
? JSON.stringify(value, null, 2)
79-
: String(value ?? '')
80-
}
81-
onChangeText={(v) => {
82-
try {
83-
onChange(JSON.parse(v));
84-
} catch {
85-
onChange(v);
86-
}
87-
}}
88-
multiline={typeof value === 'object'}
89-
/>
90-
)}
91-
/>
92-
</View>
93-
))}
107+
<TextInput
108+
value={text}
109+
onChangeText={handleChange}
110+
multiline
111+
style={[styles.input, styles.multiline, !isValid && styles.error]}
112+
autoCorrect={false}
113+
autoCapitalize="none"
114+
spellCheck={false}
115+
/>
116+
{!isValid && (
117+
<Text style={[styles.errorText]}>
118+
{error ?? 'Invalid JSON or schema mismatch'}
119+
</Text>
120+
)}
94121
</ScrollView>
95122
<View style={styles.actions}>
96123
<Pressable onPress={onClose} style={styles.cancelButton}>
97124
<Text style={styles.buttonText}>Cancel</Text>
98125
</Pressable>
99-
<Pressable
100-
onPress={handleSubmit(onSubmit)}
101-
style={styles.saveButton}
102-
>
126+
<Pressable onPress={handleSave} style={styles.saveButton}>
103127
<Text style={styles.buttonText}>Save</Text>
104128
</Pressable>
105129
</View>
@@ -122,16 +146,15 @@ const getThemedStyles = (theme: any) =>
122146
maxHeight: '85%',
123147
backgroundColor: theme.bgPrimary,
124148
borderRadius: 12,
149+
flexShrink: 1,
125150
},
151+
scroll: { padding: 12 },
126152
title: {
127153
padding: 12,
128154
fontSize: 18,
129155
fontWeight: '600',
130156
color: theme.textPrimary,
131157
},
132-
scroll: { padding: 12 },
133-
field: { marginBottom: 12 },
134-
label: { fontSize: 14, marginBottom: 4, color: theme.label },
135158
input: {
136159
borderWidth: 1,
137160
borderRadius: 8,
@@ -142,6 +165,16 @@ const getThemedStyles = (theme: any) =>
142165
backgroundColor: theme.inputBg,
143166
fontFamily: 'monospace',
144167
},
168+
multiline: { minHeight: 250, textAlignVertical: 'top' },
169+
errorText: {
170+
marginTop: 6,
171+
color: theme.errorBorder,
172+
fontSize: 12,
173+
fontFamily: 'monospace',
174+
},
175+
error: {
176+
borderColor: theme.errorBorder,
177+
},
145178
actions: {
146179
flexDirection: 'row',
147180
justifyContent: 'flex-end',
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Struct, StructError } from 'superstruct';
2+
3+
export function getSchemaNodeAtPath<T>(
4+
validator: Struct<T, any>,
5+
path: Array<string | number>
6+
): any | null {
7+
let node: any = (validator as any)?.schema;
8+
if (!node) return null;
9+
10+
for (const seg of path) {
11+
if (!node) return null;
12+
if (
13+
node.type === 'object' &&
14+
node.schema &&
15+
typeof node.schema === 'object'
16+
) {
17+
node = node.schema[seg as any];
18+
continue;
19+
}
20+
21+
if (node && typeof node === 'object' && seg in node) {
22+
node = node[seg as any];
23+
continue;
24+
}
25+
return null;
26+
}
27+
return node || null;
28+
}
29+
30+
export function extractAllowedValuesFromNode(node: any): string[] | null {
31+
if (!node || typeof node !== 'object') return null;
32+
33+
if (node.type === 'union' && Array.isArray(node._schema)) {
34+
const vals: string[] = node._schema
35+
.map((s: any) => String(s?.schema))
36+
.filter((v: any) => typeof v === 'string');
37+
return vals.length ? [...new Set(vals)] : null;
38+
}
39+
40+
if (node.type === 'enums' && node.schema && typeof node.schema === 'object') {
41+
const vals = Object.values(node.schema)
42+
.filter((v) => typeof v === 'string' || typeof v === 'number')
43+
.map(String);
44+
const strOnly = vals.filter((v) => isNaN(Number(v)));
45+
return (strOnly.length ? strOnly : vals).length
46+
? [...new Set(strOnly.length ? strOnly : vals)]
47+
: null;
48+
}
49+
50+
if (node.type === 'literal') {
51+
const lit = node.schema;
52+
if (typeof lit === 'string' || typeof lit === 'number')
53+
return [String(lit)];
54+
}
55+
56+
return null;
57+
}
58+
59+
export function formatSuperstructError<T>(
60+
err: StructError,
61+
validator: Struct<T, any>
62+
): string {
63+
const path = err.path ?? [];
64+
const pathStr = path.length ? path.join('.') : '(root)';
65+
66+
const node = getSchemaNodeAtPath(validator, path);
67+
68+
const allowed = extractAllowedValuesFromNode(node);
69+
if (allowed && allowed.length) {
70+
return `${pathStr}: must be one of ${allowed.map((v) => `"${v}"`).join(', ')}`;
71+
}
72+
73+
if (node?.type && ['number', 'boolean', 'string'].includes(node.type)) {
74+
return `${pathStr}: expected ${node.type}`;
75+
}
76+
77+
return `${pathStr}: ${err.message}`;
78+
}
79+
80+
export function stringifyWithUndefined(obj: any) {
81+
return JSON.stringify(
82+
obj,
83+
(_, v) => (v === undefined ? '__undefined__' : v),
84+
2
85+
).replace(/"__undefined__"/g, 'undefined');
86+
}
87+
88+
export function parseWithUndefined(json: string) {
89+
const fixed = json.replace(/\bundefined\b/g, '"__undefined__"');
90+
return JSON.parse(fixed, (_, v) => (v === '__undefined__' ? undefined : v));
91+
}

example/src/theme.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const lightTheme = {
1212
inputBg: '#FFFFFF',
1313
buttonBg: '#3B82F6',
1414
cancelBg: '#9CA3AF',
15+
errorBorder: '#ff0000',
1516
};
1617

1718
export const darkTheme = {
@@ -28,6 +29,7 @@ export const darkTheme = {
2829
inputBg: '#2C2C2E',
2930
buttonBg: '#2D6BE9',
3031
cancelBg: '#4B5563',
32+
errorBorder: '#ff0000',
3133
};
3234

3335
export type AppTheme = typeof lightTheme;

0 commit comments

Comments
 (0)