Files
nhost/examples/tutorials/nhost-reactnative-tutorial/app/todos.tsx

573 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { router, Stack } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
export default function Todos() {
const { nhost, session } = useAuth();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const [addingTodo, setAddingTodo] = useState(false);
const [updatingTodos, setUpdatingTodos] = useState<Set<string>>(new Set());
// Redirect to sign in if not authenticated
useEffect(() => {
if (!session) {
router.replace("/signin");
}
}, [session]);
const fetchTodos = useCallback(async () => {
try {
setLoading(true);
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
setTodos(response.body?.data?.todos || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch todos");
} finally {
setLoading(false);
}
}, [nhost.graphql]);
const addTodo = async () => {
if (!newTodoTitle.trim()) return;
try {
setAddingTodo(true);
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to add todo",
);
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
setTodos([response.body?.data?.insert_todos_one, ...todos]);
setNewTodoTitle("");
setNewTodoDetails("");
setShowAddForm(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add todo");
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to add todo",
);
} finally {
setAddingTodo(false);
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
setUpdatingTodos((prev) => new Set([...prev, id]));
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
}
setEditingTodo(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update todo");
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to update todo",
);
} finally {
setUpdatingTodos((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
};
const deleteTodo = async (id: string) => {
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setUpdatingTodos((prev) => new Set([...prev, id]));
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
setTodos(todos.filter((todo) => todo.id !== id));
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to delete todo",
);
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to delete todo",
);
} finally {
setUpdatingTodos((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
},
},
]);
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo) return;
await updateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
setExpandedTodos(newExpanded);
};
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
useEffect(() => {
if (session) {
fetchTodos();
}
}, [session, fetchTodos]);
if (!session) {
return null; // Will redirect to sign in
}
const renderTodoItem = ({ item: todo }: { item: Todo }) => {
const isUpdating = updatingTodos.has(todo.id);
const isExpanded = expandedTodos.has(todo.id);
return (
<View
style={[
commonStyles.todoCard,
todo.completed && commonStyles.todoCompleted,
]}
>
{editingTodo?.id === todo.id ? (
<View style={commonStyles.todoEditForm}>
<Text style={commonStyles.inputLabel}>Title</Text>
<TextInput
style={commonStyles.input}
value={editingTodo.title}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
title: text,
})
}
placeholder="Enter todo title"
placeholderTextColor={colors.textPlaceholder}
/>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={editingTodo.details || ""}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
details: text,
})
}
placeholder="Enter details (optional)"
placeholderTextColor={colors.textPlaceholder}
multiline
numberOfLines={3}
/>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={saveEdit}
disabled={isUpdating}
>
<Text style={commonStyles.buttonText}>
{isUpdating ? "Saving..." : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => setEditingTodo(null)}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View>
<View style={commonStyles.todoHeader}>
<TouchableOpacity
style={commonStyles.todoTitleContainer}
onPress={() => toggleTodoExpansion(todo.id)}
>
<Text
style={[
commonStyles.todoTitle,
todo.completed && commonStyles.todoTitleCompleted,
]}
>
{todo.title}
</Text>
</TouchableOpacity>
<View style={commonStyles.todoActions}>
<TouchableOpacity
style={[
commonStyles.actionButton,
commonStyles.completeButton,
]}
onPress={() => toggleComplete(todo)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>
{isUpdating ? "⌛" : todo.completed ? "↶" : "✓"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.editButton]}
onPress={() => setEditingTodo(todo)}
>
<Text style={commonStyles.actionButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.deleteButton]}
onPress={() => deleteTodo(todo.id)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>🗑</Text>
</TouchableOpacity>
</View>
</View>
{isExpanded && (
<View style={commonStyles.todoDetails}>
{todo.details && (
<Text
style={[
commonStyles.todoDescription,
todo.completed && commonStyles.todoDescriptionCompleted,
]}
>
{todo.details}
</Text>
)}
<View style={commonStyles.todoMeta}>
<Text style={commonStyles.metaText}>
Created: {new Date(todo.created_at).toLocaleString()}
</Text>
<Text style={commonStyles.metaText}>
Updated: {new Date(todo.updated_at).toLocaleString()}
</Text>
{todo.completed && (
<View style={commonStyles.completionBadge}>
<Text style={commonStyles.completionText}>
Completed
</Text>
</View>
)}
</View>
</View>
)}
</View>
)}
</View>
);
};
const renderHeader = () => (
<>
<View style={commonStyles.pageHeader}>
<Text style={commonStyles.pageTitle}>My Todos</Text>
{!showAddForm && (
<TouchableOpacity
style={commonStyles.addButton}
onPress={() => setShowAddForm(true)}
>
<Text style={commonStyles.addButtonText}>+</Text>
</TouchableOpacity>
)}
</View>
{error && (
<View style={[commonStyles.errorContainer, { marginHorizontal: 16 }]}>
<Text style={commonStyles.errorText}>Error: {error}</Text>
</View>
)}
{showAddForm && (
<View
style={[
commonStyles.card,
{ marginHorizontal: 16, width: undefined },
]}
>
<Text style={commonStyles.cardTitle}>Add New Todo</Text>
<View style={commonStyles.formFields}>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Title *</Text>
<TextInput
style={commonStyles.input}
value={newTodoTitle}
onChangeText={setNewTodoTitle}
placeholder="What needs to be done?"
placeholderTextColor={colors.textPlaceholder}
/>
</View>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={newTodoDetails}
onChangeText={setNewTodoDetails}
placeholder="Add some details (optional)..."
placeholderTextColor={colors.textPlaceholder}
multiline
numberOfLines={3}
/>
</View>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={addTodo}
disabled={addingTodo || !newTodoTitle.trim()}
>
<Text style={commonStyles.buttonText}>
{addingTodo ? "Adding..." : "Add Todo"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
</>
);
const renderEmptyState = () => (
<View style={commonStyles.emptyState}>
<Text style={commonStyles.emptyStateTitle}>No todos yet</Text>
<Text style={commonStyles.emptyStateText}>
Create your first todo to get started!
</Text>
</View>
);
if (loading) {
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={commonStyles.loadingText}>Loading todos...</Text>
</View>
</ProtectedScreen>
);
}
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.container}>
{renderHeader()}
<FlatList
data={showAddForm ? [] : todos}
renderItem={renderTodoItem}
keyExtractor={(item) => item.id}
ListEmptyComponent={!showAddForm ? renderEmptyState : null}
showsVerticalScrollIndicator={false}
contentContainerStyle={commonStyles.listContainer}
keyboardShouldPersistTaps="handled"
/>
</View>
</ProtectedScreen>
);
}