summaryrefslogtreecommitdiff
path: root/Rune/Views/Domains
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
committerChristian Cleberg <[email protected]>2026-04-11 11:48:40 -0500
commitfcf864a15b70e4ecb5bd789b1db3116221c34394 (patch)
treef15315324b9f95286b6bf7392dd0ccaee210c2de /Rune/Views/Domains
parentcf587aa0573ac4f34e1effe70108a9eca82093ac (diff)
downloadrune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.gz
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.tar.bz2
rune-fcf864a15b70e4ecb5bd789b1db3116221c34394.zip
add FUNDING.yml
Diffstat (limited to 'Rune/Views/Domains')
-rw-r--r--Rune/Views/Domains/DomainDetailView.swift28
-rw-r--r--Rune/Views/Domains/DomainEditView.swift117
-rw-r--r--Rune/Views/Domains/DomainListView.swift71
-rw-r--r--Rune/Views/Domains/ForwardAddView.swift111
-rw-r--r--Rune/Views/Domains/ForwardListView.swift171
-rw-r--r--Rune/Views/Domains/RecordAddView.swift37
-rw-r--r--Rune/Views/Domains/RecordEditView.swift51
-rw-r--r--Rune/Views/Domains/RecordListView.swift55
8 files changed, 551 insertions, 90 deletions
diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift
index dbb90a1..b4ee00d 100644
--- a/Rune/Views/Domains/DomainDetailView.swift
+++ b/Rune/Views/Domains/DomainDetailView.swift
@@ -25,6 +25,12 @@ struct DomainDetailView: View {
DetailRow(label: "Nameservers", value: nameserverText(domain.nameservers))
}
+ Section("Email") {
+ NavigationLink("Forwards") {
+ ForwardListView(domainName: domain.name, viewModel: viewModel, client: client)
+ }
+ }
+
Section("DNS") {
NavigationLink("Records") {
RecordListView(domainName: domain.name, viewModel: viewModel, client: client)
@@ -49,7 +55,12 @@ struct DomainDetailView: View {
.alert("API Error", isPresented: errorBinding) {
Button("OK", role: .cancel) {}
} message: {
- Text(viewModel.errorMessage ?? "")
+ Text(viewModel.detailErrorMessage ?? "")
+ }
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.mutationErrorMessage ?? "")
}
}
@@ -88,10 +99,21 @@ struct DomainDetailView: View {
private var errorBinding: Binding<Bool> {
Binding(
- get: { viewModel.errorMessage != nil },
+ get: { viewModel.detailErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.detailErrorMessage = nil
+ }
+ }
+ )
+ }
+
+ private var mutationErrorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.mutationErrorMessage != nil },
set: { newValue in
if !newValue {
- viewModel.errorMessage = nil
+ viewModel.dismissMutationError()
}
}
)
diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift
index 3f197e3..0b3b1f8 100644
--- a/Rune/Views/Domains/DomainEditView.swift
+++ b/Rune/Views/Domains/DomainEditView.swift
@@ -8,10 +8,15 @@ struct DomainEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var autorenew: Bool
+ @State private var autorenewDirty = false
@State private var mailforwarding: Bool
+ @State private var mailforwardingDirty = false
@State private var dnssec: Bool
+ @State private var dnssecDirty = false
@State private var lock: Bool
+ @State private var lockDirty = false
@State private var nameserversText: String
+ @State private var nameserversDirty = false
@State private var localErrorMessage: String?
init(domain: Domain, viewModel: DomainViewModel, client: NjallaClient) {
@@ -28,14 +33,14 @@ struct DomainEditView: View {
var body: some View {
Form {
Section("Settings") {
- Toggle("Autorenew", isOn: $autorenew)
- Toggle("Mail Forwarding", isOn: $mailforwarding)
- Toggle("DNSSEC", isOn: $dnssec)
- Toggle("Registrar Lock", isOn: $lock)
+ Toggle("Autorenew", isOn: dirtyBinding(for: $autorenew, dirty: $autorenewDirty, original: originalAutorenew))
+ Toggle("Mail Forwarding", isOn: dirtyBinding(for: $mailforwarding, dirty: $mailforwardingDirty, original: originalMailForwarding))
+ Toggle("DNSSEC", isOn: dirtyBinding(for: $dnssec, dirty: $dnssecDirty, original: originalDNSSEC))
+ Toggle("Registrar Lock", isOn: dirtyBinding(for: $lock, dirty: $lockDirty, original: originalLock))
}
Section {
- TextEditor(text: $nameserversText)
+ TextEditor(text: nameserversBinding)
.frame(minHeight: 120)
} header: {
Text("Nameservers")
@@ -49,12 +54,27 @@ struct DomainEditView: View {
await save()
}
}
- .disabled(viewModel.isSaving)
+ .disabled(viewModel.isSaving || !request.hasChanges)
}
}
.navigationTitle("Edit Domain")
.navigationBarTitleDisplayMode(.inline)
- .alert("API Error", isPresented: localErrorBinding) {
+ .overlay {
+ if viewModel.isSaving {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ .disabled(viewModel.isSaving)
+ }
+ }
+ .interactiveDismissDisabled(viewModel.isSaving)
+ .alert("Request Failed", isPresented: localErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(localErrorMessage ?? "")
@@ -62,16 +82,16 @@ struct DomainEditView: View {
}
private func save() async {
- let request = DomainUpdateRequest(
- autorenew: autorenew,
- mailforwarding: mailforwarding,
- dnssec: dnssec,
- lock: lock,
- nameservers: nameserversText
- .split(whereSeparator: \.isNewline)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- )
+ guard !viewModel.isSaving else {
+ return
+ }
+
+ let request = request
+
+ guard request.hasChanges else {
+ dismiss()
+ return
+ }
do {
try await viewModel.updateDomain(named: domain.name, request: request, client: client)
@@ -82,7 +102,7 @@ struct DomainEditView: View {
if (error as? URLError)?.code == .cancelled {
return
}
- localErrorMessage = error.localizedDescription
+ localErrorMessage = error.userFacingMessage
}
}
@@ -96,4 +116,65 @@ struct DomainEditView: View {
}
)
}
+
+ private var originalAutorenew: Bool {
+ domain.autorenew ?? false
+ }
+
+ private var originalMailForwarding: Bool {
+ domain.mailforwarding ?? false
+ }
+
+ private var originalDNSSEC: Bool {
+ domain.dnssec ?? false
+ }
+
+ private var originalLock: Bool {
+ domain.lock ?? false
+ }
+
+ private var originalNameservers: [String] {
+ normalizedNameservers(from: (domain.nameservers ?? []).joined(separator: "\n"))
+ }
+
+ private var request: DomainUpdateRequest {
+ DomainUpdateRequest(
+ autorenew: autorenewDirty ? autorenew : nil,
+ mailforwarding: mailforwardingDirty ? mailforwarding : nil,
+ dnssec: dnssecDirty ? dnssec : nil,
+ lock: lockDirty ? lock : nil,
+ nameservers: nameserversDirty ? normalizedNameservers(from: nameserversText) : nil
+ )
+ }
+
+ private var nameserversBinding: Binding<String> {
+ Binding(
+ get: { nameserversText },
+ set: { newValue in
+ nameserversText = newValue
+ nameserversDirty = normalizedNameservers(from: newValue) != originalNameservers
+ }
+ )
+ }
+
+ private func dirtyBinding(
+ for value: Binding<Bool>,
+ dirty: Binding<Bool>,
+ original: Bool
+ ) -> Binding<Bool> {
+ Binding(
+ get: { value.wrappedValue },
+ set: { newValue in
+ value.wrappedValue = newValue
+ dirty.wrappedValue = newValue != original
+ }
+ )
+ }
+
+ private func normalizedNameservers(from text: String) -> [String] {
+ text
+ .split(whereSeparator: \.isNewline)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
}
diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift
index d728bd7..68f2ede 100644
--- a/Rune/Views/Domains/DomainListView.swift
+++ b/Rune/Views/Domains/DomainListView.swift
@@ -15,43 +15,58 @@ struct DomainListView: View {
}
.navigationTitle("Domains")
}
- .alert("API Error", isPresented: errorBinding) {
- Button("OK", role: .cancel) {}
- } message: {
- Text(viewModel.errorMessage ?? "")
- }
}
@ViewBuilder
private func content(client: NjallaClient) -> some View {
- if viewModel.isLoadingDomains && viewModel.domains.isEmpty {
- ProgressView()
- } else if viewModel.domains.isEmpty {
- ContentUnavailableView("No Domains", systemImage: "globe", description: Text("No domains found on this account."))
- } else {
- List(viewModel.domains) { domain in
- NavigationLink {
- DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client)
- } label: {
- DomainRow(domain: domain)
+ List {
+ if let errorMessage = viewModel.domainsErrorMessage {
+ Section {
+ InlineErrorView(message: errorMessage, retryTitle: "Retry Domains") {
+ Task {
+ await viewModel.loadDomains(client: client)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
- .listStyle(.insetGrouped)
- .refreshable {
- await viewModel.loadDomains(client: client)
- }
- }
- }
- private var errorBinding: Binding<Bool> {
- Binding(
- get: { viewModel.errorMessage != nil },
- set: { newValue in
- if !newValue {
- viewModel.errorMessage = nil
+ if (!viewModel.hasLoadedDomains || viewModel.isLoadingDomains) && viewModel.domains.isEmpty {
+ Section {
+ HStack {
+ Spacer()
+ ProgressView("Loading Domains")
+ Spacer()
+ }
+ }
+ } else if viewModel.domains.isEmpty {
+ Section {
+ ContentUnavailableView(
+ "No Domains",
+ systemImage: "globe",
+ description: Text("No domains found. Pull to refresh after domains are added to this account.")
+ )
+ }
+ } else {
+ ForEach(viewModel.domains) { domain in
+ NavigationLink {
+ DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client)
+ } label: {
+ DomainRow(domain: domain)
+ }
}
}
- )
+ }
+ .listStyle(.insetGrouped)
+ .refreshable {
+ await viewModel.loadDomains(client: client)
+ }
+ .overlay(alignment: .top) {
+ if viewModel.isLoadingDomains && !viewModel.domains.isEmpty {
+ ProgressView()
+ .padding(.top, 8)
+ }
+ }
}
}
diff --git a/Rune/Views/Domains/ForwardAddView.swift b/Rune/Views/Domains/ForwardAddView.swift
new file mode 100644
index 0000000..222ab86
--- /dev/null
+++ b/Rune/Views/Domains/ForwardAddView.swift
@@ -0,0 +1,111 @@
+import SwiftUI
+
+struct ForwardAddView: View {
+ let domainName: String
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var from = ""
+ @State private var to = ""
+
+ var body: some View {
+ Form {
+ Section {
+ TextField("From", text: $from)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("To", text: $to)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ .autocorrectionDisabled()
+ } header: {
+ Text("Forward")
+ } footer: {
+ Text("Creates \(trimmedFrom)@\(domainName) -> \(trimmedTo)")
+ }
+
+ Section {
+ Button("Save") {
+ Task {
+ await save()
+ }
+ }
+ .disabled(viewModel.isSaving || !canSubmit)
+ }
+ }
+ .navigationTitle("Add Forward")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isSaving {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ .disabled(viewModel.isSaving)
+ }
+ }
+ .interactiveDismissDisabled(viewModel.isSaving)
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.mutationErrorMessage ?? "")
+ }
+ }
+
+ private var trimmedFrom: String {
+ from.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var trimmedTo: String {
+ to.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var canSubmit: Bool {
+ !trimmedFrom.isEmpty && !trimmedTo.isEmpty
+ }
+
+ private func save() async {
+ guard !viewModel.isSaving, canSubmit else {
+ return
+ }
+
+ let forward = EmailForward(domain: domainName, from: trimmedFrom, to: trimmedTo)
+
+ debugLog("Creating forward \(forward.from)@\(forward.domain) -> \(forward.to)")
+ do {
+ try await viewModel.addForward(forward, client: client)
+ debugLog("Created forward \(forward.from)@\(forward.domain) -> \(forward.to)")
+ dismiss()
+ } catch is CancellationError {
+ debugLog("Create cancelled for \(forward.from)@\(forward.domain) -> \(forward.to)")
+ return
+ } catch {
+ debugLog("Create failed for \(forward.from)@\(forward.domain) -> \(forward.to): \(error.localizedDescription)")
+ return
+ }
+ }
+
+ private var mutationErrorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.mutationErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.dismissMutationError()
+ }
+ }
+ )
+ }
+
+ private func debugLog(_ message: String) {
+ #if DEBUG
+ debugPrint("[ForwardAddView]", message)
+ #endif
+ }
+}
diff --git a/Rune/Views/Domains/ForwardListView.swift b/Rune/Views/Domains/ForwardListView.swift
new file mode 100644
index 0000000..4e20ab2
--- /dev/null
+++ b/Rune/Views/Domains/ForwardListView.swift
@@ -0,0 +1,171 @@
+import SwiftUI
+
+struct ForwardListView: View {
+ let domainName: String
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @State private var showingAddForward = false
+ @State private var forwardPendingDeletion: EmailForward?
+
+ var body: some View {
+ List {
+ if let errorMessage = viewModel.forwardsErrorMessage {
+ Section {
+ InlineErrorView(message: errorMessage, retryTitle: "Retry Forwards") {
+ Task {
+ await viewModel.loadForwards(for: domainName, client: client)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+ }
+ }
+
+ if viewModel.isLoadingForwards && viewModel.forwards.isEmpty {
+ Section {
+ HStack {
+ Spacer()
+ ProgressView("Loading Forwards")
+ Spacer()
+ }
+ }
+ } else if viewModel.forwards.isEmpty {
+ Section {
+ ContentUnavailableView(
+ "No Forwards",
+ systemImage: "envelope",
+ description: Text("No email forwards are configured for this domain.")
+ )
+ }
+ } else {
+ ForEach(viewModel.forwards) { forward in
+ VStack(alignment: .leading, spacing: 4) {
+ Text("@\(forward.from)")
+ .font(.headline)
+ Text(forward.to)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+ .swipeActions {
+ Button("Delete", role: .destructive) {
+ guard !viewModel.isSaving else { return }
+ forwardPendingDeletion = forward
+ }
+ }
+ .contextMenu {
+ Button("Delete Forward", role: .destructive) {
+ forwardPendingDeletion = forward
+ }
+ }
+ .disabled(viewModel.isSaving)
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Forwards")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ Button {
+ showingAddForward = true
+ } label: {
+ Label("Add Forward", systemImage: "plus")
+ }
+ .disabled(viewModel.isSaving)
+ }
+ .sheet(isPresented: $showingAddForward) {
+ NavigationStack {
+ ForwardAddView(domainName: domainName, viewModel: viewModel, client: client)
+ }
+ }
+ .task {
+ debugLog("Loading forwards for \(domainName)")
+ await viewModel.loadForwards(for: domainName, client: client)
+ debugLog("Loaded \(viewModel.forwards.count) forwards for \(domainName)")
+ }
+ .refreshable {
+ debugLog("Refreshing forwards for \(domainName)")
+ await viewModel.loadForwards(for: domainName, client: client)
+ debugLog("Refresh complete with \(viewModel.forwards.count) forwards for \(domainName)")
+ }
+ .overlay(alignment: .top) {
+ if viewModel.isLoadingForwards && !viewModel.forwards.isEmpty {
+ ProgressView()
+ .padding(.top, 8)
+ }
+ }
+ .alert(deleteAlertTitle, isPresented: deleteBinding) {
+ Button("Delete Forward", role: .destructive) {
+ guard let forwardPendingDeletion else { return }
+ Task {
+ await delete(forwardPendingDeletion)
+ }
+ }
+ Button("Cancel", role: .cancel) {
+ forwardPendingDeletion = nil
+ }
+ } message: {
+ Text("Delete the forward from \(forwardPendingDeletion?.from ?? "") to \(forwardPendingDeletion?.to ?? "")?")
+ }
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.mutationErrorMessage ?? "")
+ }
+ }
+
+ private func delete(_ forward: EmailForward) async {
+ guard !viewModel.isSaving else {
+ return
+ }
+
+ debugLog("Deleting forward \(forward.from)@\(forward.domain) -> \(forward.to)")
+ do {
+ try await viewModel.removeForward(forward, client: client)
+ debugLog("Deleted forward \(forward.from)@\(forward.domain) -> \(forward.to)")
+ forwardPendingDeletion = nil
+ } catch is CancellationError {
+ debugLog("Delete cancelled for \(forward.from)@\(forward.domain) -> \(forward.to)")
+ return
+ } catch {
+ debugLog("Delete failed for \(forward.from)@\(forward.domain) -> \(forward.to): \(error.localizedDescription)")
+ return
+ }
+ }
+
+ private var deleteAlertTitle: String {
+ guard let forwardPendingDeletion else {
+ return ""
+ }
+
+ return "Delete forward \(forwardPendingDeletion.from)@\(domainName)?"
+ }
+
+ private var deleteBinding: Binding<Bool> {
+ Binding(
+ get: { forwardPendingDeletion != nil },
+ set: { newValue in
+ if !newValue {
+ forwardPendingDeletion = nil
+ }
+ }
+ )
+ }
+
+ private var mutationErrorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.mutationErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.dismissMutationError()
+ }
+ }
+ )
+ }
+
+ private func debugLog(_ message: String) {
+ #if DEBUG
+ debugPrint("[ForwardListView]", message)
+ #endif
+ }
+}
diff --git a/Rune/Views/Domains/RecordAddView.swift b/Rune/Views/Domains/RecordAddView.swift
index 8dfa81d..2cc5dde 100644
--- a/Rune/Views/Domains/RecordAddView.swift
+++ b/Rune/Views/Domains/RecordAddView.swift
@@ -8,7 +8,6 @@ struct RecordAddView: View {
@Environment(\.dismiss) private var dismiss
@State private var draft = DNSRecordDraft()
- @State private var localErrorMessage: String?
var body: some View {
Form {
DNSRecordFormSections(draft: $draft)
@@ -24,44 +23,54 @@ struct RecordAddView: View {
}
.navigationTitle("Add Record")
.navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isSaving {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) {
dismiss()
}
+ .disabled(viewModel.isSaving)
}
}
+ .interactiveDismissDisabled(viewModel.isSaving)
.onChange(of: draft.type) { oldValue, newValue in
guard oldValue != newValue else { return }
draft.resetTypeSpecificFields()
}
- .alert("API Error", isPresented: localErrorBinding) {
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
- Text(localErrorMessage ?? "")
+ Text(viewModel.mutationErrorMessage ?? "")
}
}
private func save() async {
+ guard !viewModel.isSaving else {
+ return
+ }
+
do {
try await viewModel.addRecord(for: domainName, draft: draft, client: client)
+ draft = DNSRecordDraft()
dismiss()
} catch is CancellationError {
return
} catch {
- if (error as? URLError)?.code == .cancelled {
- return
- }
- localErrorMessage = error.localizedDescription
+ return
}
}
- private var localErrorBinding: Binding<Bool> {
+ private var mutationErrorBinding: Binding<Bool> {
Binding(
- get: { localErrorMessage != nil },
+ get: { viewModel.mutationErrorMessage != nil },
set: { newValue in
if !newValue {
- localErrorMessage = nil
+ viewModel.dismissMutationError()
}
}
)
@@ -94,8 +103,12 @@ struct DNSRecordFormSections: View {
if draft.type.usesTTL {
Section("TTL") {
- TextField("TTL", text: $draft.ttl)
- .keyboardType(.numberPad)
+ Picker("TTL", selection: $draft.ttlSeconds) {
+ ForEach(draft.ttlOptions) { option in
+ Text(option.isCustom ? "Custom (\(option.label))" : option.label)
+ .tag(option.seconds)
+ }
+ }
}
}
diff --git a/Rune/Views/Domains/RecordEditView.swift b/Rune/Views/Domains/RecordEditView.swift
index e0c0fde..fceaaba 100644
--- a/Rune/Views/Domains/RecordEditView.swift
+++ b/Rune/Views/Domains/RecordEditView.swift
@@ -10,7 +10,6 @@ struct RecordEditView: View {
@State private var draft: DNSRecordDraft
@State private var showingDeleteConfirmation = false
- @State private var localErrorMessage: String?
init(domainName: String, record: DNSRecord, viewModel: DomainViewModel, client: NjallaClient) {
self.domainName = domainName
@@ -35,71 +34,85 @@ struct RecordEditView: View {
Section {
Button("Delete Record", role: .destructive) {
+ guard !viewModel.isSaving else { return }
showingDeleteConfirmation = true
}
.foregroundStyle(.red)
+ .disabled(viewModel.isSaving)
}
}
.navigationTitle(record.name)
.navigationBarTitleDisplayMode(.inline)
+ .overlay {
+ if viewModel.isSaving {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ .interactiveDismissDisabled(viewModel.isSaving)
.onChange(of: draft.type) { oldValue, newValue in
guard oldValue != newValue else { return }
draft.resetTypeSpecificFields()
}
- .confirmationDialog(
- "Delete \(record.type) record \(record.name)?",
- isPresented: $showingDeleteConfirmation,
- titleVisibility: .visible
- ) {
+ .alert(deleteAlertTitle, isPresented: $showingDeleteConfirmation) {
Button("Delete Record", role: .destructive) {
Task {
await deleteRecord()
}
}
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("This action cannot be undone.")
}
- .alert("API Error", isPresented: localErrorBinding) {
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
- Text(localErrorMessage ?? "")
+ Text(viewModel.mutationErrorMessage ?? "")
}
}
private func save() async {
+ guard !viewModel.isSaving else {
+ return
+ }
+
do {
try await viewModel.editRecord(for: domainName, recordID: record.id, draft: draft, client: client)
dismiss()
} catch is CancellationError {
return
} catch {
- if (error as? URLError)?.code == .cancelled {
- return
- }
- localErrorMessage = error.localizedDescription
+ return
}
}
private func deleteRecord() async {
+ guard !viewModel.isSaving else {
+ return
+ }
+
do {
try await viewModel.removeRecord(record, client: client)
dismiss()
} catch is CancellationError {
return
} catch {
- if (error as? URLError)?.code == .cancelled {
- return
- }
- localErrorMessage = error.localizedDescription
+ return
}
}
- private var localErrorBinding: Binding<Bool> {
+ private var mutationErrorBinding: Binding<Bool> {
Binding(
- get: { localErrorMessage != nil },
+ get: { viewModel.mutationErrorMessage != nil },
set: { newValue in
if !newValue {
- localErrorMessage = nil
+ viewModel.dismissMutationError()
}
}
)
}
+
+ private var deleteAlertTitle: String {
+ "Delete \(record.type) record \(record.name)?"
+ }
}
diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift
index 67a4140..6c40ab0 100644
--- a/Rune/Views/Domains/RecordListView.swift
+++ b/Rune/Views/Domains/RecordListView.swift
@@ -8,22 +8,45 @@ struct RecordListView: View {
@State private var showingAddRecord = false
var body: some View {
- Group {
+ List {
+ if let errorMessage = viewModel.recordsErrorMessage {
+ Section {
+ InlineErrorView(message: errorMessage, retryTitle: "Retry Records") {
+ Task {
+ await viewModel.loadRecords(for: domainName, client: client)
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+ }
+ }
+
if viewModel.isLoadingRecords && viewModel.records.isEmpty {
- ProgressView()
+ Section {
+ HStack {
+ Spacer()
+ ProgressView("Loading Records")
+ Spacer()
+ }
+ }
} else if viewModel.records.isEmpty {
- ContentUnavailableView("No Records", systemImage: "list.bullet", description: Text("No DNS records for this domain."))
+ Section {
+ ContentUnavailableView(
+ "No Records",
+ systemImage: "list.bullet",
+ description: Text("No DNS records for this domain yet. Add a record to get started.")
+ )
+ }
} else {
- List(viewModel.records) { record in
+ ForEach(viewModel.records) { record in
NavigationLink {
RecordEditView(domainName: domainName, record: record, viewModel: viewModel, client: client)
} label: {
RecordRow(record: record)
}
}
- .listStyle(.insetGrouped)
}
}
+ .listStyle(.insetGrouped)
.navigationTitle("DNS Records")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -41,22 +64,34 @@ struct RecordListView: View {
.task {
await viewModel.loadRecords(for: domainName, client: client)
}
+ .onAppear {
+ viewModel.startAutoRefreshRecords(for: domainName, client: client)
+ }
+ .onDisappear {
+ viewModel.stopAutoRefreshRecords()
+ }
.refreshable {
await viewModel.loadRecords(for: domainName, client: client)
}
- .alert("API Error", isPresented: errorBinding) {
+ .overlay(alignment: .top) {
+ if viewModel.isLoadingRecords && !viewModel.records.isEmpty {
+ ProgressView()
+ .padding(.top, 8)
+ }
+ }
+ .alert("Request Failed", isPresented: mutationErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
- Text(viewModel.errorMessage ?? "")
+ Text(viewModel.mutationErrorMessage ?? "")
}
}
- private var errorBinding: Binding<Bool> {
+ private var mutationErrorBinding: Binding<Bool> {
Binding(
- get: { viewModel.errorMessage != nil },
+ get: { viewModel.mutationErrorMessage != nil },
set: { newValue in
if !newValue {
- viewModel.errorMessage = nil
+ viewModel.dismissMutationError()
}
}
)