refactor: fix correctness bugs and simplify site picker code
- Rethrow CancellationException in goals query catch block to preserve structured concurrency - Replace non-atomic delete+insert in updateSite with single UPDATE statement to prevent data loss on crash or site ID collision - Add updateSiteId SQL query to StoredSite.sq - Extract updateGroup helper on SitePickerUiState to deduplicate 8 instances of the groups.map pattern (-66 lines) - Remove unnecessary WHAT comments in SitePickerScreen/ViewModel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e2e4d7aef7
commit
50edfcdafb
5 changed files with 36 additions and 102 deletions
|
|
@ -53,13 +53,10 @@ class SiteRepository @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
|
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
|
||||||
val sites = getSitesForInstance(instanceId)
|
database.storedSiteQueries.updateSiteId(
|
||||||
val oldSortOrder = sites.find { it.id == oldSiteId }?.sortOrder?.toLong() ?: 0L
|
|
||||||
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
|
|
||||||
database.storedSiteQueries.insert(
|
|
||||||
site_id = newSiteId,
|
site_id = newSiteId,
|
||||||
instance_id = instanceId,
|
site_id_ = oldSiteId,
|
||||||
sort_order = oldSortOrder
|
instance_id = instanceId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,8 @@ class StatsRepository @Inject constructor(
|
||||||
pagination = Pagination(limit = 10)
|
pagination = Pagination(limit = 10)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Goals may not be configured — return null instead of failing the whole dashboard
|
// Goals may not be configured — return null instead of failing the whole dashboard
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ fun SitePickerScreen(
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
// Site delete confirmation dialog
|
|
||||||
uiState.siteToDelete?.let { (instanceId, siteId) ->
|
uiState.siteToDelete?.let { (instanceId, siteId) ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = viewModel::dismissDeleteSite,
|
onDismissRequest = viewModel::dismissDeleteSite,
|
||||||
|
|
@ -87,7 +86,6 @@ fun SitePickerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instance delete confirmation dialog
|
|
||||||
uiState.instanceToDelete?.let { instance ->
|
uiState.instanceToDelete?.let { instance ->
|
||||||
val group = uiState.groups.find { it.instance.id == instance.id }
|
val group = uiState.groups.find { it.instance.id == instance.id }
|
||||||
val siteCount = group?.sites?.size ?: 0
|
val siteCount = group?.sites?.size ?: 0
|
||||||
|
|
@ -175,7 +173,6 @@ fun SitePickerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expanded content: sites + add-site input
|
|
||||||
if (group.isExpanded) {
|
if (group.isExpanded) {
|
||||||
items(
|
items(
|
||||||
items = group.sites,
|
items = group.sites,
|
||||||
|
|
@ -216,7 +213,6 @@ fun SitePickerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline add-site input with validation
|
|
||||||
item(key = "add_${group.instance.id}") {
|
item(key = "add_${group.instance.id}") {
|
||||||
AddSiteRow(
|
AddSiteRow(
|
||||||
value = group.newSiteId,
|
value = group.newSiteId,
|
||||||
|
|
@ -230,13 +226,11 @@ fun SitePickerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spacer between instance groups
|
|
||||||
item(key = "spacer_${group.instance.id}") {
|
item(key = "spacer_${group.instance.id}") {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Add new instance" button at bottom
|
|
||||||
item(key = "add_instance") {
|
item(key = "add_instance") {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,14 @@ data class SitePickerUiState(
|
||||||
val editSiteValue: String = "",
|
val editSiteValue: String = "",
|
||||||
val siteToDelete: Pair<String, String>? = null,
|
val siteToDelete: Pair<String, String>? = null,
|
||||||
val instanceToDelete: PlausibleInstance? = null
|
val instanceToDelete: PlausibleInstance? = null
|
||||||
)
|
) {
|
||||||
|
fun updateGroup(
|
||||||
|
instanceId: String,
|
||||||
|
transform: (InstanceWithSites) -> InstanceWithSites
|
||||||
|
): SitePickerUiState = copy(
|
||||||
|
groups = groups.map { if (it.instance.id == instanceId) transform(it) else it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SitePickerViewModel @Inject constructor(
|
class SitePickerViewModel @Inject constructor(
|
||||||
|
|
@ -79,27 +86,11 @@ class SitePickerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleExpanded(instanceId: String) {
|
fun toggleExpanded(instanceId: String) {
|
||||||
_uiState.update { state ->
|
_uiState.update { it.updateGroup(instanceId) { g -> g.copy(isExpanded = !g.isExpanded) } }
|
||||||
state.copy(
|
|
||||||
groups = state.groups.map { group ->
|
|
||||||
if (group.instance.id == instanceId) {
|
|
||||||
group.copy(isExpanded = !group.isExpanded)
|
|
||||||
} else group
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onNewSiteIdChanged(instanceId: String, value: String) {
|
fun onNewSiteIdChanged(instanceId: String, value: String) {
|
||||||
_uiState.update { state ->
|
_uiState.update { it.updateGroup(instanceId) { g -> g.copy(newSiteId = value, siteTestError = null) } }
|
||||||
state.copy(
|
|
||||||
groups = state.groups.map { group ->
|
|
||||||
if (group.instance.id == instanceId) {
|
|
||||||
group.copy(newSiteId = value, siteTestError = null)
|
|
||||||
} else group
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -112,16 +103,7 @@ class SitePickerViewModel @Inject constructor(
|
||||||
val siteId = group.newSiteId.trim()
|
val siteId = group.newSiteId.trim()
|
||||||
if (siteId.isBlank() || group.isTestingSite) return
|
if (siteId.isBlank() || group.isTestingSite) return
|
||||||
|
|
||||||
// Set testing state
|
_uiState.update { it.updateGroup(instanceId) { g -> g.copy(isTestingSite = true, siteTestError = null) } }
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
groups = state.groups.map { g ->
|
|
||||||
if (g.instance.id == instanceId) {
|
|
||||||
g.copy(isTestingSite = true, siteTestError = null)
|
|
||||||
} else g
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val instance = group.instance
|
val instance = group.instance
|
||||||
|
|
@ -133,41 +115,20 @@ class SitePickerViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
// Validation failed — show error, don't add
|
_uiState.update { it.updateGroup(instanceId) { g -> g.copy(isTestingSite = false, siteTestError = error) } }
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
groups = state.groups.map { g ->
|
|
||||||
if (g.instance.id == instanceId) {
|
|
||||||
g.copy(isTestingSite = false, siteTestError = error)
|
|
||||||
} else g
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation passed — add the site
|
|
||||||
siteRepository.addSite(siteId, instanceId)
|
siteRepository.addSite(siteId, instanceId)
|
||||||
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
||||||
_uiState.update { state ->
|
_uiState.update {
|
||||||
state.copy(
|
it.updateGroup(instanceId) { g ->
|
||||||
groups = state.groups.map { g ->
|
g.copy(sites = updatedSites, newSiteId = "", isTestingSite = false, siteTestError = null)
|
||||||
if (g.instance.id == instanceId) {
|
}
|
||||||
g.copy(
|
|
||||||
sites = updatedSites,
|
|
||||||
newSiteId = "",
|
|
||||||
isTestingSite = false,
|
|
||||||
siteTestError = null
|
|
||||||
)
|
|
||||||
} else g
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Site deletion --
|
|
||||||
|
|
||||||
fun requestDeleteSite(instanceId: String, siteId: String) {
|
fun requestDeleteSite(instanceId: String, siteId: String) {
|
||||||
_uiState.update { it.copy(siteToDelete = instanceId to siteId) }
|
_uiState.update { it.copy(siteToDelete = instanceId to siteId) }
|
||||||
}
|
}
|
||||||
|
|
@ -181,21 +142,13 @@ class SitePickerViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
siteRepository.removeSite(siteId, instanceId)
|
siteRepository.removeSite(siteId, instanceId)
|
||||||
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
||||||
_uiState.update { state ->
|
_uiState.update {
|
||||||
state.copy(
|
it.updateGroup(instanceId) { g -> g.copy(sites = updatedSites) }
|
||||||
groups = state.groups.map { g ->
|
.copy(siteToDelete = null)
|
||||||
if (g.instance.id == instanceId) {
|
|
||||||
g.copy(sites = updatedSites)
|
|
||||||
} else g
|
|
||||||
},
|
|
||||||
siteToDelete = null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Instance deletion --
|
|
||||||
|
|
||||||
fun requestDeleteInstance(instance: PlausibleInstance) {
|
fun requestDeleteInstance(instance: PlausibleInstance) {
|
||||||
_uiState.update { it.copy(instanceToDelete = instance) }
|
_uiState.update { it.copy(instanceToDelete = instance) }
|
||||||
}
|
}
|
||||||
|
|
@ -228,8 +181,6 @@ class SitePickerViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Site editing --
|
|
||||||
|
|
||||||
fun startEditing(siteId: String) {
|
fun startEditing(siteId: String) {
|
||||||
_uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) }
|
_uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) }
|
||||||
}
|
}
|
||||||
|
|
@ -250,16 +201,9 @@ class SitePickerViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
siteRepository.updateSite(oldId, newId, instanceId)
|
siteRepository.updateSite(oldId, newId, instanceId)
|
||||||
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
val updatedSites = siteRepository.getSitesForInstance(instanceId)
|
||||||
_uiState.update { s ->
|
_uiState.update {
|
||||||
s.copy(
|
it.updateGroup(instanceId) { g -> g.copy(sites = updatedSites) }
|
||||||
groups = s.groups.map { g ->
|
.copy(editingSiteId = null, editSiteValue = "")
|
||||||
if (g.instance.id == instanceId) {
|
|
||||||
g.copy(sites = updatedSites)
|
|
||||||
} else g
|
|
||||||
},
|
|
||||||
editingSiteId = null,
|
|
||||||
editSiteValue = ""
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,24 +212,18 @@ class SitePickerViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
|
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Site reordering --
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move a site within an instance's list. Updates UI state immediately
|
* Move a site within an instance's list. Updates UI state immediately
|
||||||
* for responsive drag feedback — persisted on drag end via saveSiteOrder.
|
* for responsive drag feedback — persisted on drag end via saveSiteOrder.
|
||||||
*/
|
*/
|
||||||
fun moveSite(instanceId: String, fromIndex: Int, toIndex: Int) {
|
fun moveSite(instanceId: String, fromIndex: Int, toIndex: Int) {
|
||||||
_uiState.update { state ->
|
_uiState.update {
|
||||||
state.copy(
|
it.updateGroup(instanceId) { group ->
|
||||||
groups = state.groups.map { group ->
|
val reordered = group.sites.toMutableList().apply {
|
||||||
if (group.instance.id == instanceId) {
|
add(toIndex, removeAt(fromIndex))
|
||||||
val reordered = group.sites.toMutableList().apply {
|
|
||||||
add(toIndex, removeAt(fromIndex))
|
|
||||||
}
|
|
||||||
group.copy(sites = reordered)
|
|
||||||
} else group
|
|
||||||
}
|
}
|
||||||
)
|
group.copy(sites = reordered)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,6 @@ UPDATE stored_sites SET sort_order = ? WHERE site_id = ? AND instance_id = ?;
|
||||||
|
|
||||||
maxSortOrderForInstance:
|
maxSortOrderForInstance:
|
||||||
SELECT MAX(sort_order) AS max_order FROM stored_sites WHERE instance_id = ?;
|
SELECT MAX(sort_order) AS max_order FROM stored_sites WHERE instance_id = ?;
|
||||||
|
|
||||||
|
updateSiteId:
|
||||||
|
UPDATE stored_sites SET site_id = ? WHERE site_id = ? AND instance_id = ?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue