Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
const TestRenderer = require('react-test-renderer')

const useWatchMock = vi.fn()
const suggestMutateAsyncMock = vi.fn()
let mockHasProAccess = false

vi.mock('react-hook-form', () => ({
Expand Down Expand Up @@ -39,6 +40,7 @@
useCreateTag: () => ({ isPending: false, mutateAsync: vi.fn() }),
useUpdateTag: () => ({ isPending: false, mutateAsync: vi.fn() }),
useDeleteTag: () => ({ isPending: false, mutateAsync: vi.fn() }),
useSuggestTags: () => ({ isPending: false, mutateAsync: suggestMutateAsyncMock }),
}))

vi.mock('@/components/habits/habit-checklist', () => ({
Expand Down Expand Up @@ -153,6 +155,7 @@
setNewTagColor: vi.fn(),
tagColors: ['#7f46f7', '#dc2626', '#047857'] as readonly string[],
createAndSelectTag: vi.fn(),
acceptSuggestedTag: vi.fn(),
editingTagId: null,
editTagName: '',
setEditTagName: vi.fn(),
Expand Down Expand Up @@ -572,4 +575,86 @@
shouldDirty: true,
})
})

it('suggests tags and accepts an existing suggestion as the real tag', async () => {
suggestMutateAsyncMock.mockResolvedValue({
tags: [
{ name: 'Health', color: '#10b981', isExisting: true, id: 'tag-1' },
{ name: 'Reading', color: '#7c3aed', isExisting: false, id: null },
],
})
const acceptSuggestedTag = vi.fn()
const formHelpers = createMockFormHelpers({ title: 'Morning run' })
const tags = createMockTags({ acceptSuggestedTag })
let tree: any

await TestRenderer.act(async () => {
tree = TestRenderer.create(
<HabitFormFields
formHelpers={formHelpers}
tags={tags}
selectedGoalIds={[]}
atGoalLimit={false}
onToggleGoal={vi.fn()}
reminderTimes={[]}
onReminderTimesChange={vi.fn()}
/>,
)
})

const suggestButton = tree.root.findByProps({
accessibilityLabel: 'habits.form.suggestTags',
})

await TestRenderer.act(async () => {
await suggestButton.props.onPress()
})

const healthChip = tree.root.findByProps({ accessibilityLabel: 'Health' })

await TestRenderer.act(async () => {
healthChip.props.onPress()
})

expect(acceptSuggestedTag).toHaveBeenCalledWith(
expect.objectContaining({ name: 'Health', isExisting: true, id: 'tag-1' }),
expect.any(Function),
)
})

it('shows the empty state when no tag suggestions are returned', async () => {
suggestMutateAsyncMock.mockResolvedValue({ tags: [] })
const formHelpers = createMockFormHelpers({ title: 'Morning run' })
const tags = createMockTags()
let tree: any

await TestRenderer.act(async () => {
tree = TestRenderer.create(
<HabitFormFields
formHelpers={formHelpers}
tags={tags}
selectedGoalIds={[]}
atGoalLimit={false}
onToggleGoal={vi.fn()}
reminderTimes={[]}
onReminderTimesChange={vi.fn()}
/>,
)
})

const suggestButton = tree.root.findByProps({
accessibilityLabel: 'habits.form.suggestTags',
})

await TestRenderer.act(async () => {
await suggestButton.props.onPress()
})

const emptyState = tree.root.findAll(
(node: any) =>
node.type === 'Text' &&
node.props.children === 'habits.form.noTagSuggestions',
)
expect(emptyState.length).toBe(1)

Check warning on line 658 in apps/mobile/__tests__/components/habits/habit-form-fields.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer a more specific assertion instead of this generic one, e.g. "expect(emptyState).toHaveLength(1)".

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-ui-mobile&issues=AZ8CTd46uGICQHVDinOZ&open=AZ8CTd46uGICQHVDinOZ&pullRequest=320
})
})
34 changes: 32 additions & 2 deletions apps/mobile/__tests__/hooks/use-tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockGoal } from '@orbit/shared/__tests__/factories'
import { API } from '@orbit/shared/api'
import { habitKeys, tagKeys } from '@orbit/shared/query'
import type { HabitScheduleItem } from '@orbit/shared/types/habit'
import type { HabitScheduleItem, SuggestTagsResponse } from '@orbit/shared/types/habit'

