From d162438388751486d59b04dd87acca82b6cd834a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sat, 6 Jun 2026 15:30:03 -0300 Subject: [PATCH] fix(mobile): enable habit submit button once a title is typed The Create Habit / Save Changes buttons gated on `formState.isValid`, but the title is a buffered input that only commits to react-hook-form on blur/submit. In the default onSubmit mode, `setValue` without `shouldValidate` re-broadcasts the cached `isValid` instead of recomputing it, so the button stayed disabled even with a title filled. Web is unaffected: its inputs write to the form live, so the registered field's onChange recomputes `isValid` and the same gate works. Observe the live title draft through BufferedSheetInput's existing onDraftChange hook and gate the button on a lightweight title-present boolean. This preserves the buffer that keeps typing lag-free on Android. Also seeds the boolean from the prefilled title in the edit modal, fixing the latent case where clearing an existing title left Save enabled. Co-Authored-By: Claude Opus 4.8 --- .../habits/create-habit-modal.test.tsx | 26 ++++++++++++- .../habits/habit-form-fields.test.tsx | 39 +++++++++++++++++++ .../components/habits/create-habit-modal.tsx | 5 ++- .../components/habits/edit-habit-modal.tsx | 5 ++- .../components/habits/habit-form-fields.tsx | 3 ++ .../habit-form-fields/title-section.tsx | 3 ++ 6 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx b/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx index 32c7a36c7..f0aa0d1f0 100644 --- a/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx +++ b/apps/mobile/__tests__/components/habits/create-habit-modal.test.tsx @@ -109,8 +109,8 @@ vi.mock('@orbit/shared/utils', async (importOriginal) => { }) vi.mock('@/components/habits/habit-form-fields', () => ({ - HabitFormFields: ({ children }: { children?: React.ReactNode }) => - React.createElement('HabitFormFields', null, children), + HabitFormFields: (props: { children?: React.ReactNode }) => + React.createElement('HabitFormFields', props, props.children), })) vi.mock('@/components/ui/pro-badge', () => ({ @@ -199,4 +199,26 @@ describe('CreateHabitModal (mobile)', () => { tree.root.findAll((node: any) => node.props?.testID === 'pro-badge'), ).toHaveLength(1) }) + + it('disables the submit button until the title has content', () => { + const tree = renderModal() + + const findSubmit = () => + tree.root.findAll( + (node: any) => + node.type === 'TouchableOpacity' && + node.props.accessibilityLabel === 'habits.createHabit', + )[0] + + expect(findSubmit().props.disabled).toBe(true) + + const fields = tree.root.findAll( + (node: any) => node.type === 'HabitFormFields', + )[0] + TestRenderer.act(() => { + fields.props.onTitlePresenceChange(true) + }) + + expect(findSubmit().props.disabled).toBe(false) + }) }) diff --git a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx index 784a3698d..0051d9e65 100644 --- a/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx +++ b/apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx @@ -409,4 +409,43 @@ describe('HabitFormFields (mobile)', () => { expect(hasText('habits.form.reminder')).toBe(true) expect(hasText('habits.form.tags')).toBe(true) }) + + it('reports title presence to onTitlePresenceChange as the title is typed and cleared', async () => { + const formHelpers = createMockFormHelpers({ title: '' }) + const tags = createMockTags() + const onTitlePresenceChange = vi.fn() + let tree: any + + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + }) + + const titleInput = tree.root.findAll( + (node: any) => + node.type === 'TextInput' && + node.props.accessibilityLabel === 'habits.form.title', + )[0] + expect(titleInput).toBeTruthy() + + await TestRenderer.act(async () => { + titleInput.props.onChangeText('Read a book') + }) + expect(onTitlePresenceChange).toHaveBeenLastCalledWith(true) + + await TestRenderer.act(async () => { + titleInput.props.onChangeText(' ') + }) + expect(onTitlePresenceChange).toHaveBeenLastCalledWith(false) + }) }) diff --git a/apps/mobile/components/habits/create-habit-modal.tsx b/apps/mobile/components/habits/create-habit-modal.tsx index 68596e418..6d442c2b7 100644 --- a/apps/mobile/components/habits/create-habit-modal.tsx +++ b/apps/mobile/components/habits/create-habit-modal.tsx @@ -90,6 +90,7 @@ export function CreateHabitModal({ const [selectedGoalIds, setSelectedGoalIds] = useState([]) const [subHabits, setSubHabits] = useState([]) const [reminderTimes, setReminderTimes] = useState([0, 15]) + const [titleFilled, setTitleFilled] = useState(false) const [reminderWasManuallyToggled, setReminderWasManuallyToggled] = useState(false) const flushBufferedInputsRef = useRef<() => void>(() => {}) const [initialTagIdsSnapshot, setInitialTagIdsSnapshot] = useState('[]') @@ -145,6 +146,7 @@ export function CreateHabitModal({ const fallbackDate = initialDate ?? formatAPIDate(new Date()) setReminderWasManuallyToggled(false) + setTitleFilled(false) formHelpers.form.reset(buildEmptyHabitFormValues(fallbackDate)) tags.resetTags() setSelectedGoalIds([]) @@ -312,7 +314,7 @@ export function CreateHabitModal({ ]) const isPending = createHabit.isPending || createSubHabit.isPending - const submitDisabled = isPending || !formHelpers.form.formState.isValid + const submitDisabled = isPending || !titleFilled const updateSubHabitValue = useCallback((id: string, value: string) => { setSubHabits((prev) => @@ -357,6 +359,7 @@ export function CreateHabitModal({ onReminderTimesChange={setReminderTimes} onReminderEnabledChange={handleReminderEnabledChange} onFlushBufferedInputsReady={handleBufferedInputsReady} + onTitlePresenceChange={setTitleFilled} > {!isSubHabitMode ? ( = 10 const isDirty = @@ -123,6 +124,7 @@ export function EditHabitModal({ if (open && habit) { const prefill = buildEditHabitFormState(habit, habitDetail) formHelpers.form.reset(prefill.formValues) + setTitleFilled(prefill.formValues.title.trim().length > 0) setOriginalEndDate(prefill.originalEndDate) setReminderTimes(prefill.reminderTimes) tags.resetTags(prefill.selectedTagIds) @@ -201,7 +203,7 @@ export function EditHabitModal({ translate, ]) - const submitDisabled = updateHabit.isPending || !formHelpers.form.formState.isValid + const submitDisabled = updateHabit.isPending || !titleFilled return ( <> @@ -229,6 +231,7 @@ export function EditHabitModal({ reminderTimes={reminderTimes} onReminderTimesChange={setReminderTimes} onFlushBufferedInputsReady={handleBufferedInputsReady} + onTitlePresenceChange={setTitleFilled} defaultExpanded={true} /> diff --git a/apps/mobile/components/habits/habit-form-fields.tsx b/apps/mobile/components/habits/habit-form-fields.tsx index 678fafa72..6b4c2d0e9 100644 --- a/apps/mobile/components/habits/habit-form-fields.tsx +++ b/apps/mobile/components/habits/habit-form-fields.tsx @@ -38,6 +38,7 @@ interface HabitFormFieldsProps { onReminderTimesChange: (times: number[]) => void; onReminderEnabledChange?: (nextEnabled: boolean) => void; onFlushBufferedInputsReady?: (flush: () => void) => void; + onTitlePresenceChange?: (hasTitle: boolean) => void; /** When true, advanced fields are visible by default (used in edit modal) */ defaultExpanded?: boolean; children?: ReactNode; @@ -53,6 +54,7 @@ export function HabitFormFields({ onReminderTimesChange, onReminderEnabledChange, onFlushBufferedInputsReady, + onTitlePresenceChange, defaultExpanded = false, children, }: Readonly) { @@ -135,6 +137,7 @@ export function HabitFormFields({ error={errors.title?.message} registerFlush={registerBufferedInputFlusher} onCommit={(val) => setValue("title", val, { shouldDirty: true })} + onDraftChange={(val) => onTitlePresenceChange?.(val.trim().length > 0)} styles={styles} tokens={tokens} /> diff --git a/apps/mobile/components/habits/habit-form-fields/title-section.tsx b/apps/mobile/components/habits/habit-form-fields/title-section.tsx index 59f2ec225..39c27d890 100644 --- a/apps/mobile/components/habits/habit-form-fields/title-section.tsx +++ b/apps/mobile/components/habits/habit-form-fields/title-section.tsx @@ -10,6 +10,7 @@ interface TitleSectionProps { error: string | undefined; registerFlush: (flush: () => void) => () => void; onCommit: (value: string) => void; + onDraftChange?: (value: string) => void; styles: HabitFormStyles; tokens: AppTokens; } @@ -19,6 +20,7 @@ export function TitleSection({ error, registerFlush, onCommit, + onDraftChange, styles, tokens, }: Readonly) { @@ -36,6 +38,7 @@ export function TitleSection({ placeholderTextColor={tokens.fg3} style={styles.input} onCommit={onCommit} + onDraftChange={onDraftChange} accessibilityLabel={t("habits.form.title")} /> {error && (