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 ; +};