Allow milestones to be closed and reopened
This commit is contained in:
15
TESTING.md
15
TESTING.md
@@ -303,13 +303,16 @@ xcrun simctl launch booted de.rfc1437.gotcha
|
|||||||
- [ ] Open a milestone and verify every assigned issue and pull request is
|
- [ ] Open a milestone and verify every assigned issue and pull request is
|
||||||
listed; tapping either opens its normal detail.
|
listed; tapping either opens its normal detail.
|
||||||
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
|
- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls,
|
||||||
title and multiline description fields, and an optional inline date
|
title and multiline description fields, a **Closed** switch that defaults
|
||||||
picker. Save is disabled for an empty or whitespace-only title. Create
|
off, and an optional inline date picker. Save is disabled for an empty or
|
||||||
milestones both without and with a due date and verify each appears.
|
whitespace-only title. Create milestones both without and with a due date
|
||||||
|
and verify each appears open.
|
||||||
- [ ] Tap the pencil button on a milestone. Change its title and description, add and
|
- [ ] Tap the pencil button on a milestone. Change its title and description, add and
|
||||||
change its due date, then turn Due Date off and save to clear it. Refresh
|
change its due date, then turn Due Date off and save to clear it. Turn
|
||||||
and relaunch after each save to verify persistence; Cancel leaves every
|
**Closed** on and save; verify the list and detail show the closed state.
|
||||||
value unchanged. Exercise the form with the keyboard, Dynamic Type, and
|
Edit it again, turn **Closed** off, and verify it reopens. Refresh and
|
||||||
|
relaunch after each save to verify persistence; Cancel leaves every value
|
||||||
|
unchanged. Exercise the form with the keyboard, Dynamic Type, and
|
||||||
VoiceOver labels.
|
VoiceOver labels.
|
||||||
- [ ] Repository issue lists and issue details show the assigned milestone with
|
- [ ] Repository issue lists and issue details show the assigned milestone with
|
||||||
a flag icon instead of a text label.
|
a flag icon instead of a text label.
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ pub async fn save_milestone(
|
|||||||
title: draft.title,
|
title: draft.title,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
due_on: draft.due_date.map(api_date),
|
due_on: draft.due_date.map(api_date),
|
||||||
|
state: if draft.closed { "closed" } else { "open" }.into(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ pub struct MilestoneDraft {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub due_date: Option<i64>,
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_status() -> String {
|
pub fn open_status() -> String {
|
||||||
|
|||||||
@@ -527,12 +527,10 @@ impl GotchaCore {
|
|||||||
owner: String,
|
owner: String,
|
||||||
repository: String,
|
repository: String,
|
||||||
id: Option<i64>,
|
id: Option<i64>,
|
||||||
title: String,
|
draft: MilestoneEditorPage,
|
||||||
description: String,
|
|
||||||
due_date: Option<i64>,
|
|
||||||
) -> Result<i64, GotchaError> {
|
) -> Result<i64, GotchaError> {
|
||||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||||
let title = title.trim();
|
let title = draft.title.trim();
|
||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
return Err("Enter a milestone title.".into());
|
return Err("Enter a milestone title.".into());
|
||||||
}
|
}
|
||||||
@@ -546,8 +544,9 @@ impl GotchaCore {
|
|||||||
id,
|
id,
|
||||||
MilestoneDraft {
|
MilestoneDraft {
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
description,
|
description: draft.description,
|
||||||
due_date,
|
due_date: draft.due_date,
|
||||||
|
closed: draft.closed,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ pub struct MilestoneEditorPage {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub due_date: Option<i64>,
|
pub due_date: Option<i64>,
|
||||||
|
pub closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, uniffi::Record)]
|
#[derive(Clone, uniffi::Record)]
|
||||||
@@ -424,6 +425,10 @@ pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> MilestoneE
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|milestone| milestone.due_on.as_deref())
|
.and_then(|milestone| milestone.due_on.as_deref())
|
||||||
.and_then(parse_api_date),
|
.and_then(parse_api_date),
|
||||||
|
closed: milestone
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|milestone| milestone.state.as_deref())
|
||||||
|
== Some("closed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1628,13 +1633,17 @@ mod tests {
|
|||||||
title: Some("Version 1".into()),
|
title: Some("Version 1".into()),
|
||||||
description: Some("Ship it".into()),
|
description: Some("Ship it".into()),
|
||||||
due_on: Some("2024-02-29T12:34:56Z".into()),
|
due_on: Some("2024-02-29T12:34:56Z".into()),
|
||||||
|
state: Some("closed".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}));
|
}));
|
||||||
assert_eq!(page.title, "Version 1");
|
assert_eq!(page.title, "Version 1");
|
||||||
assert_eq!(page.description, "Ship it");
|
assert_eq!(page.description, "Ship it");
|
||||||
assert_eq!(page.due_date, Some(1_709_164_800));
|
assert_eq!(page.due_date, Some(1_709_164_800));
|
||||||
|
assert!(page.closed);
|
||||||
|
|
||||||
assert_eq!(milestone_editor_page(None).due_date, None);
|
let new = milestone_editor_page(None);
|
||||||
|
assert_eq!(new.due_date, None);
|
||||||
|
assert!(!new.closed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ pub struct MilestoneDraft {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub due_on: Option<String>,
|
pub due_on: Option<String>,
|
||||||
|
pub state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ impl Client {
|
|||||||
"milestone title must not be empty".into(),
|
"milestone title must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !matches!(draft.state.as_str(), "open" | "closed") {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"milestone state must be open or closed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let endpoint = match id {
|
let endpoint = match id {
|
||||||
Some(id) if id > 0 => format!(
|
Some(id) if id > 0 => format!(
|
||||||
"repos/{}/{}/milestones/{id}",
|
"repos/{}/{}/milestones/{id}",
|
||||||
@@ -194,6 +199,7 @@ impl Client {
|
|||||||
title: draft.title,
|
title: draft.title,
|
||||||
description: draft.description,
|
description: draft.description,
|
||||||
due_on: draft.due_on,
|
due_on: draft.due_on,
|
||||||
|
state: draft.state,
|
||||||
});
|
});
|
||||||
self.execute(request)
|
self.execute(request)
|
||||||
.await?
|
.await?
|
||||||
@@ -219,6 +225,7 @@ struct MilestoneRequest {
|
|||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
due_on: Option<String>,
|
due_on: Option<String>,
|
||||||
|
state: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -231,8 +238,10 @@ mod tests {
|
|||||||
title: "Release".into(),
|
title: "Release".into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
due_on: None,
|
due_on: None,
|
||||||
|
state: "closed".into(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(value.get("due_on").unwrap().is_null());
|
assert!(value.get("due_on").unwrap().is_null());
|
||||||
|
assert_eq!(value["state"], "closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws
|
func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws
|
||||||
|
|
||||||
func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?) async throws -> Int64
|
func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage) async throws -> Int64
|
||||||
|
|
||||||
func selectServer(index: UInt32) throws
|
func selectServer(index: UInt32) throws
|
||||||
|
|
||||||
@@ -1133,12 +1133,12 @@ open func saveIssueComment(owner: String, repository: String, number: Int64, com
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
open func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?)async throws -> Int64 {
|
open func saveMilestone(owner: String, repository: String, id: Int64?, draft: MilestoneEditorPage)async throws -> Int64 {
|
||||||
return
|
return
|
||||||
try await uniffiRustCallAsync(
|
try await uniffiRustCallAsync(
|
||||||
rustFutureFunc: {
|
rustFutureFunc: {
|
||||||
uniffi_gotcha_core_fn_method_gotchacore_save_milestone(
|
uniffi_gotcha_core_fn_method_gotchacore_save_milestone(
|
||||||
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterString.lower(title),FfiConverterString.lower(description),FfiConverterOptionInt64.lower(dueDate)
|
self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterTypeMilestoneEditorPage_lower(draft)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
pollFunc: ffi_gotcha_core_rust_future_poll_i64,
|
pollFunc: ffi_gotcha_core_rust_future_poll_i64,
|
||||||
@@ -2571,13 +2571,15 @@ public struct MilestoneEditorPage: Equatable, Hashable {
|
|||||||
public var title: String
|
public var title: String
|
||||||
public var description: String
|
public var description: String
|
||||||
public var dueDate: Int64?
|
public var dueDate: Int64?
|
||||||
|
public var closed: Bool
|
||||||
|
|
||||||
// Default memberwise initializers are never public by default, so we
|
// Default memberwise initializers are never public by default, so we
|
||||||
// declare one manually.
|
// declare one manually.
|
||||||
public init(title: String, description: String, dueDate: Int64?) {
|
public init(title: String, description: String, dueDate: Int64?, closed: Bool) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.dueDate = dueDate
|
self.dueDate = dueDate
|
||||||
|
self.closed = closed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2598,7 +2600,8 @@ public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer {
|
|||||||
try MilestoneEditorPage(
|
try MilestoneEditorPage(
|
||||||
title: FfiConverterString.read(from: &buf),
|
title: FfiConverterString.read(from: &buf),
|
||||||
description: FfiConverterString.read(from: &buf),
|
description: FfiConverterString.read(from: &buf),
|
||||||
dueDate: FfiConverterOptionInt64.read(from: &buf)
|
dueDate: FfiConverterOptionInt64.read(from: &buf),
|
||||||
|
closed: FfiConverterBool.read(from: &buf)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2606,6 +2609,7 @@ public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer {
|
|||||||
FfiConverterString.write(value.title, into: &buf)
|
FfiConverterString.write(value.title, into: &buf)
|
||||||
FfiConverterString.write(value.description, into: &buf)
|
FfiConverterString.write(value.description, into: &buf)
|
||||||
FfiConverterOptionInt64.write(value.dueDate, into: &buf)
|
FfiConverterOptionInt64.write(value.dueDate, into: &buf)
|
||||||
|
FfiConverterBool.write(value.closed, into: &buf)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4354,7 +4358,7 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 33075) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 9828) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
|
if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr
|
|||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE
|
||||||
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer title, RustBuffer description, RustBuffer due_date
|
uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer draft
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
|
|||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let titleField = UITextField()
|
private let titleField = UITextField()
|
||||||
private let descriptionView = UITextView()
|
private let descriptionView = UITextView()
|
||||||
|
private let closedSwitch = UISwitch()
|
||||||
private let dueSwitch = UISwitch()
|
private let dueSwitch = UISwitch()
|
||||||
private let duePicker = UIDatePicker()
|
private let duePicker = UIDatePicker()
|
||||||
private let spinner = UIActivityIndicatorView(style: .medium)
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
||||||
@@ -88,6 +89,14 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
|
|||||||
descriptionView.autocapitalizationType = .sentences
|
descriptionView.autocapitalizationType = .sentences
|
||||||
descriptionView.accessibilityLabel = "Milestone description"
|
descriptionView.accessibilityLabel = "Milestone description"
|
||||||
|
|
||||||
|
let closedLabel = UILabel()
|
||||||
|
closedLabel.text = "Closed"
|
||||||
|
closedLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
|
closedLabel.adjustsFontForContentSizeCategory = true
|
||||||
|
let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch])
|
||||||
|
closedRow.alignment = .center
|
||||||
|
closedSwitch.accessibilityLabel = "Closed milestone"
|
||||||
|
|
||||||
let dueLabel = UILabel()
|
let dueLabel = UILabel()
|
||||||
dueLabel.text = "Due Date"
|
dueLabel.text = "Due Date"
|
||||||
dueLabel.font = .preferredFont(forTextStyle: .body)
|
dueLabel.font = .preferredFont(forTextStyle: .body)
|
||||||
@@ -103,6 +112,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
|
|||||||
stack.addArrangedSubview(titleField)
|
stack.addArrangedSubview(titleField)
|
||||||
stack.addArrangedSubview(descriptionLabel)
|
stack.addArrangedSubview(descriptionLabel)
|
||||||
stack.addArrangedSubview(descriptionView)
|
stack.addArrangedSubview(descriptionView)
|
||||||
|
stack.addArrangedSubview(closedRow)
|
||||||
stack.addArrangedSubview(dueRow)
|
stack.addArrangedSubview(dueRow)
|
||||||
stack.addArrangedSubview(duePicker)
|
stack.addArrangedSubview(duePicker)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@@ -129,6 +139,7 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
|
|||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
titleField.text = page.title
|
titleField.text = page.title
|
||||||
descriptionView.text = page.description
|
descriptionView.text = page.description
|
||||||
|
closedSwitch.isOn = page.closed
|
||||||
if let timestamp = page.dueDate {
|
if let timestamp = page.dueDate {
|
||||||
dueSwitch.isOn = true
|
dueSwitch.isOn = true
|
||||||
duePicker.date = localDate(forUTCTimestamp: timestamp)
|
duePicker.date = localDate(forUTCTimestamp: timestamp)
|
||||||
@@ -167,9 +178,12 @@ final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate
|
|||||||
owner: owner,
|
owner: owner,
|
||||||
repository: repository,
|
repository: repository,
|
||||||
id: id,
|
id: id,
|
||||||
title: titleField.text ?? "",
|
draft: MilestoneEditorPage(
|
||||||
description: descriptionView.text ?? "",
|
title: titleField.text ?? "",
|
||||||
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil
|
description: descriptionView.text ?? "",
|
||||||
|
dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil,
|
||||||
|
closed: closedSwitch.isOn
|
||||||
|
)
|
||||||
)
|
)
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
saved(id)
|
saved(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user