import { useAssignTags, useDeleteTag } from '@/hooks/use-tags'
import { useAssignTags, useDeleteTag, useSuggestTags } from '@/hooks/use-tags'

const mocks = vi.hoisted(() => {
const state = {
Expand Down Expand Up @@ -228,6 +229,35 @@ describe('mobile tag hooks', () => {
expect(mocks.queryClient.invalidateQueries).not.toHaveBeenCalled()
})

it('requests AI tag suggestions through the online api client', async () => {
const { apiClient } = await import('@/lib/api-client')
const response: SuggestTagsResponse = {
tags: [
{ name: 'Health', color: '#10b981', isExisting: true, id: 'tag-1' },
{ name: 'Reading', color: '#7c3aed', isExisting: false, id: null },
],
}
vi.mocked(apiClient).mockResolvedValue(response)

const mutation = useSuggestTags() as unknown as MutationConfig<
SuggestTagsResponse,
{ title: string; description: string | null; language: string },
unknown
>

const result = await mutation.mutationFn({
title: 'Morning run',
description: null,
language: 'en',
})

expect(apiClient).toHaveBeenCalledWith(API.tags.suggest, {
method: 'POST',
body: JSON.stringify({ title: 'Morning run', description: null, language: 'en' }),
})
expect(result).toEqual(response)
})

it('restores both tag and habit caches when deleting a tag fails', async () => {
const mutation = useDeleteTag() as unknown as MutationConfig<
{ queued: true; queuedMutationId: string },
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/components/habits/habit-form-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export function HabitFormFields({

const watchedEmoji = useWatch({ control: form.control, name: "emoji" }) ?? "";
const watchedTitle = useWatch({ control: form.control, name: "title" }) ?? "";
const watchedDescription =
useWatch({ control: form.control, name: "description" }) ?? "";

const handleReminderEnabledChange = useCallback(
(nextEnabled: boolean) => {
Expand Down Expand Up @@ -230,7 +232,13 @@ export function HabitFormFields({
/>
)}

<TagsSection tags={tags} styles={styles} tokens={tokens} />
<TagsSection
tags={tags}
title={watchedTitle}
description={watchedDescription}
styles={styles}
tokens={tokens}
/>

<MoreOptionsToggle
control={form.control}
Expand Down
134 changes: 134 additions & 0 deletions apps/mobile/components/habits/habit-form-fields/suggested-tags-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useCallback, useState } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { Sparkles } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { SuggestedTag } from "@orbit/shared/types/habit";
import { getFriendlyErrorMessage } from "@orbit/shared/utils";
import { useAppToast } from "@/hooks/use-app-toast";
import { useSuggestTags } from "@/hooks/use-tags";
import { type AppTokens } from "./styles";
import type { HabitFormStyles } from "./types";

interface SuggestedTagsRowProps {
title: string;
description: string;
atTagLimit: boolean;
onAccept: (suggestion: SuggestedTag) => void;
styles: HabitFormStyles;
tokens: AppTokens;
}

export function SuggestedTagsRow({
title,
description,
atTagLimit,
onAccept,
styles,
tokens,
}: Readonly<SuggestedTagsRowProps>) {
const { t, i18n } = useTranslation();
const translate = useCallback(
(key: string, values?: Record<string, unknown>) => t(key, values),
[t],
);
const { showError } = useAppToast();
const suggestMutation = useSuggestTags();
const [suggestions, setSuggestions] = useState<SuggestedTag[]>([]);
const [noResults, setNoResults] = useState(false);

const trimmedTitle = title.trim();
const canSuggest =
trimmedTitle.length > 0 && !atTagLimit && !suggestMutation.isPending;

async function handleSuggest() {
if (!canSuggest) return;
setNoResults(false);
try {
const response = await suggestMutation.mutateAsync({
title: trimmedTitle,
description: description.trim() ? description.trim() : null,
language: i18n.language,
});
setSuggestions(response.tags);
setNoResults(response.tags.length === 0);
} catch (error: unknown) {
showError(
getFriendlyErrorMessage(
error,
translate,
"habits.form.suggestTagsError",
"generic",
),
);
}
}

function handleAccept(suggestion: SuggestedTag) {
onAccept(suggestion);
setSuggestions((previous) =>
previous.filter((candidate) => candidate.name !== suggestion.name),
);
}

return (
<View style={styles.fieldGroup}>
<View style={styles.tagsRow}>
<TouchableOpacity
style={[styles.newTagButton, !canSuggest && { opacity: 0.5 }]}
disabled={!canSuggest}
accessibilityRole="button"
accessibilityLabel={t("habits.form.suggestTags")}
accessibilityState={{
disabled: !canSuggest,
busy: suggestMutation.isPending,
}}
onPress={handleSuggest}
activeOpacity={0.7}
>
<Sparkles size={14} color={tokens.fg2} strokeWidth={2} />
<Text style={styles.newTagButtonText}>
{suggestMutation.isPending
? t("habits.form.suggestingTags")
: t("habits.form.suggestTags")}
</Text>
</TouchableOpacity>
</View>

{suggestions.length > 0 && (
<>
<Text style={styles.hintText}>
{t("habits.form.suggestedTagsLabel")}
</Text>
<View style={styles.tagsRow}>
{suggestions.map((suggestion) => (
<TouchableOpacity
key={`${suggestion.name}-${suggestion.id ?? "new"}`}
style={[
styles.tagChip,
styles.tagChipInactive,
atTagLimit && { opacity: 0.3 },
]}
disabled={atTagLimit}
accessibilityRole="button"
accessibilityLabel={suggestion.name}
onPress={() => handleAccept(suggestion)}
activeOpacity={0.7}
>
<View style={styles.tagChipMain}>
<View
style={[styles.tagDot, { backgroundColor: suggestion.color }]}
/>
<Text style={styles.tagChipText}>{suggestion.name}</Text>
</View>
</TouchableOpacity>
))}
</View>
</>
)}

{noResults && !suggestMutation.isPending && (
<Text style={styles.hintText}>{t("habits.form.noTagSuggestions")}</Text>
)}
</View>
);
}
34 changes: 34 additions & 0 deletions apps/mobile/components/habits/habit-form-fields/tags-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { Plus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { SuggestedTag } from "@orbit/shared/types/habit";
import { getFriendlyErrorMessage } from "@orbit/shared/utils";
import { validateTagForm } from "@orbit/shared/validation";
import type { TagSelectionState } from "@/hooks/use-tag-selection";
Expand All @@ -16,16 +17,21 @@ import { type AppTokens } from "./styles";
import { TagColorPicker } from "./tag-color-picker";
import { TagEditorRow } from "./tag-editor-row";
import { HabitTagChip } from "./habit-tag-chip";
import { SuggestedTagsRow } from "./suggested-tags-row";
import type { HabitFormStyles } from "./types";

interface TagsSectionProps {
tags: TagSelectionState;
title: string;
description: string;
styles: HabitFormStyles;
tokens: AppTokens;
}

export function TagsSection({
tags,
title,
description,
styles,
tokens,
}: Readonly<TagsSectionProps>) {
Expand All @@ -42,6 +48,25 @@ export function TagsSection({
const isTagMutationPending =
createTag.isPending || updateTag.isPending || deleteTag.isPending;

function handleAcceptSuggestion(suggestion: SuggestedTag) {
void tags.acceptSuggestedTag(suggestion, async (name, color) => {
try {
const result = await createTag.mutateAsync({ name, color });
return result.id;
} catch (error: unknown) {
showError(
getFriendlyErrorMessage(
error,
translate,
"toast.errors.validation",
"tag",
),
);
throw error;
}
});
}

return (
<View style={styles.fieldGroup}>
<Text style={styles.label}>{t("habits.form.tags")}</Text>
Expand Down Expand Up @@ -98,6 +123,15 @@ export function TagsSection({
)}
</View>

<SuggestedTagsRow
title={title}
description={description}
atTagLimit={tags.atTagLimit}
onAccept={handleAcceptSuggestion}
styles={styles}
tokens={tokens}
/>

{tags.editingTagId && (
<View style={styles.tagEditSection}>
<TagColorPicker
Expand Down
Loading