feat: filter out deleted fields in ListDetailScreen and webRoutes for improved data integrity

This commit is contained in:
2025-10-31 22:12:21 +01:00
parent 1e628a3a16
commit 62077840d1
4 changed files with 98 additions and 36 deletions
@@ -69,11 +69,12 @@ data class Field(
val isDeleted: Boolean = false, val isDeleted: Boolean = false,
) { ) {
fun getType(): FieldType { fun getType(): FieldType {
val raw = fieldType.ifBlank { "TEXT" }.uppercase()
return try { return try {
FieldType.valueOf(fieldType) FieldType.valueOf(raw)
} catch (e: Exception) { } catch (e: Exception) {
// Handle legacy field types // Handle legacy/synonym field types
when (fieldType) { when (raw) {
"STRING" -> FieldType.TEXT "STRING" -> FieldType.TEXT
"PRICE" -> FieldType.CURRENCY "PRICE" -> FieldType.CURRENCY
"AMOUNT" -> FieldType.NUMBER "AMOUNT" -> FieldType.NUMBER
@@ -84,7 +85,7 @@ data class Field(
} }
fun getDropdownOptions(): List<String> { fun getDropdownOptions(): List<String> {
return if (fieldType == "DROPDOWN" && fieldOptions.isNotBlank()) { return if (getType() == FieldType.DROPDOWN && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
@@ -92,7 +93,7 @@ data class Field(
} }
fun getCurrency(): String { fun getCurrency(): String {
return if ((fieldType == "CURRENCY" || fieldType == "PRICE") && fieldOptions.isNotBlank()) { return if ((getType() == FieldType.CURRENCY) && fieldOptions.isNotBlank()) {
fieldOptions fieldOptions
} else { } else {
"CHF" "CHF"
@@ -100,7 +101,7 @@ data class Field(
} }
fun getMaxRating(): Int { fun getMaxRating(): Int {
return if (fieldType == "RATING" && fieldOptions.isNotBlank()) { return if (getType() == FieldType.RATING && fieldOptions.isNotBlank()) {
fieldOptions.toIntOrNull() ?: 5 fieldOptions.toIntOrNull() ?: 5
} else { } else {
5 5
@@ -108,7 +109,7 @@ data class Field(
} }
fun getAutocompleteOptions(): List<String> { fun getAutocompleteOptions(): List<String> {
return if (fieldType == "AUTOCOMPLETE" && fieldOptions.isNotBlank()) { return if (getType() == FieldType.AUTOCOMPLETE && fieldOptions.isNotBlank()) {
fieldOptions.split("|") fieldOptions.split("|")
} else { } else {
emptyList() emptyList()
@@ -1345,13 +1345,23 @@ fun AddFieldDialog(
"CURRENCY" -> "Currency" "CURRENCY" -> "Currency"
"PERCENTAGE" -> "Percentage" "PERCENTAGE" -> "Percentage"
"DROPDOWN" -> "Dropdown" "DROPDOWN" -> "Dropdown"
"AUTOCOMPLETE" -> "Autocomplete"
"CHECKBOX" -> "Checkbox" "CHECKBOX" -> "Checkbox"
"SWITCH" -> "Switch"
"URL" -> "URL" "URL" -> "URL"
"EMAIL" -> "Email" "EMAIL" -> "Email"
"PHONE" -> "Phone" "PHONE" -> "Phone"
"DATE" -> "Date" "DATE" -> "Date"
"TIME" -> "Time" "TIME" -> "Time"
"DATETIME" -> "Date & Time" "DATETIME" -> "Date & Time"
"DURATION" -> "Duration"
"IMAGE" -> "Image"
"FILE" -> "File"
"BARCODE" -> "Barcode"
"SIGNATURE" -> "Signature"
"RATING" -> "Rating"
"COLOR" -> "Color"
"LOCATION" -> "Location"
else -> "Text" else -> "Text"
}, },
onValueChange = {}, onValueChange = {},
@@ -1599,8 +1609,9 @@ fun AddFieldDialog(
TextButton( TextButton(
onClick = { onClick = {
if (fieldName.isNotBlank()) { if (fieldName.isNotBlank()) {
val normalizedType = selectedFieldType.uppercase()
val options = val options =
when (selectedFieldType) { when (normalizedType) {
"CURRENCY" -> currency.trim() "CURRENCY" -> currency.trim()
"DROPDOWN" -> "DROPDOWN" ->
dropdownOptions.split(",") dropdownOptions.split(",")
@@ -1615,12 +1626,12 @@ fun AddFieldDialog(
"RATING" -> currency.trim().ifBlank { "5" } "RATING" -> currency.trim().ifBlank { "5" }
else -> "" else -> ""
} }
onAdd(fieldName.trim(), selectedFieldType, options) onAdd(fieldName.trim(), normalizedType, options)
} }
}, },
enabled = enabled =
fieldName.isNotBlank() && fieldName.isNotBlank() &&
(selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()), (selectedFieldType.uppercase() !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) { ) {
Text(stringResource(R.string.add)) Text(stringResource(R.string.add))
} }
@@ -1641,7 +1652,7 @@ fun EditFieldDialog(
onUpdate: (String, String, String) -> Unit, onUpdate: (String, String, String) -> Unit,
) { ) {
var name by remember { mutableStateOf(field.name) } var name by remember { mutableStateOf(field.name) }
var selectedFieldType by remember { mutableStateOf(field.fieldType ?: "TEXT") } var selectedFieldType by remember { mutableStateOf(field.fieldType?.uppercase() ?: "TEXT") }
var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) } var dropdownOptions by remember { mutableStateOf(field.getDropdownOptions().joinToString(", ")) }
var currency by remember { mutableStateOf(field.getCurrency()) } var currency by remember { mutableStateOf(field.getCurrency()) }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -1676,13 +1687,23 @@ fun EditFieldDialog(
"CURRENCY", "PRICE" -> "Currency" "CURRENCY", "PRICE" -> "Currency"
"PERCENTAGE" -> "Percentage" "PERCENTAGE" -> "Percentage"
"DROPDOWN" -> "Dropdown" "DROPDOWN" -> "Dropdown"
"AUTOCOMPLETE" -> "Autocomplete"
"CHECKBOX" -> "Checkbox" "CHECKBOX" -> "Checkbox"
"SWITCH" -> "Switch"
"URL" -> "URL" "URL" -> "URL"
"EMAIL" -> "Email" "EMAIL" -> "Email"
"PHONE" -> "Phone" "PHONE" -> "Phone"
"DATE" -> "Date" "DATE" -> "Date"
"TIME" -> "Time" "TIME" -> "Time"
"DATETIME" -> "Date & Time" "DATETIME" -> "Date & Time"
"DURATION" -> "Duration"
"IMAGE" -> "Image"
"FILE" -> "File"
"BARCODE" -> "Barcode"
"SIGNATURE" -> "Signature"
"RATING" -> "Rating"
"COLOR" -> "Color"
"LOCATION" -> "Location"
else -> "Text" else -> "Text"
}, },
onValueChange = {}, onValueChange = {},
@@ -1929,8 +1950,9 @@ fun EditFieldDialog(
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
val normalizedType = selectedFieldType.uppercase()
val options = val options =
when (selectedFieldType) { when (normalizedType) {
"CURRENCY", "PRICE" -> currency.trim() "CURRENCY", "PRICE" -> currency.trim()
"DROPDOWN" -> "DROPDOWN" ->
dropdownOptions.split(",") dropdownOptions.split(",")
@@ -1945,7 +1967,7 @@ fun EditFieldDialog(
"RATING" -> currency.trim().ifBlank { "5" } "RATING" -> currency.trim().ifBlank { "5" }
else -> "" else -> ""
} }
onUpdate(name.trim(), selectedFieldType, options) onUpdate(name.trim(), normalizedType, options)
}, },
enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()), enabled = name.isNotBlank() && (selectedFieldType !in listOf("DROPDOWN", "AUTOCOMPLETE") || dropdownOptions.isNotBlank()),
) { ) {
@@ -130,12 +130,15 @@ class ListDetailViewModel(
emptyList() emptyList()
} }
// Insert atomically to avoid intermediate inconsistent states // Insert atomically to avoid intermediate inconsistent states and bump list.updatedAt
database.withTransaction { database.withTransaction {
database.fieldDao().insertField(newField) database.fieldDao().insertField(newField)
if (newValues.isNotEmpty()) { if (newValues.isNotEmpty()) {
database.itemValueDao().insertValues(newValues) database.itemValueDao().insertValues(newValues)
} }
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
} }
performSync() performSync()
@@ -144,7 +147,13 @@ class ListDetailViewModel(
fun deleteField(fieldId: String) { fun deleteField(fieldId: String) {
viewModelScope.launch { viewModelScope.launch {
database.fieldDao().softDeleteField(fieldId, System.currentTimeMillis()) val ts = System.currentTimeMillis()
database.withTransaction {
database.fieldDao().softDeleteField(fieldId, ts)
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync() performSync()
} }
} }
@@ -158,14 +167,20 @@ class ListDetailViewModel(
viewModelScope.launch { viewModelScope.launch {
val field = database.fieldDao().getFieldById(fieldId) val field = database.fieldDao().getFieldById(fieldId)
if (field != null) { if (field != null) {
val ts = System.currentTimeMillis()
database.withTransaction {
database.fieldDao().updateField( database.fieldDao().updateField(
field.copy( field.copy(
name = name, name = name,
fieldType = fieldType, fieldType = fieldType,
fieldOptions = fieldOptions, fieldOptions = fieldOptions,
updatedAt = System.currentTimeMillis(), updatedAt = ts,
), ),
) )
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync() performSync()
} }
} }
@@ -198,6 +213,9 @@ class ListDetailViewModel(
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
database.itemValueDao().insertValues(values) database.itemValueDao().insertValues(values)
} }
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
} }
performSync() performSync()
} }
@@ -230,6 +248,9 @@ class ListDetailViewModel(
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
database.itemValueDao().insertValues(values) database.itemValueDao().insertValues(values)
} }
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
} }
performSync() performSync()
} }
@@ -242,12 +263,18 @@ class ListDetailViewModel(
viewModelScope.launch { viewModelScope.launch {
val itemValue = database.itemValueDao().getValueById(itemValueId) val itemValue = database.itemValueDao().getValueById(itemValueId)
if (itemValue != null) { if (itemValue != null) {
val ts = System.currentTimeMillis()
database.withTransaction {
database.itemValueDao().updateValue( database.itemValueDao().updateValue(
itemValue.copy( itemValue.copy(
value = newValue, value = newValue,
updatedAt = System.currentTimeMillis(), updatedAt = ts,
), ),
) )
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync() performSync()
} }
} }
@@ -255,7 +282,13 @@ class ListDetailViewModel(
fun deleteItem(itemId: String) { fun deleteItem(itemId: String) {
viewModelScope.launch { viewModelScope.launch {
database.itemDao().softDeleteItem(itemId, System.currentTimeMillis()) val ts = System.currentTimeMillis()
database.withTransaction {
database.itemDao().softDeleteItem(itemId, ts)
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = ts))
}
}
performSync() performSync()
} }
} }
@@ -263,8 +296,13 @@ class ListDetailViewModel(
fun reorderFields(reorderedFields: List<Field>) { fun reorderFields(reorderedFields: List<Field>) {
viewModelScope.launch { viewModelScope.launch {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
// Use DAO-level transaction for clarity // Reorder within a transaction and bump list.updatedAt
database.withTransaction {
database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp) database.fieldDao().reorderFieldsInTransaction(reorderedFields, timestamp)
database.listDao().getListById(listId)?.let { l ->
database.listDao().updateList(l.copy(updatedAt = timestamp))
}
}
performSync() performSync()
} }
} }
+5 -4
View File
@@ -262,7 +262,8 @@ router.get('/', (req: Request, res: Response) => {
} }
container.innerHTML = lists.map(list => { container.innerHTML = lists.map(list => {
const listFields = fields.filter(f => f.listId === list.id); // Only show non-deleted fields in tags and columns
const listFields = fields.filter(f => f.listId === list.id && !f.isDeleted);
const listItems = items.filter(i => i.listId === list.id); const listItems = items.filter(i => i.listId === list.id);
return \` return \`
@@ -282,7 +283,7 @@ router.get('/', (req: Request, res: Response) => {
<div class="fields-container"> <div class="fields-container">
<strong>Fields (\${listFields.length}):</strong><br> <strong>Fields (\${listFields.length}):</strong><br>
\${listFields.length > 0 \${listFields.length > 0
? listFields.map(f => \`<span class="field-tag \${f.isDeleted ? 'deleted' : ''}">\${f.name} (\${f.fieldType})</span>\`).join('') ? listFields.map(f => \`<span class="field-tag">\${f.name} (\${f.fieldType})</span>\`).join('')
: '<span style="color: #999;">No fields</span>' : '<span style="color: #999;">No fields</span>'
} }
</div> </div>
@@ -343,9 +344,9 @@ router.get('/web/data', async (req: Request, res: Response) => {
res.setHeader('Pragma', 'no-cache'); res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0'); res.setHeader('Expires', '0');
// Exclude deleted lists and items from web UI // Exclude deleted lists/items/fields from web UI
const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC'); const lists = await dbAdapter.queryAll('SELECT * FROM lists WHERE isDeleted = 0 ORDER BY updatedAt DESC');
const fields = await dbAdapter.queryAll('SELECT * FROM fields ORDER BY listId ASC, "order" ASC'); const fields = await dbAdapter.queryAll('SELECT * FROM fields WHERE isDeleted = 0 ORDER BY listId ASC, "order" ASC');
const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC'); const items = await dbAdapter.queryAll('SELECT * FROM items WHERE isDeleted = 0 ORDER BY listId ASC, updatedAt DESC');
const values = await dbAdapter.queryAll('SELECT * FROM item_values'); const values = await dbAdapter.queryAll('SELECT * FROM item_values');