Skip to content

Commit 0ac15fd

Browse files
authored
Merge pull request #6 from devitools/feat/field-types-fillers-sub-schema
feat(renderers): add currency/file renderers, filler generators, and list sub-schema modal
2 parents aefba5d + 26a0c65 commit 0ac15fd

20 files changed

Lines changed: 1433 additions & 19 deletions

File tree

packages/core/src/fields/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class ListFieldDefinition extends FieldDefinition<Record<string, unknown>
77
}
88

99
itemSchema(schema: SchemaDefinition<any>): this {
10-
this._config.attrs = { ...this._config.attrs, itemSchema: schema }
10+
this._config.attrs = { ...this._config.attrs, itemSchema: schema.provide() }
1111
return this
1212
}
1313

packages/core/src/filler.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,56 @@ describe('defaultFillers', () => {
150150
const value = defaultFillers.checkbox(makeFieldConfig({ component: 'checkbox' }))
151151
expect(value).toBeTypeOf('boolean')
152152
})
153+
154+
it('generates password for text with password kind', () => {
155+
const value = defaultFillers.text(makeFieldConfig({ component: 'text', kind: 'password' }))
156+
expect(value).toBeTypeOf('string')
157+
expect((value as string).length).toBeGreaterThanOrEqual(8)
158+
})
159+
160+
it('generates paragraph for textarea', () => {
161+
const value = defaultFillers.textarea(makeFieldConfig({ component: 'textarea' }))
162+
expect(value).toBeTypeOf('string')
163+
expect((value as string).length).toBeGreaterThan(10)
164+
})
165+
166+
it('generates time string for time', () => {
167+
const value = defaultFillers.time(makeFieldConfig({ component: 'time' }))
168+
expect(value).toBeTypeOf('string')
169+
expect(value as string).toMatch(/^\d{2}:\d{2}$/)
170+
})
171+
172+
it('generates value from options for select', () => {
173+
const value = defaultFillers.select(makeFieldConfig({
174+
component: 'select',
175+
attrs: { options: ['a', 'b', 'c'] },
176+
}))
177+
expect(['a', 'b', 'c']).toContain(value)
178+
})
179+
180+
it('returns undefined for select without options', () => {
181+
const value = defaultFillers.select(makeFieldConfig({ component: 'select' }))
182+
expect(value).toBeUndefined()
183+
})
184+
185+
it('generates subset of options for multiselect', () => {
186+
const value = defaultFillers.multiselect(makeFieldConfig({
187+
component: 'multiselect',
188+
attrs: { options: ['a', 'b', 'c', 'd'] },
189+
}))
190+
expect(Array.isArray(value)).toBe(true)
191+
const arr = value as string[]
192+
expect(arr.length).toBeGreaterThanOrEqual(1)
193+
expect(arr.length).toBeLessThanOrEqual(3)
194+
for (const item of arr) {
195+
expect(['a', 'b', 'c', 'd']).toContain(item)
196+
}
197+
})
198+
199+
it('returns empty array for multiselect without options', () => {
200+
const value = defaultFillers.multiselect(makeFieldConfig({ component: 'multiselect' }))
201+
expect(value).toEqual([])
202+
})
153203
})
154204

