diff --git a/demos/react-native-supabase-todolist/app/_layout.tsx b/demos/react-native-supabase-todolist/app/_layout.tsx
index 8efcf8ab..dc3ce684 100644
--- a/demos/react-native-supabase-todolist/app/_layout.tsx
+++ b/demos/react-native-supabase-todolist/app/_layout.tsx
@@ -30,6 +30,12 @@ const HomeLayout = () => {
+
);
diff --git a/demos/react-native-supabase-todolist/app/search_modal.tsx b/demos/react-native-supabase-todolist/app/search_modal.tsx
new file mode 100644
index 00000000..9f065f31
--- /dev/null
+++ b/demos/react-native-supabase-todolist/app/search_modal.tsx
@@ -0,0 +1,23 @@
+import { Stack } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import { StyleSheet, Text, View } from 'react-native';
+import { SearchBarWidget } from '../library/widgets/SearchBarWidget';
+
+export default function Modal() {
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ flexGrow: 1,
+ alignItems: 'center',
+ justifyContent: 'center'
+ }
+});
diff --git a/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts b/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts
new file mode 100644
index 00000000..1b9d0be5
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts
@@ -0,0 +1,39 @@
+import { system } from '../powersync/system';
+
+/**
+ * adding * to the end of the search term will match any word that starts with the search term
+ * e.g. searching bl will match blue, black, etc.
+ * consult FTS5 Full-text Query Syntax documentation for more options
+ * @param searchTerm
+ * @returns a modified search term with options.
+ */
+function createSearchTermWithOptions(searchTerm: string): string {
+ const searchTermWithOptions: string = `${searchTerm}*`;
+ return searchTermWithOptions;
+}
+
+/**
+ * Search the FTS table for the given searchTerm
+ * @param searchTerm
+ * @param tableName
+ * @returns results from the FTS table
+ */
+export async function searchTable(searchTerm: string, tableName: string): Promise {
+ const searchTermWithOptions = createSearchTermWithOptions(searchTerm);
+ return await system.powersync.getAll(`SELECT * FROM fts_${tableName} WHERE fts_${tableName} MATCH ? ORDER BY rank`, [
+ searchTermWithOptions
+ ]);
+}
+
+//Used to display the search results in the autocomplete text field
+export class SearchResult {
+ id: string;
+ todoName: string | null;
+ listName: string;
+
+ constructor(id: string, listName: string, todoName: string | null = null) {
+ this.id = id;
+ this.listName = listName;
+ this.todoName = todoName;
+ }
+}
diff --git a/demos/react-native-supabase-todolist/library/fts/fts_setup.ts b/demos/react-native-supabase-todolist/library/fts/fts_setup.ts
new file mode 100644
index 00000000..c2d16a01
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/fts/fts_setup.ts
@@ -0,0 +1,64 @@
+import { AppSchema } from '../powersync/AppSchema';
+import { ExtractType, generateJsonExtracts } from './helpers';
+import { system } from '../powersync/system';
+
+/**
+ * Create a Full Text Search table for the given table and columns
+ * with an option to use a different tokenizer otherwise it defaults
+ * to unicode61. It also creates the triggers that keep the FTS table
+ * and the PowerSync table in sync.
+ * @param tableName
+ * @param columns
+ * @param tokenizationMethod
+ */
+async function createFtsTable(tableName: string, columns: string[], tokenizationMethod = 'unicode61'): Promise {
+ const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName;
+ const stringColumns = columns.join(', ');
+
+ return await system.powersync.writeTransaction(async (tx) => {
+ // Add FTS table
+ await tx.execute(`
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_${tableName}
+ USING fts5(id UNINDEXED, ${stringColumns}, tokenize='${tokenizationMethod}');
+ `);
+ // Copy over records already in table
+ await tx.execute(`
+ INSERT OR REPLACE INTO fts_${tableName}(rowid, id, ${stringColumns})
+ SELECT rowid, id, ${generateJsonExtracts(ExtractType.columnOnly, 'data', columns)} FROM ${internalName};
+ `);
+ // Add INSERT, UPDATE and DELETE and triggers to keep fts table in sync with table
+ await tx.execute(`
+ CREATE TRIGGER IF NOT EXISTS fts_insert_trigger_${tableName} AFTER INSERT ON ${internalName}
+ BEGIN
+ INSERT INTO fts_${tableName}(rowid, id, ${stringColumns})
+ VALUES (
+ NEW.rowid,
+ NEW.id,
+ ${generateJsonExtracts(ExtractType.columnOnly, 'NEW.data', columns)}
+ );
+ END;
+ `);
+ await tx.execute(`
+ CREATE TRIGGER IF NOT EXISTS fts_update_trigger_${tableName} AFTER UPDATE ON ${internalName} BEGIN
+ UPDATE fts_${tableName}
+ SET ${generateJsonExtracts(ExtractType.columnInOperation, 'NEW.data', columns)}
+ WHERE rowid = NEW.rowid;
+ END;
+ `);
+ await tx.execute(`
+ CREATE TRIGGER IF NOT EXISTS fts_delete_trigger_${tableName} AFTER DELETE ON ${internalName} BEGIN
+ DELETE FROM fts_${tableName} WHERE rowid = OLD.rowid;
+ END;
+ `);
+ });
+}
+
+/**
+ * This is where you can add more methods to generate FTS tables in this demo
+ * that correspond to the tables in your schema and populate them
+ * with the data you would like to search on
+ */
+export async function configureFts(): Promise {
+ await createFtsTable('lists', ['name'], 'porter unicode61');
+ await createFtsTable('todos', ['description', 'list_id']);
+}
diff --git a/demos/react-native-supabase-todolist/library/fts/helpers.ts b/demos/react-native-supabase-todolist/library/fts/helpers.ts
new file mode 100644
index 00000000..5a9054bd
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/fts/helpers.ts
@@ -0,0 +1,36 @@
+type ExtractGenerator = (jsonColumnName: string, columnName: string) => string;
+
+export enum ExtractType {
+ columnOnly,
+ columnInOperation
+}
+
+type ExtractGeneratorMap = Map;
+
+function _createExtract(jsonColumnName: string, columnName: string): string {
+ return `json_extract(${jsonColumnName}, '$.${columnName}')`;
+}
+
+const extractGeneratorsMap: ExtractGeneratorMap = new Map([
+ [ExtractType.columnOnly, (jsonColumnName: string, columnName: string) => _createExtract(jsonColumnName, columnName)],
+ [
+ ExtractType.columnInOperation,
+ (jsonColumnName: string, columnName: string) => {
+ const extract = _createExtract(jsonColumnName, columnName);
+ return `${columnName} = ${extract}`;
+ }
+ ]
+]);
+
+export const generateJsonExtracts = (type: ExtractType, jsonColumnName: string, columns: string[]): string => {
+ const generator = extractGeneratorsMap.get(type);
+ if (generator == null) {
+ throw new Error('Unexpected null generator for key: $type');
+ }
+
+ if (columns.length == 1) {
+ return generator(jsonColumnName, columns[0]);
+ }
+
+ return columns.map((column) => generator(jsonColumnName, column)).join(', ');
+};
diff --git a/demos/react-native-supabase-todolist/library/powersync/system.ts b/demos/react-native-supabase-todolist/library/powersync/system.ts
index ca78d05c..f2551446 100644
--- a/demos/react-native-supabase-todolist/library/powersync/system.ts
+++ b/demos/react-native-supabase-todolist/library/powersync/system.ts
@@ -11,6 +11,7 @@ import { AppConfig } from '../supabase/AppConfig';
import { SupabaseConnector } from '../supabase/SupabaseConnector';
import { AppSchema } from './AppSchema';
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
+import { configureFts } from '../fts/fts_setup';
Logger.useDefaults();
@@ -68,6 +69,10 @@ export class System {
if (this.attachmentQueue) {
await this.attachmentQueue.init();
}
+
+ // Demo using SQLite Full-Text Search with PowerSync.
+ // See https://docs.powersync.com/usage-examples/full-text-search for more details
+ configureFts();
}
}
diff --git a/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx
new file mode 100644
index 00000000..203c9c1f
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx
@@ -0,0 +1,121 @@
+import { View, StyleSheet, TouchableOpacity } from 'react-native';
+import { Card, Input, ListItem, Text } from '@rneui/themed';
+import React, { useState } from 'react';
+import { router } from 'expo-router';
+
+export interface AutocompleteWidgetProps {
+ origValue: string;
+ label?: string;
+ data: any[];
+ onChange: (value: string) => void;
+ // origOnChange: (value: string) => void;
+ icon?: string;
+ style?: object;
+ menuStyle?: object;
+ right?: object;
+ left?: object;
+}
+
+export const Autocomplete: React.FC = ({
+ origValue,
+ label,
+ data,
+ onChange,
+ // origOnChange,
+ icon,
+ style,
+ menuStyle,
+ right,
+ left
+}) => {
+ const [value, setValue] = useState(origValue);
+ const [menuVisible, setMenuVisible] = useState(false);
+ const [filteredData, setFilteredData] = useState([]);
+
+ const filterData = (text: string) => {
+ return data.filter((val: any) => val?.toLowerCase()?.indexOf(text?.toLowerCase()) > -1);
+ };
+ return (
+
+
+ {
+ if (value.length === 0) {
+ setMenuVisible(true);
+ }
+ }}
+ onBlur={() => setMenuVisible(false)}
+ label={label}
+ // right={right}
+ // left={left}
+ // style={styles.input}
+ onChangeText={(text) => {
+ // origOnChange(text);
+ onChange(text);
+ // if (text && text.length > 0) {
+ // setFilteredData(filterData(text));
+ // } else if (text && text.length === 0) {
+ // setFilteredData(data);
+ // }
+ setMenuVisible(true);
+ setValue(text);
+ }}
+ // value={value}
+ />
+
+ {menuVisible && (
+
+ {data.map((val, index) => (
+ {
+ router.push({
+ pathname: 'views/todos/edit/[id]',
+ params: { id: val.id }
+ });
+ }}>
+
+ {val.listName && {val.listName}}
+ {val.todoName && (
+
+ {'\u2022'} {val.todoName}
+
+ )}
+
+
+ // {
+ // setValue(val);
+ // setMenuVisible(false);
+ // }}
+ // // title={datum}
+ // >
+ //
+ // {val}
+ //
+ //
+ ))}
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ input: {
+ flexDirection: 'row',
+ flex: 1,
+ flexGrow: 1,
+ width: '100%',
+ alignItems: 'center',
+ justifyContent: 'flex-start'
+ }
+});
diff --git a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
index f8af67d3..52354d66 100644
--- a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
+++ b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { Alert, Text } from 'react-native';
-import { useNavigation } from 'expo-router';
+import { Alert, View, StyleSheet } from 'react-native';
+import { router, useNavigation } from 'expo-router';
import { Icon, Header } from '@rneui/themed';
import { useStatus } from '@powersync/react';
import { DrawerActions } from '@react-navigation/native';
@@ -29,27 +29,46 @@ export const HeaderWidget: React.FC<{
/>
}
rightComponent={
- {
- if (system.attachmentQueue) {
- system.attachmentQueue.trigger();
- }
- Alert.alert(
- 'Status',
- `${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
- status?.lastSyncedAt?.toISOString() ?? '-'
- }\nVersion: ${powersync.sdkVersion}`
- );
- }}
- />
+
+ {
+ router.push('search_modal');
+ }}
+ />
+ {
+ if (system.attachmentQueue) {
+ system.attachmentQueue.trigger();
+ }
+ Alert.alert(
+ 'Status',
+ `${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
+ status?.lastSyncedAt?.toISOString() ?? '-'
+ }\nVersion: ${powersync.sdkVersion}`
+ );
+ }}
+ />
+
}
centerContainerStyle={{ justifyContent: 'center', alignItems: 'center' }}
centerComponent={{ text: title, style: { color: '#fff' } }}
/>
);
};
+
+const styles = StyleSheet.create({
+ headerRight: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center'
+ }
+});
diff --git a/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx
new file mode 100644
index 00000000..6052210a
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx
@@ -0,0 +1,40 @@
+// import { Autocomplete, Box, Card, CardContent, FormControl, TextField, Typography } from '@mui/material';
+import React from 'react';
+import { usePowerSync } from '@powersync/react';
+import { Autocomplete } from './AutoCompleteWidget';
+import { SearchResult, searchTable } from '../fts/fts_helpers';
+import { LIST_TABLE, TODO_TABLE, ListRecord } from '../powersync/AppSchema';
+
+// This is a simple search bar widget that allows users to search for lists and todo items
+export const SearchBarWidget: React.FC = () => {
+ const [searchResults, setSearchResults] = React.useState([]);
+ const [value, setValue] = React.useState(null);
+
+ // const navigate = useNavigate();
+ const powersync = usePowerSync();
+
+ const handleInputChange = async (value: string) => {
+ if (value.length !== 0) {
+ let listsSearchResults: any[] = [];
+ const todoItemsSearchResults = await searchTable(value, 'todos');
+ for (let i = 0; i < todoItemsSearchResults.length; i++) {
+ const res = await powersync.get(`SELECT * FROM ${LIST_TABLE} WHERE id = ?`, [
+ todoItemsSearchResults[i]['list_id']
+ ]);
+ todoItemsSearchResults[i]['list_name'] = res.name;
+ }
+ if (!todoItemsSearchResults.length) {
+ listsSearchResults = await searchTable(value, 'lists');
+ }
+ const formattedListResults: SearchResult[] = listsSearchResults.map(
+ (result) => new SearchResult(result['id'], result['name'])
+ );
+ const formattedTodoItemsResults: SearchResult[] = todoItemsSearchResults.map((result) => {
+ return new SearchResult(result['list_id'], result['list_name'] ?? '', result['description']);
+ });
+ setSearchResults([...formattedTodoItemsResults, ...formattedListResults]);
+ }
+ };
+
+ return ;
+};