diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/Documents.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/Documents.kt index 62e397d..a73941b 100644 --- a/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/Documents.kt +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/Documents.kt @@ -7,8 +7,18 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.* +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState @@ -21,7 +31,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -37,9 +47,19 @@ fun Documents(collectionName: String, isStandAlone: Boolean) { val selectedDoc by viewModel.selectedDoc.observeAsState() val docsList by viewModel.docsList.observeAsState() + val errorMessage by viewModel.errorMessage.observeAsState() var selectedIndex by remember { mutableStateOf(0) } var startUp by remember { mutableStateOf(true) } + // Auto-select first document when docsList loads/changes + LaunchedEffect(docsList) { + if (!docsList.isNullOrEmpty() && startUp) { + selectedIndex = 0 + viewModel.selectedDoc.value = docsList!![0] + startUp = false + } + } + Column( modifier = Modifier .fillMaxWidth() @@ -55,72 +75,136 @@ fun Documents(collectionName: String, isStandAlone: Boolean) { viewModel.filterDocs(searchText) selectedIndex = 0 }) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Type an ID to search, or use DQL: id == \"value\" • name CONTAINS \"text\"", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp) + ) + + // Show message for large datasets + if ((docsList?.size ?: 0) > 1000) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Large dataset: Use search bar to find documents, or Previous/Next buttons to browse", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) - Text(text = "Docs count: ${docsList?.size}") + + errorMessage?.let { error -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + Text(text = "Docs count: ${docsList?.size ?: "Loading..."}") Spacer(modifier = Modifier.height(16.dp)) - Row { - Text( - text = "Doc ID: ", - textAlign = TextAlign.Start, - modifier = Modifier - .clickable { + Column { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Doc ID: ", + textAlign = TextAlign.Start + ) - } - ) + if (!docsList.isNullOrEmpty()) { + val isLargeDataset = (docsList?.size ?: 0) > 1000 - if (!docsList.isNullOrEmpty()) { - Box { - // Show selected item or "select" if no item is selected - (if ((startUp)) "select" else docsList?.get(selectedIndex)?.id)?.let { - Text( - text = it, - textAlign = TextAlign.Start, - color = Color.Blue, - modifier = Modifier - .clickable { - showMenu = true - - startUp = false + if (isLargeDataset) { + // For large datasets, show ID without dropdown + docsList?.getOrNull(selectedIndex)?.id?.let { docId -> + Text( + text = docId, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.primary + ) + } + } else { + // For small datasets, show dropdown + Box { + docsList?.getOrNull(selectedIndex)?.id?.let { docId -> + Text( + text = docId, + textAlign = TextAlign.Start, + color = Color.Blue, + modifier = Modifier + .clickable { + showMenu = true + } + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + docsList?.forEachIndexed { index, item -> + DropdownMenuItem(onClick = { + selectedIndex = index + viewModel.selectedDoc.value = item + showMenu = false + }, text = { + Text(text = item.id) + }) } - ) + } + } } + } else { + Text( + text = "No Docs", + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.primary + ) + } + } - // Dropdown menu - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } + // Navigation buttons - below the Doc ID row + if (!docsList.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + if (selectedIndex > 0) { + selectedIndex-- + viewModel.selectedDoc.value = docsList!![selectedIndex] + } + }, + enabled = selectedIndex > 0 ) { - docsList?.forEachIndexed { index, item -> - DropdownMenuItem(onClick = { - selectedIndex = index - viewModel.selectedDoc.value = item - }, text = { - Text(text = item.id) - }, modifier = Modifier.onKeyEvent { keyEvent -> - when (keyEvent.key) { - Key.Spacebar -> { - when (keyEvent.type) { - KeyEventType.KeyUp -> { - selectedIndex = index - viewModel.selectedDoc.value = item - true - } - else -> false - } - } - else -> false - } - }) - } + Text("Previous") + } + Button( + onClick = { + if (selectedIndex < (docsList?.size ?: 0) - 1) { + selectedIndex++ + viewModel.selectedDoc.value = docsList!![selectedIndex] + } + }, + enabled = selectedIndex < (docsList?.size ?: 0) - 1 + ) { + Text("Next") } } - } else { - Text( - text = "No Docs", - textAlign = TextAlign.Start, - color = Color.Blue, - ) } } @@ -165,31 +249,56 @@ fun DocItem(property: String, viewModel: DocumentsViewModel, selectedDoc: Docume @Composable fun SearchBar(onSearch: (String) -> Unit) { - var searchText by remember { mutableStateOf(TextFieldValue("")) } + var searchText by remember { mutableStateOf("") } + val textColor = MaterialTheme.colorScheme.onSurface Box( modifier = Modifier .fillMaxWidth() .height(56.dp) - .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(4.dp)) .padding(horizontal = 16.dp), contentAlignment = Alignment.CenterStart ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { onSearch(searchText.text) }) { - Icon(Icons.Default.Search, contentDescription = "Search") + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.Search, contentDescription = "Search") + + Spacer(modifier = Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + if (searchText.isEmpty()) { + Text( + text = "Search by ID or DQL query...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp + ) + } + BasicTextField( + value = searchText, + onValueChange = { newValue -> + searchText = newValue + onSearch(newValue) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + fontSize = 16.sp, + color = textColor + ) + ) } - BasicTextField( - modifier = Modifier - .padding(), - value = searchText, - onValueChange = { searchText = it }, - singleLine = true, - decorationBox = { innerTextField -> - innerTextField() + if (searchText.isNotEmpty()) { + IconButton(onClick = { + searchText = "" + onSearch("") + }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") } - ) + } } } } diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/DocumentsViewModel.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/DocumentsViewModel.kt index 34725bb..4883e9e 100644 --- a/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/DocumentsViewModel.kt +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/databrowser/DocumentsViewModel.kt @@ -9,11 +9,17 @@ class DocumentsViewModel(private val collectionName: String, isStandAlone: Boole val docsList: MutableLiveData> = MutableLiveData>(mutableListOf()) var docProperties: MutableLiveData> = MutableLiveData(emptyList()) var selectedDoc = MutableLiveData() + var errorMessage = MutableLiveData() - val subscription = if (isStandAlone) DittoHandler.ditto.store.collection(collectionName).findAll().limit(1000).subscribe() else null - private var liveQuery = DittoHandler.ditto.store.collection(collectionName).findAll().limit(1000).observeLocal { docs, _ -> + // Store all documents for client-side filtering + private var allDocuments: MutableList = mutableListOf() + private var currentFilter: String = "" + private var isDQLMode: Boolean = false - docsList.value?.clear() + val subscription = if (isStandAlone) DittoHandler.ditto.store.collection(collectionName).findAll().limit(50000).subscribe() else null + private var liveQuery = DittoHandler.ditto.store.collection(collectionName).findAll().limit(50000).observeLocal { docs, _ -> + + val newDocsList = mutableListOf() for(doc in docs) { this.docProperties.postValue(doc.value.keys.map{it}.sorted()) @@ -21,13 +27,15 @@ class DocumentsViewModel(private val collectionName: String, isStandAlone: Boole for((key, value) in doc.value) { docValues[key] = value } - docsList.value?.add(Document(doc.id.toString(), docValues)) + newDocsList.add(Document(doc.id.toString(), docValues)) } + allDocuments = newDocsList + applyFilter() } private fun findAllLiveQuery() { - this.liveQuery = DittoHandler.ditto.store.collection(collectionName).findAll().limit(1000).observeLocal { docs, _ -> - docsList.value?.clear() + this.liveQuery = DittoHandler.ditto.store.collection(collectionName).findAll().limit(50000).observeLocal { docs, _ -> + val newDocsList = mutableListOf() for(doc in docs) { this.docProperties.postValue(doc.value.keys.map{it}.sorted()) @@ -35,36 +43,75 @@ class DocumentsViewModel(private val collectionName: String, isStandAlone: Boole for((key, value) in doc.value) { docValues[key] = value } - docsList.value?.add(Document(doc.id.toString(), docValues)) + newDocsList.add(Document(doc.id.toString(), docValues)) } + allDocuments = newDocsList + applyFilter() } } private fun findWithFilterLiveQuery(queryString: String) { - this.liveQuery = DittoHandler.ditto.store.collection(collectionName).find(queryString).limit(1000).observeLocal { docs, _ -> - docsList.value?.clear() + try { + errorMessage.postValue(null) + this.liveQuery = DittoHandler.ditto.store.collection(collectionName).find(queryString).limit(50000).observeLocal { docs, _ -> + val newDocsList = mutableListOf() - for(doc in docs) { - this.docProperties.postValue(doc.value.keys.map{it}.sorted()) + for(doc in docs) { + this.docProperties.postValue(doc.value.keys.map{it}.sorted()) - val docValues = mutableMapOf() - for((key, value) in doc.value) { - docValues[key] = value + val docValues = mutableMapOf() + for((key, value) in doc.value) { + docValues[key] = value + } + newDocsList.add(Document(doc.id.toString(), docValues)) } - docsList.value?.add(Document(doc.id.toString(), docValues)) + docsList.postValue(newDocsList) } + } catch (e: Exception) { + errorMessage.postValue("Invalid DQL query: ${e.message}") + docsList.postValue(mutableListOf()) } } fun filterDocs(queryString: String) { - liveQuery.close() + currentFilter = queryString - if(queryString.isEmpty()) { - findAllLiveQuery() - } - else { + if (isDQLQuery(queryString)) { + // User provided explicit DQL query - use server-side filtering + liveQuery.close() + isDQLMode = true findWithFilterLiveQuery(queryString) + } else { + // Simple text search - use client-side filtering + if (isDQLMode) { + // Switching from DQL mode back to simple search + // Need to restart the findAll query + liveQuery.close() + isDQLMode = false + findAllLiveQuery() + } else { + // Already in simple mode, just filter the existing data + applyFilter() + } + } + } + + private fun applyFilter() { + val filtered = if (currentFilter.isEmpty()) { + allDocuments + } else { + // Filter documents where ID contains the search text (case-insensitive) + allDocuments.filter { doc -> + doc.id.contains(currentFilter, ignoreCase = true) + }.toMutableList() } + docsList.postValue(filtered) + } + + private fun isDQLQuery(text: String): Boolean { + // Check if the text contains DQL operators + val dqlOperators = listOf("==", "!=", "CONTAINS", "contains", ">", "<", ">=", "<=", "AND", "and", "OR", "or", "IN", "in") + return dqlOperators.any { text.contains(it) } } class MyViewModelFactory(private val collectionName: String, private val isStandAlone: Boolean) : diff --git a/Img/collections.png b/Img/collections.png index 77f8ebc..adfd49d 100644 Binary files a/Img/collections.png and b/Img/collections.png differ diff --git a/README.md b/README.md index 99cf56c..9b0f9f0 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ DittoPresenceViewer(ditto = ditto) ### 3. Data Browser -The Ditto Data Browser allows you to view all your collections, documents within each collection and the propeties/values of a document. With the Data Browser, you can observe any changes that are made to your collections and documents in real time. +The Ditto Data Browser allows you to view all your collections, documents within each collection and the properties/values of a document. With the Data Browser, you can observe any changes that are made to your collections and documents in real time. Within a Composable function, you pass ditto to the constructor: @@ -96,9 +96,18 @@ Within a Composable function, you pass ditto to the constructor: DittoDataBrowser(ditto = ditto) ``` - Collections Image +**Features** - Document Image +- **Search**: Filter documents by ID or use DQL queries + - Simple ID search: Type any part of a document ID + - DQL queries: Use expressions like `id == "value"` or `name CONTAINS "text"` +- **Large Dataset Support**: Handles up to 50,000 documents with client-side filtering for fast searches +- **Navigation**: Use Previous/Next buttons to browse through documents sequentially +- **Real-time Updates**: Watch collections and documents update live as changes occur + + Collections Image + + Document Image **Standalone App**