155205
describe('fill', () => {

packages/core/src/filler.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type FillerRegistry = Record<string, FillerFn>
88
const textKindGenerators: Record<string, () => string> = {
99
email: () => faker.internet.email(),
1010
phone: () => faker.phone.number(),
11+
password: () => faker.internet.password({ length: 12 }),
1112
cpf: () => faker.string.numeric({ length: 11, allowLeadingZeros: true }).replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'),
1213
cnpj: () => faker.string.numeric({ length: 14, allowLeadingZeros: true }).replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5'),
1314
cep: () => faker.location.zipCode(),
@@ -44,6 +45,31 @@ export const defaultFillers: FillerRegistry = {
4445
checkbox() {
4546
return faker.datatype.boolean()
4647
},
48+
textarea() {
49+
return faker.lorem.paragraph()
50+
},
51+
time() {
52+
const hour = faker.number.int({ min: 0, max: 23 }).toString().padStart(2, '0')
53+
const minute = faker.number.int({ min: 0, max: 59 }).toString().padStart(2, '0')
54+
return `${hour}:${minute}`
55+
},
56+
select(config) {
57+
const options = (config.attrs.options ?? []) as (string | number)[]
58+
if (options.length === 0) return undefined
59+
return faker.helpers.arrayElement(options)
60+
},
61+
multiselect(config) {
62+
const options = (config.attrs.options ?? []) as (string | number)[]
63+
if (options.length === 0) return []
64+
const count = faker.number.int({ min: 1, max: Math.min(3, options.length) })
65+
return faker.helpers.arrayElements(options, count)
66+
},
67+
file() {
68+
return undefined
69+
},
70+
image() {
71+
return undefined
72+
},
4773
}
4874

4975
export function createFiller(registry: FillerRegistry) {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { View, Text, TextInput, StyleSheet } from "react-native";
2+
import { useTranslation } from "react-i18next";
3+
import type { FieldRendererProps } from "@ybyra/react";
4+
import { useTheme } from "../theme/context";
5+
import type { Theme } from "../theme/default";
6+
import { ds } from "../support/ds";
7+
8+
export function CurrencyField({ domain, name, value, config, proxy, errors, onChange, onBlur, onFocus }: FieldRendererProps) {
9+
const { t } = useTranslation();
10+
const theme = useTheme();
11+
const styles = createStyles(theme);
12+
if (proxy.hidden) return null;
13+
14+
const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name });
15+
const prefix = (config.attrs.prefix as string) ?? "";
16+
17+
return (
18+
<View style={styles.container} {...ds(`CurrencyField:${name}`)}>
19+
<Text style={styles.label}>{fieldLabel}</Text>
20+
<View style={styles.inputWrapper}>
21+
{prefix ? <Text style={styles.prefix}>{prefix}</Text> : null}
22+
<TextInput
23+
style={[styles.input, prefix ? styles.inputWithPrefix : null, proxy.disabled && styles.inputDisabled, errors.length > 0 && styles.inputError]}
24+
value={value !== undefined && value !== null ? String(value) : ""}
25+
onChangeText={(text) => {
26+
const num = Number(text);
27+
onChange(isNaN(num) ? text : num);
28+
}}
29+
onBlur={onBlur}
30+
onFocus={onFocus}
31+
editable={!proxy.disabled}
32+
keyboardType="decimal-pad"
33+
placeholderTextColor={theme.colors.mutedForeground}
34+
/>
35+
</View>
36+
<View style={styles.errorSlot}>
37+
{errors.map((error, i) => (
38+
<Text key={i} style={styles.error}>{error}</Text>
39+
))}
40+
</View>
41+
</View>
42+
);
43+
}
44+
45+
const createStyles = (theme: Theme) => StyleSheet.create({
46+
container: {
47+
paddingHorizontal: theme.spacing.xs,
48+
},
49+
label: {
50+
fontSize: theme.fontSize.sm,
51+
fontWeight: theme.fontWeight.semibold,
52+
marginBottom: theme.spacing.xs,
53+
color: theme.colors.foreground,
54+
},
55+
inputWrapper: {
56+
flexDirection: "row",
57+
alignItems: "center",
58+
},
59+
prefix: {
60+
position: "absolute",
61+
left: theme.spacing.md,
62+
fontSize: theme.fontSize.md,
63+
color: theme.colors.mutedForeground,
64+
zIndex: 1,
65+
},
66+
input: {
67+
flex: 1,
68+
borderWidth: 1,
69+
borderColor: theme.colors.input,
70+
borderRadius: theme.borderRadius.md,
71+
paddingHorizontal: theme.spacing.md,
72+
paddingVertical: 10,
73+
fontSize: theme.fontSize.md,
74+
backgroundColor: theme.colors.card,
75+
color: theme.colors.cardForeground,
76+
},
77+
inputWithPrefix: {
78+
paddingLeft: 36,
79+
},
80+
inputDisabled: {
81+
backgroundColor: theme.colors.muted,
82+
color: theme.colors.mutedForeground,
83+
},
84+
inputError: {
85+
borderColor: theme.colors.destructive,
86+
},
87+
errorSlot: {
88+
minHeight: 20,
89+
marginTop: 2,
90+
},
91+
error: {
92+
fontSize: theme.fontSize.xs,
93+
color: theme.colors.destructive,
94+
},
95+
});
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { View, Text, Pressable, StyleSheet } from "react-native";
2+
import { useTranslation } from "react-i18next";
3+
import type { FieldRendererProps } from "@ybyra/react";
4+
import { useTheme } from "../theme/context";
5+
import type { Theme } from "../theme/default";
6+
import { ds } from "../support/ds";
7+
8+
export function FileField({ domain, name, value, config, proxy, errors }: FieldRendererProps) {
9+
const { t } = useTranslation();
10+
const theme = useTheme();
11+
const styles = createStyles(theme);
12+
if (proxy.hidden) return null;
13+
14+
const fieldLabel = t(`${domain}.fields.${name}`, { defaultValue: name });
15+
const isImage = config.component === "image";
16+
const fileName = value && typeof value === "object" && "name" in value ? String(value.name) : (value ? String(value) : "");
17+
18+
return (
19+
<View style={styles.container} {...ds(`FileField:${name}`)}>
20+
<Text style={styles.label}>{fieldLabel}</Text>
21+
<View style={styles.inputWrapper}>
22+
{/* TODO: integrate expo-document-picker or react-native-document-picker for actual file selection */}
23+
<Pressable
24+
style={[styles.chooseBtn, proxy.disabled && styles.chooseBtnDisabled]}
25+
disabled={proxy.disabled}
26+
>
27+
<Text style={[styles.chooseBtnText, proxy.disabled && styles.chooseBtnTextDisabled]}>
28+
{t("common.file.choose", { defaultValue: isImage ? "Choose image…" : "Choose file…" })}
29+
</Text>
30+
</Pressable>
31+
<Text style={styles.fileName} numberOfLines={1}>
32+
{fileName || t("common.file.none", { defaultValue: "No file selected" })}
33+
</Text>
34+
</View>
35+
<View style={styles.errorSlot}>
36+
{errors.map((error, i) => (
37+
<Text key={i} style={styles.error}>{error}</Text>
38+
))}
39+
</View>
40+
</View>
41+
);
42+
}
43+
44+
const createStyles = (theme: Theme) => StyleSheet.create({
45+
container: {
46+
paddingHorizontal: theme.spacing.xs,
47+
},
48+
label: {
49+
fontSize: theme.fontSize.sm,
50+
fontWeight: theme.fontWeight.semibold,
51+
marginBottom: theme.spacing.xs,
52+
color: theme.colors.foreground,
53+
},
54+
inputWrapper: {
55+
flexDirection: "row",
56+
alignItems: "center",
57+
gap: theme.spacing.sm,
58+
},
59+
chooseBtn: {
60+
borderWidth: 1,
61+
borderColor: theme.colors.input,
62+
borderRadius: theme.borderRadius.md,
63+
paddingHorizontal: theme.spacing.md,
64+
paddingVertical: 8,
65+
backgroundColor: theme.colors.secondary,
66+
},
67+
chooseBtnDisabled: {
68+
backgroundColor: theme.colors.muted,
69+
},
70+
chooseBtnText: {
71+
fontSize: theme.fontSize.sm,
72+
color: theme.colors.secondaryForeground,
73+
},
74+
chooseBtnTextDisabled: {
75+
color: theme.colors.mutedForeground,
76+
},
77+
fileName: {
78+
flex: 1,
79+
fontSize: theme.fontSize.sm,
80+
color: theme.colors.mutedForeground,
81+
},
82+
errorSlot: {
83+
minHeight: 20,
84+
marginTop: 2,
85+
},
86+
error: {
87+
fontSize: theme.fontSize.xs,
88+
color: theme.colors.destructive,
89+
},
90+
});

0 commit comments

Comments
 (0)