diff --git a/demos/react-native-supabase-todolist/app/_layout.tsx b/demos/react-native-supabase-todolist/app/_layout.tsx
index 8efcf8ab..941d4095 100644
--- a/demos/react-native-supabase-todolist/app/_layout.tsx
+++ b/demos/react-native-supabase-todolist/app/_layout.tsx
@@ -1,7 +1,9 @@
-import { Stack } from 'expo-router';
+import { router, Stack } from 'expo-router';
import React, { useMemo } from 'react';
import { useSystem } from '../library/powersync/system';
import { PowerSyncContext } from '@powersync/react-native';
+import { Pressable } from 'react-native';
+import { MaterialIcons } from '@expo/vector-icons';
/**
* This App uses a nested navigation stack.
@@ -30,6 +32,21 @@ const HomeLayout = () => {
+ (
+ {
+ router.back();
+ }}>
+
+
+ ),
+ presentation: 'fullScreenModal'
+ }}
+ />
);
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..6024a6ae
--- /dev/null
+++ b/demos/react-native-supabase-todolist/app/search_modal.tsx
@@ -0,0 +1,21 @@
+import { StatusBar } from 'expo-status-bar';
+import { StyleSheet, 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/ios/Podfile.lock b/demos/react-native-supabase-todolist/ios/Podfile.lock
index bcfd7481..b589188c 100644
--- a/demos/react-native-supabase-todolist/ios/Podfile.lock
+++ b/demos/react-native-supabase-todolist/ios/Podfile.lock
@@ -1005,7 +1005,7 @@ PODS:
- React-debug
- react-native-encrypted-storage (4.0.3):
- React-Core
- - react-native-quick-sqlite (2.2.0):
+ - react-native-quick-sqlite (2.2.1):
- DoubleConversion
- glog
- hermes-engine
@@ -1467,7 +1467,7 @@ DEPENDENCIES:
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- react-native-encrypted-storage (from `../../../node_modules/react-native-encrypted-storage`)
- - "react-native-quick-sqlite (from `../../../node_modules/@journeyapps/react-native-quick-sqlite`)"
+ - "react-native-quick-sqlite (from `../node_modules/@journeyapps/react-native-quick-sqlite`)"
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- React-nativeconfig (from `../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -1593,7 +1593,7 @@ EXTERNAL SOURCES:
react-native-encrypted-storage:
:path: "../../../node_modules/react-native-encrypted-storage"
react-native-quick-sqlite:
- :path: "../../../node_modules/@journeyapps/react-native-quick-sqlite"
+ :path: "../node_modules/@journeyapps/react-native-quick-sqlite"
react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context"
React-nativeconfig:
@@ -1698,7 +1698,7 @@ SPEC CHECKSUMS:
React-logger: 257858bd55f3a4e1bc0cf07ddc8fb9faba6f8c7c
React-Mapbuffer: 6c1cacdbf40b531f549eba249e531a7d0bfd8e7f
react-native-encrypted-storage: db300a3f2f0aba1e818417c1c0a6be549038deb7
- react-native-quick-sqlite: b4b34028dbe2d532beb2575f4b90ae58bec42260
+ react-native-quick-sqlite: fa617eb5224e530a0cafe21f35dbff9d98b1a557
react-native-safe-area-context: afa5d614d6b1b73b743c9261985876606560d128
React-nativeconfig: ba9a2e54e2f0882cf7882698825052793ed4c851
React-NativeModulesApple: 8d11ff8955181540585c944cf48e9e7236952697
@@ -1733,4 +1733,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: ad989b4e43152979093488e5c8b7457e401bf191
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
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..5d2a396b
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/fts/fts_helpers.ts
@@ -0,0 +1,33 @@
+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 interface SearchResult {
+ id: string;
+ listName: string;
+ todoName: string | null;
+}
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..574eceaf
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/fts/fts_setup.ts
@@ -0,0 +1,69 @@
+import { AppSchema } from '../powersync/AppSchema';
+import { ExtractType, generateJsonExtracts } from './helpers';
+import { PowerSyncDatabase } from '@powersync/react-native';
+
+/**
+ * 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(
+ db: PowerSyncDatabase,
+ tableName: string,
+ columns: string[],
+ tokenizationMethod = 'unicode61'
+): Promise {
+ const internalName = AppSchema.tables.find((table) => table.name === tableName)?.internalName;
+ const stringColumns = columns.join(', ');
+
+ return await db.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(db: PowerSyncDatabase): Promise {
+ await createFtsTable(db, 'lists', ['name'], 'porter unicode61');
+ await createFtsTable(db, '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..d95650c4 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
+ await configureFts(this.powersync);
}
}
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..bbc06444
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/widgets/AutoCompleteWidget.tsx
@@ -0,0 +1,93 @@
+import { View, StyleSheet } from 'react-native';
+import { Input, ListItem } from '@rneui/themed';
+import React, { useState } from 'react';
+import { IconNode } from '@rneui/base';
+
+export interface AutocompleteWidgetProps {
+ data: any[];
+ onChange: (value: string) => void;
+ placeholder?: string;
+ onPress: (id: string) => void;
+ leftIcon?: IconNode;
+}
+
+export const Autocomplete: React.FC = ({ data, onChange, placeholder, onPress, leftIcon }) => {
+ const [value, setValue] = useState('');
+ const [menuVisible, setMenuVisible] = useState(false);
+
+ return (
+
+
+ {
+ if (value?.length === 0) {
+ setMenuVisible(true);
+ }
+ }}
+ leftIcon={leftIcon}
+ placeholder={placeholder}
+ onBlur={() => setMenuVisible(false)}
+ underlineColorAndroid={'transparent'}
+ inputContainerStyle={{ borderBottomWidth: 0 }}
+ onChangeText={(text) => {
+ onChange(text);
+ setMenuVisible(true);
+ setValue(text);
+ }}
+ containerStyle={{
+ borderColor: 'black',
+ borderWidth: 1,
+ borderRadius: 4,
+ height: 48,
+ backgroundColor: 'white'
+ }}
+ />
+
+ {menuVisible && (
+
+ {data.map((val, index) => (
+ {
+ setMenuVisible(false);
+ onPress(val.id);
+ }}
+ style={{ paddingBottom: 8 }}>
+
+ {val.listName && (
+ {val.listName}
+ )}
+ {val.todoName && (
+
+ {'\u2022'} {val.todoName}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'column',
+ flex: 1,
+ flexGrow: 1,
+ marginHorizontal: 8
+ },
+ inputContainer: {
+ flexDirection: 'row',
+ flex: 0,
+ marginVertical: 8
+ },
+ menuContainer: {
+ flex: 2,
+ flexGrow: 1,
+ flexDirection: 'column'
+ }
+});
diff --git a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
index f8af67d3..47e9ea9d 100644
--- a/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
+++ b/demos/react-native-supabase-todolist/library/widgets/HeaderWidget.tsx
@@ -1,10 +1,11 @@
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';
import { useSystem } from '../powersync/system';
+import { usePathname } from 'expo-router';
export const HeaderWidget: React.FC<{
title?: string;
@@ -15,6 +16,8 @@ export const HeaderWidget: React.FC<{
const status = useStatus();
const { title } = props;
+
+ const pathName = usePathname();
return (
}
rightComponent={
- {
- if (system.attachmentQueue) {
- system.attachmentQueue.trigger();
- }
- Alert.alert(
- 'Status',
- `${status.connected ? 'Connected' : 'Disconnected'}. \nLast Synced at ${
- status?.lastSyncedAt?.toISOString() ?? '-'
- }\nVersion: ${powersync.sdkVersion}`
- );
- }}
- />
+
+ {pathName.includes('lists') && (
+ {
+ 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..ce5e35fc
--- /dev/null
+++ b/demos/react-native-supabase-todolist/library/widgets/SearchBarWidget.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { usePowerSync } from '@powersync/react';
+import { Autocomplete } from './AutoCompleteWidget';
+import { SearchResult, searchTable } from '../fts/fts_helpers';
+import { LIST_TABLE, ListRecord } from '../powersync/AppSchema';
+import { router } from 'expo-router';
+
+// 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 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): SearchResult => {
+ return { id: result['id'], listName: result['name'], todoName: null };
+ });
+ const formattedTodoItemsResults: SearchResult[] = todoItemsSearchResults.map((result): SearchResult => {
+ return { id: result['list_id'], listName: result['list_name'] ?? '', todoName: result['description'] };
+ });
+ setSearchResults([...formattedTodoItemsResults, ...formattedListResults]);
+ }
+ };
+
+ return (
+ {
+ router.back();
+ router.push({
+ pathname: 'views/todos/edit/[id]',
+ params: { id: id }
+ });
+ }}
+ />
+ );
+};
diff --git a/demos/react-native-supabase-todolist/package.json b/demos/react-native-supabase-todolist/package.json
index 1aa52ff6..9cbd43e6 100644
--- a/demos/react-native-supabase-todolist/package.json
+++ b/demos/react-native-supabase-todolist/package.json
@@ -10,7 +10,7 @@
"dependencies": {
"@azure/core-asynciterator-polyfill": "^1.0.2",
"@expo/vector-icons": "^14.0.3",
- "@journeyapps/react-native-quick-sqlite": "^2.2.0",
+ "@journeyapps/react-native-quick-sqlite": "^2.2.1",
"@powersync/attachments": "workspace:*",
"@powersync/common": "workspace:*",
"@powersync/react": "workspace:*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 329e1d59..65c03465 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -768,8 +768,8 @@ importers:
specifier: ^14.0.3
version: 14.0.4
'@journeyapps/react-native-quick-sqlite':
- specifier: ^2.2.0
- version: 2.2.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)
+ specifier: ^2.2.1
+ version: 2.2.1(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)
'@powersync/attachments':
specifier: workspace:*
version: link:../../packages/attachments
@@ -4601,6 +4601,12 @@ packages:
react: '*'
react-native: '*'
+ '@journeyapps/react-native-quick-sqlite@2.2.1':
+ resolution: {integrity: sha512-imvd5P9ii5SmtPJed+R4Yn26QDV3ED/jx/3OuKMDcdjCFi/m5yCCrLz0ewgRwubHGkltNYy5d3tCYodmy4+1KA==}
+ peerDependencies:
+ react: '*'
+ react-native: '*'
+
'@journeyapps/wa-sqlite@0.4.2':
resolution: {integrity: sha512-xdpDLbyC/DHkNcnXCfgBXUgfy+ff1w/sxVY6mjdGP8F4bgxnSQfUyN8+PNE2nTgYUx4y5ar57MEnSty4zjIm7Q==}
@@ -19107,9 +19113,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1)':
+ '@babel/eslint-parser@7.25.8(@babel/core@7.25.7)(eslint@8.57.1)':
dependencies:
- '@babel/core': 7.24.5
+ '@babel/core': 7.25.7
'@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1
eslint: 8.57.1
eslint-visitor-keys: 2.1.0
@@ -24549,6 +24555,11 @@ snapshots:
react: 18.2.0
react-native: 0.74.5(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0)
+ '@journeyapps/react-native-quick-sqlite@2.2.1(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0)':
+ dependencies:
+ react: 18.2.0
+ react-native: 0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.7(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0)
+
'@journeyapps/wa-sqlite@0.4.2': {}
'@journeyapps/wa-sqlite@1.0.0': {}
@@ -26904,7 +26915,7 @@ snapshots:
'@react-native/eslint-config@0.73.2(eslint@8.57.1)(prettier@3.3.3)(typescript@5.5.4)':
dependencies:
'@babel/core': 7.24.5
- '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1)
+ '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1)
'@react-native/eslint-plugin': 0.73.1
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.5.4))(eslint@8.57.1)(typescript@5.5.4)
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.5.4)
@@ -33369,7 +33380,7 @@ snapshots:
eslint-plugin-ft-flow@2.0.3(@babel/eslint-parser@7.25.8(@babel/core@7.24.5)(eslint@8.57.1))(eslint@8.57.1):
dependencies:
- '@babel/eslint-parser': 7.25.8(@babel/core@7.24.5)(eslint@8.57.1)
+ '@babel/eslint-parser': 7.25.8(@babel/core@7.25.7)(eslint@8.57.1)
eslint: 8.57.1
lodash: 4.17.21
string-natural-compare: 3.0.1