initial commit
This commit is contained in:
parent
48f031d4fe
commit
e862b43281
6
Cargo.toml
Normal file
6
Cargo.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "scrumtask-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
112
README.md
Normal file
112
README.md
Normal file
@ -0,0 +1,112 @@
|
||||
# Jira Clone
|
||||
|
||||
## IMPORTANT NOTE
|
||||
|
||||
___Please read the project description thoroughly BEFORE getting started, especially the FAQs section.___
|
||||
|
||||
___Re-visit the project description multiple times DURING your design and development process, to ensure you're meeting the project requirements.___
|
||||
|
||||
## Problem Statement
|
||||
We will build a Jira clone for the terminal.
|
||||
|
||||
We will build two primary features in Jira:
|
||||
1. Epic CRUD
|
||||
2. Story CRUD
|
||||
|
||||

|
||||
|
||||
NOTE: If you're not familiar with Jira, create an [Atlassian account online](https://www.atlassian.com/software/jira) and try it out, or watch a YouTube tutorial.
|
||||
|
||||
## Objective
|
||||
In this project, we aim to learn and practice the following:
|
||||
* Building CLI apps in Rust
|
||||
* Reading & writing to disk
|
||||
* Using third-party crates (like `serde`, `anyhow`, `itertools`, etc.)
|
||||
* Writing testable code
|
||||
* Organizing code using modules
|
||||
* Navigating and contributing to an existing code base
|
||||
|
||||
## Terminologies
|
||||
|
||||
__Jira, Epic & Story__
|
||||
|
||||
Jira is an industry-standard tool for tracking progress of (not limited to) software projects. An Epic is usually used for entire initiatives, while a Story is a smaller unit of work with more specific instructions.
|
||||
|
||||
__Model__
|
||||
Models describe how information is organized, transmitted or stored.
|
||||
|
||||
__Database / Storage / Persistence__
|
||||
|
||||
Database, storage and persistence are often used interchangeably. They represent the component we use to store and access information for the application.
|
||||
|
||||
__CRUD__
|
||||
|
||||
CRUD stands for actions of creation, read, update & deletion.
|
||||
|
||||
## Stages
|
||||
The project is split into multiple stages, each containing steps. Each step has a partially built Rust program with TODO items for you to finish. The TODO items are either `TODO` comments or `todo!()` macros. Most steps will include failing tests which you need to make pass by completing the TODO items.
|
||||
|
||||
Each stage will have it's own README file with more details.
|
||||
|
||||
To complete the project go through each stage and step in order.
|
||||
|
||||
If you get stuck look at the next step for the solution to the current step.
|
||||
|
||||
You can find the final project in the `Solution` folder, one directory up.
|
||||
|
||||
## Recommendations
|
||||
Here's a list of recommended action items to do during and after the development, to help you more effectively build the project and learn from the project.
|
||||
|
||||
During Development:
|
||||
* You can either create your own Rust project and copy over the code in each step or clone this repo and finish the steps directly in this repo.
|
||||
* Check the project description/requirements to make sure you are building what is asked of you.
|
||||
* If you get stuck, ask for help in the Discord server or look at the next step for the solution to the current step.
|
||||
* Refactor as you implement. Keep your code clean and compartmentalized. Doing so makes debugging exponentially easier, as your implementation grows.
|
||||
* Make sure your code compiles and all tests are passing (if applicable) before moving on to the next step.
|
||||
|
||||
After Development:
|
||||
* Run through the provided manual test cases (included in the Stage 3 README), and fix any bugs! You are almost done, so finish the project strong!
|
||||
* Post your completed project on GitHub. You're a Rust developer now!
|
||||
* Showcase your project to your friends and family (at the very least, to others in the Let's Get Rusty community)!
|
||||
* After completing the project feel free to modify the program by changing the architecture, adding features, etc. This will help you make the project your own and better internalize the lessons you've learned.
|
||||
|
||||
## FAQs
|
||||
|
||||
__Will there a template to build the project on top of?__
|
||||
|
||||
Yes. Each step has a partially built Rust project for you to finish. Stages and steps build on top of each other until you have a completed project.
|
||||
|
||||
__Should my implementation look exactly like the solution?__
|
||||
|
||||
Your code may differ from the solution, as long as your code compiles, tests are passing, and the program works as intended you are in good shape. Also after completing the project feel free to modify the program by changing the architecture, adding features, etc.
|
||||
|
||||
__What if I get stuck and have questions?__
|
||||
|
||||
If you haven't already, join our Discord server and the exclusive Bootcamp channels as instructed on the Home page of the Bootcamp. Fire away your questions and find project partners over there!
|
||||
|
||||
__NOTE:__ `If you don't know how to implement a TODO item, look at the corresponding test to see what is expected.`
|
||||
|
||||
## Stages Overview
|
||||
The project is split into multiple stages. Please keep in mind, some implementation choices are made to minimize the scope of the project, so we can focus on the learning and implementing Rust related concepts. Here's an overview of the stages:
|
||||
|
||||
### Stage 1
|
||||
|
||||
__Database and Models__
|
||||
|
||||
In this state we will design our models, persist them in a JSON file, and build CRUD operations for Epics and Stories.
|
||||
|
||||
### Stage 2
|
||||
|
||||
__UI (pages and prompts)__
|
||||
|
||||
In this state we will implement the user interface for our application.
|
||||
|
||||
### Stage 3
|
||||
|
||||
__Navigation and Program Loop__
|
||||
|
||||
In this stage we will hook up our persistent storage component to the UI. We will also implement navigation and the program loop.
|
||||
|
||||
## Get Started!
|
||||
|
||||
Get started by navigating to Stage 1 and reading the README!
|
26
data/db.json
Normal file
26
data/db.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"last_item_id": 3,
|
||||
"epics": {
|
||||
"1": {
|
||||
"name": "Epic - Project 1",
|
||||
"description": "This is Project 1 for the Bootcamp",
|
||||
"status": "InProgress",
|
||||
"stories": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"2": {
|
||||
"name": "Story - Project 1 Solution",
|
||||
"description": "Please provide full implement for Project 1",
|
||||
"status": "Closed"
|
||||
},
|
||||
"3": {
|
||||
"name": "Story - Project 1 README",
|
||||
"description": "Please create README file for Project 1",
|
||||
"status": "InProgress"
|
||||
}
|
||||
}
|
||||
}
|
49
flake.lock
generated
Normal file
49
flake.lock
generated
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"rust-overlay",
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1722738111,
|
||||
"narHash": "sha256-cWD5pCs9AYb+512/yCx9D0Pl5KcmyuXHeJpsDw/D1vs=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "27ec296d93cb4b2d03e8cbd019b1b4cde8c34280",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
62
flake.nix
Normal file
62
flake.nix
Normal file
@ -0,0 +1,62 @@
|
||||
{
|
||||
description = "Example Rust development environment for Zero to Nix";
|
||||
|
||||
# Flake inputs
|
||||
inputs = {
|
||||
# nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/*.tar.gz";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay"; # A helper for Rust + Nix
|
||||
# cargo2nix.url = "github:cargo2nix/cargo2nix/";
|
||||
nixpkgs.follows = "rust-overlay/nixpkgs";
|
||||
};
|
||||
|
||||
# Flake outputs
|
||||
outputs = { self, nixpkgs, rust-overlay}:
|
||||
let
|
||||
# Overlays enable you to customize the Nixpkgs attribute set
|
||||
overlays = [
|
||||
# Makes a `rust-bin` attribute available in Nixpkgs
|
||||
(import rust-overlay)
|
||||
# Provides a `rustToolchain` attribute for Nixpkgs that we can use to
|
||||
# create a Rust environment
|
||||
(self: super: {
|
||||
rustToolchain = super.rust-bin.stable.latest.default;
|
||||
})
|
||||
];
|
||||
|
||||
# Systems supported
|
||||
allSystems = [
|
||||
"x86_64-linux" # 64-bit Intel/AMD Linux
|
||||
"aarch64-linux" # 64-bit ARM Linux
|
||||
"x86_64-darwin" # 64-bit Intel macOS
|
||||
"aarch64-darwin" # 64-bit ARM macOS
|
||||
];
|
||||
|
||||
# rustTarget = nixpkgs.pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
||||
# extensions = [ "rust-src" "rustup" "rust-analyzer" "rust-std" ];
|
||||
# targets = [ "x86_64-unknown-linux-gnu" ];
|
||||
# });
|
||||
|
||||
# Helper to provide system-specific attributes
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
|
||||
pkgs = import nixpkgs { inherit overlays system; };
|
||||
});
|
||||
in
|
||||
{
|
||||
# Development environment output
|
||||
devShells = forAllSystems ({ pkgs }: {
|
||||
default = pkgs.mkShell {
|
||||
# shellHook = ''
|
||||
# rustup target add wasm32-unknown-unknown
|
||||
# '';
|
||||
# The Nix packages provided in the environment
|
||||
packages = (with pkgs; [
|
||||
# The package provided by our custom overlay. Includes cargo, Clippy, cargo-fmt,
|
||||
# rustdoc, rustfmt, and other tools.
|
||||
rust-analyzer
|
||||
clippy
|
||||
rustToolchain
|
||||
]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ libiconv ]);
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
BIN
jira-cli.gif
Normal file
BIN
jira-cli.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 914 KiB |
99
src/db.rs
Normal file
99
src/db.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::models::{DBState, Epic, Story, Status};
|
||||
|
||||
trait Database {
|
||||
fn read_db(&self) -> Result<DBState>;
|
||||
fn write_db(&self, db_state: &DBState) -> Result<()>;
|
||||
}
|
||||
|
||||
struct JSONFileDatabase {
|
||||
pub file_path: String
|
||||
}
|
||||
|
||||
impl Database for JSONFileDatabase {
|
||||
fn read_db(&self) -> Result<DBState> {
|
||||
todo!() // read the content's of self.file_path and deserialize it using serde
|
||||
}
|
||||
|
||||
fn write_db(&self, db_state: &DBState) -> Result<()> {
|
||||
todo!() // serialize db_state to json and store it in self.file_path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod database {
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_db_should_fail_with_invalid_path() {
|
||||
let db = JSONFileDatabase { file_path: "INVALID_PATH".to_owned() };
|
||||
assert_eq!(db.read_db().is_err(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_db_should_fail_with_invalid_json() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
|
||||
let file_contents = r#"{ "last_item_id": 0 epics: {} stories {} }"#;
|
||||
write!(tmpfile, "{}", file_contents).unwrap();
|
||||
|
||||
let db = JSONFileDatabase { file_path: tmpfile.path().to_str()
|
||||
.expect("failed to convert tmpfile path to str").to_string() };
|
||||
|
||||
let result = db.read_db();
|
||||
|
||||
assert_eq!(result.is_err(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_db_should_parse_json_file() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
|
||||
let file_contents = r#"{ "last_item_id": 0, "epics": {}, "stories": {} }"#;
|
||||
write!(tmpfile, "{}", file_contents).unwrap();
|
||||
|
||||
let db = JSONFileDatabase { file_path: tmpfile.path().to_str()
|
||||
.expect("failed to convert tmpfile path to str").to_string() };
|
||||
|
||||
let result = db.read_db();
|
||||
|
||||
assert_eq!(result.is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_db_should_work() {
|
||||
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
|
||||
|
||||
let file_contents = r#"{ "last_item_id": 0, "epics": {}, "stories": {} }"#;
|
||||
write!(tmpfile, "{}", file_contents).unwrap();
|
||||
|
||||
let db = JSONFileDatabase { file_path: tmpfile.path().to_str()
|
||||
.expect("failed to convert tmpfile path to str").to_string() };
|
||||
|
||||
let story = Story { name: "epic 1".to_owned(), description: "epic 1".to_owned(), status: Status::Open };
|
||||
let epic = Epic { name: "epic 1".to_owned(), description: "epic 1".to_owned(), status: Status::Open, stories: vec![2] };
|
||||
|
||||
let mut stories = HashMap::new();
|
||||
stories.insert(2, story);
|
||||
|
||||
let mut epics = HashMap::new();
|
||||
epics.insert(1, epic);
|
||||
|
||||
let state = DBState { last_item_id: 2, epics, stories };
|
||||
|
||||
let write_result = db.write_db(&state);
|
||||
let read_result = db.read_db().unwrap();
|
||||
|
||||
assert_eq!(write_result.is_ok(), true);
|
||||
// TODO: fix this error by deriving the appropriate traits for DBState
|
||||
assert_eq!(read_result, state);
|
||||
}
|
||||
}
|
||||
}
|
9
src/io_utils.rs
Normal file
9
src/io_utils.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use std::io;
|
||||
|
||||
pub fn get_user_input() -> String {
|
||||
let mut user_input = String::new();
|
||||
|
||||
io::stdin().read_line(&mut user_input).unwrap();
|
||||
|
||||
user_input
|
||||
}
|
5
src/main.rs
Normal file
5
src/main.rs
Normal file
@ -0,0 +1,5 @@
|
||||
mod models;
|
||||
|
||||
fn main() {
|
||||
println!("Welcome scrumtask-cli!");
|
||||
}
|
28
src/models.rs
Normal file
28
src/models.rs
Normal file
@ -0,0 +1,28 @@
|
||||
pub enum Status {
|
||||
// TODO: add fields (make sure the fields are public)
|
||||
}
|
||||
|
||||
pub struct Epic {
|
||||
// TODO: add fields (make sure the fields are public)
|
||||
}
|
||||
|
||||
impl Epic {
|
||||
pub fn new(name: String, description: String) -> Self {
|
||||
todo!() // by default the status should be set to open and the stories should be an empty vector
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Story {
|
||||
// TODO: add fields (make sure the fields are public)
|
||||
}
|
||||
|
||||
impl Story {
|
||||
pub fn new(name: String, description: String) -> Self {
|
||||
todo!() // by default the status should be set to open
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DBState {
|
||||
// This struct represents the entire db state which includes the last_item_id, epics, and stories
|
||||
// TODO: add fields (make sure the fields are public)
|
||||
}
|
257
src/navigator.rs
Normal file
257
src/navigator.rs
Normal file
@ -0,0 +1,257 @@
|
||||
use anyhow::{anyhow, Result, Context, Ok};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{ui::{Page, HomePage, EpicDetail, StoryDetail, Prompts}, db::JiraDatabase, models::Action};
|
||||
|
||||
pub struct Navigator {
|
||||
pages: Vec<Box<dyn Page>>,
|
||||
prompts: Prompts,
|
||||
db: Rc<JiraDatabase>
|
||||
}
|
||||
|
||||
impl Navigator {
|
||||
pub fn new(db: Rc<JiraDatabase>) -> Self {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub fn get_current_page(&self) -> Option<&Box<dyn Page>> {
|
||||
todo!() // this should always return the last element in the pages vector
|
||||
}
|
||||
|
||||
pub fn handle_action(&mut self, action: Action) -> Result<()> {
|
||||
match action {
|
||||
Action::NavigateToEpicDetail { epic_id } => {
|
||||
todo!() // create a new EpicDetail instance and add it to the pages vector
|
||||
}
|
||||
Action::NavigateToStoryDetail { epic_id, story_id } => {
|
||||
todo!() // create a new StoryDetail instance and add it to the pages vector
|
||||
}
|
||||
Action::NavigateToPreviousPage => {
|
||||
todo!() // remove the last page from the pages vector
|
||||
}
|
||||
Action::CreateEpic => {
|
||||
todo!() // prompt the user to create a new epic and persist it in the database
|
||||
}
|
||||
Action::UpdateEpicStatus { epic_id } => {
|
||||
todo!() // prompt the user to update status and persist it in the database
|
||||
}
|
||||
Action::DeleteEpic { epic_id } => {
|
||||
todo!() // prompt the user to delete the epic and persist it in the database
|
||||
}
|
||||
Action::CreateStory { epic_id } => {
|
||||
todo!() // prompt the user to create a new story and persist it in the database
|
||||
}
|
||||
Action::UpdateStoryStatus { story_id } => {
|
||||
todo!() // prompt the user to update status and persist it in the database
|
||||
}
|
||||
Action::DeleteStory { epic_id, story_id } => {
|
||||
todo!() // prompt the user to delete the story and persist it in the database
|
||||
}
|
||||
Action::Exit => {
|
||||
todo!() // remove all pages from the pages vector
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Private functions used for testing
|
||||
|
||||
fn get_page_count(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
fn set_prompts(&mut self, prompts: Prompts) {
|
||||
self.prompts = prompts;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{db::test_utils::MockDB, models::{Epic, Status, Story}};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_start_on_home_page() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let nav = Navigator::new(db);
|
||||
|
||||
assert_eq!(nav.get_page_count(), 1);
|
||||
|
||||
let current_page = nav.get_current_page().unwrap();
|
||||
let home_page = current_page.as_any().downcast_ref::<HomePage>();
|
||||
|
||||
assert_eq!(home_page.is_some(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_navigate_pages() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let mut nav = Navigator::new(db);
|
||||
|
||||
nav.handle_action(Action::NavigateToEpicDetail { epic_id: 1 }).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 2);
|
||||
|
||||
let current_page = nav.get_current_page().unwrap();
|
||||
let epic_detail_page = current_page.as_any().downcast_ref::<EpicDetail>();
|
||||
assert_eq!(epic_detail_page.is_some(), true);
|
||||
|
||||
nav.handle_action(Action::NavigateToStoryDetail { epic_id: 1, story_id: 2 }).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 3);
|
||||
|
||||
let current_page = nav.get_current_page().unwrap();
|
||||
let story_detail_page = current_page.as_any().downcast_ref::<StoryDetail>();
|
||||
assert_eq!(story_detail_page.is_some(), true);
|
||||
|
||||
nav.handle_action(Action::NavigateToPreviousPage).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 2);
|
||||
|
||||
let current_page = nav.get_current_page().unwrap();
|
||||
let epic_detail_page = current_page.as_any().downcast_ref::<EpicDetail>();
|
||||
assert_eq!(epic_detail_page.is_some(), true);
|
||||
|
||||
nav.handle_action(Action::NavigateToPreviousPage).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 1);
|
||||
|
||||
let current_page = nav.get_current_page().unwrap();
|
||||
let home_page = current_page.as_any().downcast_ref::<HomePage>();
|
||||
assert_eq!(home_page.is_some(), true);
|
||||
|
||||
nav.handle_action(Action::NavigateToPreviousPage).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 0);
|
||||
|
||||
nav.handle_action(Action::NavigateToPreviousPage).unwrap();
|
||||
assert_eq!(nav.get_page_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_clear_pages_on_exit() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let mut nav = Navigator::new(db);
|
||||
|
||||
nav.handle_action(Action::NavigateToEpicDetail { epic_id: 1 }).unwrap();
|
||||
nav.handle_action(Action::NavigateToStoryDetail { epic_id: 1, story_id: 2 }).unwrap();
|
||||
nav.handle_action(Action::Exit).unwrap();
|
||||
|
||||
assert_eq!(nav.get_page_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_create_epic() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.create_epic = Box::new(|| Epic::new("name".to_owned(), "description".to_owned()));
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::CreateEpic).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.epics.len(), 1);
|
||||
|
||||
let epic = db_state.epics.into_iter().next().unwrap().1;
|
||||
assert_eq!(epic.name, "name".to_owned());
|
||||
assert_eq!(epic.description, "description".to_owned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_update_epic() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.update_status = Box::new(|| Some(Status::InProgress));
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::UpdateEpicStatus { epic_id }).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.epics.get(&epic_id).unwrap().status, Status::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_delete_epic() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.delete_epic = Box::new(|| true);
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::DeleteEpic { epic_id }).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.epics.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_create_story() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.create_story = Box::new(|| Story::new("name".to_owned(), "description".to_owned()));
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::CreateStory { epic_id }).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.stories.len(), 1);
|
||||
|
||||
let story = db_state.stories.into_iter().next().unwrap().1;
|
||||
assert_eq!(story.name, "name".to_owned());
|
||||
assert_eq!(story.description, "description".to_owned());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_update_story() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.update_status = Box::new(|| Some(Status::InProgress));
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::UpdateStoryStatus { story_id }).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.stories.get(&story_id).unwrap().status, Status::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_action_should_handle_delete_story() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let mut nav = Navigator::new(Rc::clone(&db));
|
||||
|
||||
let mut prompts = Prompts::new();
|
||||
prompts.delete_story = Box::new(|| true);
|
||||
|
||||
nav.set_prompts(prompts);
|
||||
|
||||
nav.handle_action(Action::DeleteStory { epic_id, story_id }).unwrap();
|
||||
|
||||
let db_state = db.read_db().unwrap();
|
||||
assert_eq!(db_state.stories.len(), 0);
|
||||
}
|
||||
}
|
3
src/ui/mod.rs
Normal file
3
src/ui/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
mod pages;
|
||||
|
||||
pub use pages::*;
|
281
src/ui/pages/mod.rs
Normal file
281
src/ui/pages/mod.rs
Normal file
@ -0,0 +1,281 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
|
||||
use crate::db::JiraDatabase;
|
||||
use crate::models::Action;
|
||||
|
||||
mod page_helpers;
|
||||
use page_helpers::*;
|
||||
|
||||
pub trait Page {
|
||||
fn draw_page(&self) -> Result<()>;
|
||||
fn handle_input(&self, input: &str) -> Result<Option<Action>>;
|
||||
}
|
||||
|
||||
pub struct HomePage {
|
||||
pub db: Rc<JiraDatabase>
|
||||
}
|
||||
impl Page for HomePage {
|
||||
fn draw_page(&self) -> Result<()> {
|
||||
println!("----------------------------- EPICS -----------------------------");
|
||||
println!(" id | name | status ");
|
||||
|
||||
// TODO: print out epics using get_column_string(). also make sure the epics are sorted by id
|
||||
|
||||
println!();
|
||||
println!();
|
||||
|
||||
println!("[q] quit | [c] create epic | [:id:] navigate to epic");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(&self, input: &str) -> Result<Option<Action>> {
|
||||
todo!() // match against the user input and return the corresponding action. If the user input was invalid return None.
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EpicDetail {
|
||||
pub epic_id: u32,
|
||||
pub db: Rc<JiraDatabase>
|
||||
}
|
||||
|
||||
impl Page for EpicDetail {
|
||||
fn draw_page(&self) -> Result<()> {
|
||||
let db_state = self.db.read_db()?;
|
||||
let epic = db_state.epics.get(&self.epic_id).ok_or_else(|| anyhow!("could not find epic!"))?;
|
||||
|
||||
println!("------------------------------ EPIC ------------------------------");
|
||||
println!(" id | name | description | status ");
|
||||
|
||||
// TODO: print out epic details using get_column_string()
|
||||
|
||||
println!();
|
||||
|
||||
println!("---------------------------- STORIES ----------------------------");
|
||||
println!(" id | name | status ");
|
||||
|
||||
let stories = &db_state.stories;
|
||||
|
||||
// TODO: print out stories using get_column_string(). also make sure the stories are sorted by id
|
||||
|
||||
println!();
|
||||
println!();
|
||||
|
||||
println!("[p] previous | [u] update epic | [d] delete epic | [c] create story | [:id:] navigate to story");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(&self, input: &str) -> Result<Option<Action>> {
|
||||
todo!() // match against the user input and return the corresponding action. If the user input was invalid return None.
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StoryDetail {
|
||||
pub epic_id: u32,
|
||||
pub story_id: u32,
|
||||
pub db: Rc<JiraDatabase>
|
||||
}
|
||||
|
||||
impl Page for StoryDetail {
|
||||
fn draw_page(&self) -> Result<()> {
|
||||
let db_state = self.db.read_db()?;
|
||||
let story = db_state.stories.get(&self.story_id).ok_or_else(|| anyhow!("could not find story!"))?;
|
||||
|
||||
println!("------------------------------ STORY ------------------------------");
|
||||
println!(" id | name | description | status ");
|
||||
|
||||
// TODO: print out story details using get_column_string()
|
||||
|
||||
println!();
|
||||
println!();
|
||||
|
||||
println!("[p] previous | [u] update story | [d] delete story");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(&self, input: &str) -> Result<Option<Action>> {
|
||||
todo!() // match against the user input and return the corresponding action. If the user input was invalid return None.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{db::test_utils::MockDB};
|
||||
use crate::models::{Epic, Story};
|
||||
|
||||
mod home_page {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draw_page_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let page = HomePage { db };
|
||||
assert_eq!(page.draw_page().is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let page = HomePage { db };
|
||||
assert_eq!(page.handle_input("").is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_return_the_correct_actions() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic = Epic::new("".to_owned(), "".to_owned());
|
||||
|
||||
let epic_id = db.create_epic(epic).unwrap();
|
||||
|
||||
let page = HomePage { db };
|
||||
|
||||
let q = "q";
|
||||
let c = "c";
|
||||
let valid_epic_id = epic_id.to_string();
|
||||
let invalid_epic_id = "999";
|
||||
let junk_input = "j983f2j";
|
||||
let junk_input_with_valid_prefix = "q983f2j";
|
||||
let input_with_trailing_white_spaces = "q\n";
|
||||
|
||||
assert_eq!(page.handle_input(q).unwrap(), Some(Action::Exit));
|
||||
assert_eq!(page.handle_input(c).unwrap(), Some(Action::CreateEpic));
|
||||
assert_eq!(page.handle_input(&valid_epic_id).unwrap(), Some(Action::NavigateToEpicDetail { epic_id: 1 }));
|
||||
assert_eq!(page.handle_input(invalid_epic_id).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input_with_valid_prefix).unwrap(), None);
|
||||
assert_eq!(page.handle_input(input_with_trailing_white_spaces).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
mod epic_detail_page {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draw_page_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
|
||||
let page = EpicDetail { epic_id, db };
|
||||
assert_eq!(page.draw_page().is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
|
||||
let page = EpicDetail { epic_id, db };
|
||||
assert_eq!(page.handle_input("").is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_page_should_throw_error_for_invalid_epic_id() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let page = EpicDetail { epic_id: 999, db };
|
||||
assert_eq!(page.draw_page().is_err(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_return_the_correct_actions() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let page = EpicDetail { epic_id, db };
|
||||
|
||||
let p = "p";
|
||||
let u = "u";
|
||||
let d = "d";
|
||||
let c = "c";
|
||||
let invalid_story_id = "999";
|
||||
let junk_input = "j983f2j";
|
||||
let junk_input_with_valid_prefix = "p983f2j";
|
||||
let input_with_trailing_white_spaces = "p\n";
|
||||
|
||||
assert_eq!(page.handle_input(p).unwrap(), Some(Action::NavigateToPreviousPage));
|
||||
assert_eq!(page.handle_input(u).unwrap(), Some(Action::UpdateEpicStatus { epic_id: 1 }));
|
||||
assert_eq!(page.handle_input(d).unwrap(), Some(Action::DeleteEpic { epic_id: 1 }));
|
||||
assert_eq!(page.handle_input(c).unwrap(), Some(Action::CreateStory { epic_id: 1 }));
|
||||
assert_eq!(page.handle_input(&story_id.to_string()).unwrap(), Some(Action::NavigateToStoryDetail { epic_id: 1, story_id: 2 }));
|
||||
assert_eq!(page.handle_input(invalid_story_id).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input_with_valid_prefix).unwrap(), None);
|
||||
assert_eq!(page.handle_input(input_with_trailing_white_spaces).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
mod story_detail_page {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn draw_page_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let page = StoryDetail { epic_id, story_id, db };
|
||||
assert_eq!(page.draw_page().is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_not_throw_error() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let page = StoryDetail { epic_id, story_id, db };
|
||||
assert_eq!(page.handle_input("").is_ok(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_page_should_throw_error_for_invalid_story_id() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let _ = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let page = StoryDetail { epic_id, story_id: 999, db };
|
||||
assert_eq!(page.draw_page().is_err(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_input_should_return_the_correct_actions() {
|
||||
let db = Rc::new(JiraDatabase { database: Box::new(MockDB::new()) });
|
||||
|
||||
let epic_id = db.create_epic(Epic::new("".to_owned(), "".to_owned())).unwrap();
|
||||
let story_id = db.create_story(Story::new("".to_owned(), "".to_owned()), epic_id).unwrap();
|
||||
|
||||
let page = StoryDetail { epic_id, story_id, db };
|
||||
|
||||
let p = "p";
|
||||
let u = "u";
|
||||
let d = "d";
|
||||
let some_number = "1";
|
||||
let junk_input = "j983f2j";
|
||||
let junk_input_with_valid_prefix = "p983f2j";
|
||||
let input_with_trailing_white_spaces = "p\n";
|
||||
|
||||
assert_eq!(page.handle_input(p).unwrap(), Some(Action::NavigateToPreviousPage));
|
||||
assert_eq!(page.handle_input(u).unwrap(), Some(Action::UpdateStoryStatus { story_id }));
|
||||
assert_eq!(page.handle_input(d).unwrap(), Some(Action::DeleteStory { epic_id, story_id }));
|
||||
assert_eq!(page.handle_input(some_number).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input).unwrap(), None);
|
||||
assert_eq!(page.handle_input(junk_input_with_valid_prefix).unwrap(), None);
|
||||
assert_eq!(page.handle_input(input_with_trailing_white_spaces).unwrap(), None);
|
||||
}
|
||||
}
|
||||
}
|
45
src/ui/pages/page_helpers.rs
Normal file
45
src/ui/pages/page_helpers.rs
Normal file
@ -0,0 +1,45 @@
|
||||
use ellipse::Ellipse;
|
||||
|
||||
pub fn get_column_string(text: &str, width: usize) -> String {
|
||||
todo!() // use the truncate_ellipse function from the ellipse crate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_column_string() {
|
||||
let text1 = "";
|
||||
let text2 = "test";
|
||||
let text3 = "testme";
|
||||
let text4 = "testmetest";
|
||||
|
||||
let width = 0;
|
||||
|
||||
assert_eq!(get_column_string(text4, width), "".to_owned());
|
||||
|
||||
let width = 1;
|
||||
|
||||
assert_eq!(get_column_string(text4, width), ".".to_owned());
|
||||
|
||||
let width = 2;
|
||||
|
||||
assert_eq!(get_column_string(text4, width), "..".to_owned());
|
||||
|
||||
let width = 3;
|
||||
|
||||
assert_eq!(get_column_string(text4, width), "...".to_owned());
|
||||
|
||||
let width = 4;
|
||||
|
||||
assert_eq!(get_column_string(text4, width), "t...".to_owned());
|
||||
|
||||
let width = 6;
|
||||
|
||||
assert_eq!(get_column_string(text1, width), " ".to_owned());
|
||||
assert_eq!(get_column_string(text2, width), "test ".to_owned());
|
||||
assert_eq!(get_column_string(text3, width), "testme".to_owned());
|
||||
assert_eq!(get_column_string(text4, width), "tes...".to_owned());
|
||||
}
|
||||
}
|
41
src/ui/pages/prompts.rs
Normal file
41
src/ui/pages/prompts.rs
Normal file
@ -0,0 +1,41 @@
|
||||
use crate::{models::{Epic, Story, Status}, io_utils::get_user_input};
|
||||
|
||||
pub struct Prompts {
|
||||
pub create_epic: Box<dyn Fn() -> Epic>,
|
||||
pub create_story: Box<dyn Fn() -> Story>,
|
||||
pub delete_epic: Box<dyn Fn() -> bool>,
|
||||
pub delete_story: Box<dyn Fn() -> bool>,
|
||||
pub update_status: Box<dyn Fn() -> Option<Status>>
|
||||
}
|
||||
|
||||
impl Prompts {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
create_epic: Box::new(create_epic_prompt),
|
||||
create_story: Box::new(create_story_prompt),
|
||||
delete_epic: Box::new(delete_epic_prompt),
|
||||
delete_story: Box::new(delete_story_prompt),
|
||||
update_status: Box::new(update_status_prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_epic_prompt() -> Epic {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn create_story_prompt() -> Story {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn delete_epic_prompt() -> bool {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn delete_story_prompt() -> bool {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn update_status_prompt() -> Option<Status> {
|
||||
todo!();
|
||||
}
|
80
stage_1.md
Normal file
80
stage_1.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Stage 1
|
||||
|
||||
__Database and Models__
|
||||
|
||||
In backend development projects, the database design is often the very first task to complete. The database design determines what and how information is imported and stored for repeated usages. While designing the database (what technologies to use, how to model it, etc.), we can very quickly assess if the project is feasible and if we can meet the requirements.
|
||||
|
||||
In this project, we will persist Epic and Story records in a JSON file to keep things as simple as possible. The JSON model contains the following components:
|
||||
* `last_item_id` - A global integer ID counter for both Epics and Stories. Each newly created Epic/Story will increment the counter.
|
||||
* `epics` - A mapping between Epic IDs and the actual Epics. An Epic will consist a list of Stories in the form of Story IDs.
|
||||
* `stories` - A mapping between Story IDs and the actual Stories.
|
||||
* `epic` and `story` both have `name`, `description` and `status`.
|
||||
* `status` can be `Open`, `InProgress`, `Resolved` or `Closed`.
|
||||
|
||||
Here's an example for how the JSON file will look like:
|
||||
```json
|
||||
{
|
||||
"last_item_id": 3,
|
||||
"epics": {
|
||||
"1": {
|
||||
"name": "Epic - Project 1",
|
||||
"description": "This is Project 1 for the Bootcamp",
|
||||
"status": "InProgress",
|
||||
"stories": [
|
||||
2,
|
||||
3
|
||||
]
|
||||
}
|
||||
},
|
||||
"stories": {
|
||||
"2": {
|
||||
"name": "Story - Project 1 Solution",
|
||||
"description": "Please provide full implement for Project 1",
|
||||
"status": "Closed"
|
||||
},
|
||||
"3": {
|
||||
"name": "Story - Project 1 README",
|
||||
"description": "Please create README file for Project 1",
|
||||
"status": "InProgress"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This file will be stored in `data/db.json`.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1
|
||||
|
||||
__Modeling the JSON representation in Rust__
|
||||
|
||||
Take the JSON representation and translate it to Rust Structs and Enums. Do this by completing the TODO items in `models.rs`.
|
||||
|
||||
### Step 2
|
||||
|
||||
__Reading and writing to the JSON file__
|
||||
|
||||
A new file called `db.rs` has been added. This is where we will store the logic which handles reading and writing the JSON file. This file contains two items, `Database` and `JSONFileDatabase`. `Database` is a trait with two methods, `read_db` and `write_db`. For simplicity we will read/write the entire state of the database. `JSONFileDatabase` is a Struct that implements the `Database` trait.
|
||||
|
||||
Note that a few dependencies have also been added.
|
||||
|
||||
`anyhow` has been added for error handling.
|
||||
|
||||
`serde` and `serde_json` have been added for serializing/de-serializing JSON. We haven't discussed these crates in the bootcamp but they are very straight forward. Please check out the documentation for both on [crates.io](https://crates.io/) if you are not familiar with them. You will see serde used in MANY Rust projects.
|
||||
|
||||
Lastly `tempfile` has been added as a dev dependency to help with testing.
|
||||
|
||||
Complete this step by finishing the TODO items in `db.rs` and `models.rs`.
|
||||
|
||||
### Step 3
|
||||
|
||||
__Add CRUD operations for Epics/Stories__
|
||||
|
||||
Another Struct called `JiraDatabase` has been added to `db.rs`. This Struct will contain CRUD methods for Epics and Stories.
|
||||
|
||||
Complete this step by finishing the TODO items in `db.rs`.
|
||||
|
||||
__NOTE 1:__ Use the `anyhow!()` macro for error handling.
|
||||
|
||||
__NOTE 2:__ Take a look at the `test_utils` module. Because `JiraDatabase` stores a trait object which can be any type that implements `Database`, we can create a mock database for testing (`MockDB`). Also note that `MockDB` uses the `RefCell` smart pointer. This is because `write_db()` takes an immutable reference to `self` and we need some way to work around this restriction.
|
133
stage_2.md
Normal file
133
stage_2.md
Normal file
@ -0,0 +1,133 @@
|
||||
# Stage 2
|
||||
|
||||
__UI (pages and prompts)__
|
||||
|
||||
Now that we have the database working we will implement the user interface of our application. The UI is made up of two objects, pages and prompts.
|
||||
|
||||
Pages represent the navigable pages of our application. There are 3 pages.
|
||||
|
||||
__Pages__
|
||||
|
||||
Home - The home page displays a list of epics.
|
||||
|
||||
```
|
||||
----------------------------- EPICS -----------------------------
|
||||
id | name | status
|
||||
1 | Epic - Project 1 | IN PROGRESS
|
||||
4 | Epic - Project 2 | OPEN
|
||||
|
||||
|
||||
[q] quit | [c] create epic | [:id:] navigate to epic
|
||||
```
|
||||
|
||||
Epic Detail - The epic detail page displays a single epic's details and a list of stories connected to that epic.
|
||||
|
||||
```
|
||||
------------------------------ EPIC ------------------------------
|
||||
id | name | description | status
|
||||
1 | Epic - Pr... | This is Project 1 for th... | IN PROGRESS
|
||||
|
||||
---------------------------- STORIES ----------------------------
|
||||
id | name | status
|
||||
2 | Story - Project 1 Solution | CLOSED
|
||||
3 | Story - Project 1 README | RESOLVED
|
||||
|
||||
|
||||
[p] previous | [u] update epic | [d] delete epic | [c] create story | [:id:] navigate to story
|
||||
```
|
||||
|
||||
Story Detail - The story detail page display a single story's details.
|
||||
|
||||
```
|
||||
------------------------------ STORY ------------------------------
|
||||
id | name | description | status
|
||||
2 | Story - P... | Please provide full impl... | CLOSED
|
||||
|
||||
|
||||
[p] previous | [u] update story | [d] delete story
|
||||
```
|
||||
|
||||
Pages will have access to the database so they can query it and render the data in a nicely formatted way.
|
||||
|
||||
Pages have two methods, `draw_page()` and `handle_input()`.
|
||||
|
||||
`draw_page()` is responsible for rendering the page to standard out. This method will also render a list of actions users can take (ex: navigate to epic detail page, create story, delete epic, etc.)
|
||||
|
||||
`handle_input()` is responsible for handling user input and potentially producing an action. The return type is `Result<Option<Action>>` because this function call can fail. If it doesn't fail then it can optionally return an action. Returning `None` means that the user input was invalid.
|
||||
|
||||
__Actions__
|
||||
|
||||
User actions are represented by the `Action` Enum in `models.rs`.
|
||||
|
||||
__Prompts__
|
||||
|
||||
Prompts are used when more complicated user input is needed. For example, when creating a new epic the user is asked to enter a name and description.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1
|
||||
|
||||
__Pages and Page Helpers__
|
||||
|
||||
Pages are defined inside the UI module. Complete this step by finishing the TODOs in `pages/mod.rs`, `page_helpers.rs`, and `models.rs`. Make sure all the tests are passing.
|
||||
|
||||
__NOTE 1:__ Status needs to implement the `Display` trait so it can be printed to the screen. The implementation should result in the following Enum variant to string mapping:
|
||||
|
||||
* OPEN -> "OPEN"
|
||||
* InProgress -> "IN PROGRESS"
|
||||
* Resolved -> "RESOLVED"
|
||||
* Closed -> "Closed"
|
||||
|
||||
__NOTE 2:__ `page_helpers.rs` contains a function called `get_column_string()` which is used to confine text to a specific character width. A dependency called `ellipse` was added to help accomplish this. Use the `truncate_ellipse()` function from the `ellipse` crate to implement `get_column_string()`.
|
||||
|
||||
__NOTE 3:__ Use `get_column_string()` inside the `draw_page()` methods.
|
||||
|
||||
__NOTE 4:__ The `iterools` dependency has also been added. This allows you to sort an iterator by calling `sorted()` on it.
|
||||
|
||||
### Step 2
|
||||
|
||||
__Prompts__
|
||||
|
||||
Complete this step by finishing the TODOs in `prompts.rs`. The `Prompts` Struct has been added as a level of indirection which will allow us to mock the prompt functions during testing later on in this project.
|
||||
|
||||
Here is how each prompt should look:
|
||||
|
||||
Create Epic
|
||||
```
|
||||
----------------------------
|
||||
Epic Name:
|
||||
test
|
||||
Epic Description:
|
||||
test
|
||||
```
|
||||
|
||||
Create Story
|
||||
```
|
||||
----------------------------
|
||||
Story Name:
|
||||
test
|
||||
Story Description:
|
||||
test
|
||||
```
|
||||
|
||||
Delete Epic
|
||||
```
|
||||
----------------------------
|
||||
Are you sure you want to delete this epic? All stories in this epic will also be deleted [Y/n]:
|
||||
Y
|
||||
```
|
||||
|
||||
Delete Story
|
||||
```
|
||||
----------------------------
|
||||
Are you sure you want to delete this story? [Y/n]: Y
|
||||
```
|
||||
|
||||
Update Status
|
||||
```
|
||||
----------------------------
|
||||
New Status (1 - OPEN, 2 - IN-PROGRESS, 3 - RESOLVED, 4 - CLOSED):
|
||||
3
|
||||
```
|
||||
|
||||
__NOTE:__ a new file called `io_utils.rs` has been added. This file contains a function called `get_user_input()`. Use this function inside the prompt functions for getting user input.
|
144
stage_3.md
Normal file
144
stage_3.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Stage 3
|
||||
|
||||
__Navigation and Program Loop__
|
||||
|
||||
Now that the database and UI are complete, it's time to build the bridge between them. In this step we will be building the navigator and program loop.
|
||||
|
||||
The navigator will handle navigating between pages, responding to actions returned from `handle_input()` on Page objects, and displaying prompts.
|
||||
|
||||
The program loop is responsible for running our application and continuously asking for user input until the user exits the program.
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 1
|
||||
|
||||
__Implementing The Navigator__
|
||||
|
||||
A new file called `navigator.rs` has been added. Inside this file a `Navigator` Struct is defined, which contains 3 fields.
|
||||
|
||||
`pages` is a vector of `Page` objects. This vector is used for navigation. The user starts off on the home page and if they navigate to the Epic details page (for example) a new instance of `EpicDetail` will be created and pushed onto the pages vector. To navigate back to the home page we will simply pop the `EpicDetail` page off the pages vector (which acts like a stack). Note that the current page is always the last element in the `pages` vector.
|
||||
|
||||
`prompts` is an instance of the `Prompts` Struct. Look at the navigator tests to see how we can mock colures in the prompts Struct.
|
||||
|
||||
`db` is a `JiraDatabase` instance wrapped in a reference counting smart pointer so we can share ownership.
|
||||
|
||||
The `Navigator` Struct contains 2 primary functions. `get_current_page()` which does exactly what the name suggests and `handle_action()` which responds to user actions.
|
||||
|
||||
To complete this step finish all the TODO items in `navigator.rs`.
|
||||
|
||||
__NOTE 1:__ Use `with_context()` and the `anyhow!` macro form the anyhow crate inside `handle_action()` for error handling.
|
||||
|
||||
__NOTE 2:__ A method called `as_any()` has been added to all page objects. This was done to support down-casting, which is used in the navigator tests. For more info check out [this StackOverflow post](https://stackoverflow.com/questions/33687447/how-to-get-a-reference-to-a-concrete-type-from-a-trait-object).
|
||||
|
||||
### Step 2
|
||||
|
||||
__The Program Loop__
|
||||
|
||||
The program loop will be defined inside `main.rs`. First we will instantiate the navigator. Then inside the program loop we will get the current page, render it, wait for user input and then handle the input.
|
||||
|
||||
To complete this task finish the TODO items in `main.rs`.
|
||||
|
||||
__NOTE 1:__ A new dependency called `clearscreen` has been added. At the top of the program loop we call `clearscreen::clear()`. This will clear the screen which is what we want to do before rendering the new content. Think about it like refreshing a web page... everything is wiped away and reloaded.
|
||||
|
||||
__NOTE 2:__ A function called `wait_for_key_press()` has been added to `io_utils.rs`. Use this method when displaying errors. For example:
|
||||
```rust
|
||||
if let Err(error) = page.draw_page() {
|
||||
println!("Error rendering page: {}\nPress any key to continue...", error);
|
||||
wait_for_key_press();
|
||||
};
|
||||
```
|
||||
|
||||
## Manual Tests
|
||||
|
||||
Run these manual tests (___from top to bottom in order___) to see if your program works as expected.
|
||||
|
||||
__NOTE:__ Before running these tests, reset the database by updating `data/db.json` to this:
|
||||
```json
|
||||
{
|
||||
"last_item_id": 0,
|
||||
"epics": {},
|
||||
"stories": {}
|
||||
}
|
||||
```
|
||||
|
||||
__Create Epic__
|
||||
|
||||
Steps:
|
||||
* `cd` into the root folder of the project
|
||||
* Run `cargo run`
|
||||
* Input `c` to create a new Epic
|
||||
* Input `"New Epic name"` as Epic name
|
||||
* Input `"New Epic description"` as Epic description
|
||||
* Check if `db.json` matches with the following:
|
||||
```json
|
||||
{"last_item_id":1,"epics":{"1":{"name":"New Epic name","description":"New Epic description","status":"Open","stories":[]}},"stories":{}}
|
||||
```
|
||||
|
||||
__Create Story__
|
||||
|
||||
Steps:
|
||||
* Input `1` to select the created Epic
|
||||
* Input `c` to create a new Story in the selected Epic
|
||||
* Input `"New Story name"` as Story name
|
||||
* Input `"New Story description"` as Story description
|
||||
* Check if `db.json` matches with the following:
|
||||
```json
|
||||
{"last_item_id":2,"epics":{"1":{"name":"New Epic name","description":"New Epic description","status":"Open","stories":[2]}},"stories":{"2":{"name":"New Story name","description":"New Story description","status":"Open"}}}
|
||||
```
|
||||
|
||||
__Update Epic__
|
||||
|
||||
Steps:
|
||||
* Input `u` to update the selected Epic
|
||||
* Input `2` to select `IN-PROGRESS` as the updated status
|
||||
* Check if `db.json` matches with the following:
|
||||
```json
|
||||
{"last_item_id":2,"epics":{"1":{"name":"New Epic name","description":"New Epic description","status":"InProgress","stories":[2]}},"stories":{"2":{"name":"New Story name","description":"New Story description","status":"Open"}}}
|
||||
```
|
||||
|
||||
__Update Story__
|
||||
|
||||
Steps:
|
||||
* Input `2` to select the created Story
|
||||
* Input `u` to update the selected Story
|
||||
* Input `3` to select `RESOLVED` as the updated status
|
||||
* Check if `db.json` matches with the following:
|
||||
```json
|
||||
{"last_item_id":2,"epics":{"1":{"name":"New Epic name","description":"New Epic description","status":"InProgress","stories":[2]}},"stories":{"2":{"name":"New Story name","description":"New Story description","status":"Resolved"}}}
|
||||
```
|
||||
|
||||
__Remove Story__
|
||||
|
||||
Steps:
|
||||
* Input `d` to delete the selected Story
|
||||
* Input `Y` to confirm removal
|
||||
* Check if `db.json` matches with the following:
|
||||
```json
|
||||
{"last_item_id":2,"epics":{"1":{"name":"New Epic name","description":"New Epic description","status":"InProgress","stories":[]}},"stories":{}}
|
||||
```
|
||||
|
||||
__Remove Epic__
|
||||
|
||||
Steps:
|
||||
* Input `c` to create a new Story in the selected Epic
|
||||
* Input `"New Story name"` as Story name
|
||||
* Input `"New Story description"` as Story description
|
||||
* Input `d` to delete the selected Epic
|
||||
* Input `Y` to confirm removal
|
||||
* Check if storage.json matches with the following:
|
||||
```json
|
||||
{"last_item_id":3,"epics":{},"stories":{}}
|
||||
```
|
||||
|
||||
__NOTE:__ You can also check your work against the `Solution` folder.
|
||||
|
||||
## Final Note
|
||||
|
||||
Check this -- you're a Rust developer now!
|
||||
|
||||
This is a pretty elaborate first project. You should be proud of your progress if you've gotten this far.
|
||||
|
||||
Showcase your implementation and struggles you've faced along the way to others in the Let's Get Rusty community.
|
||||
More importantly, teaching is the best way to learn. Any questions posted by others in the Discord channels are opportunities for you to answer and truly internalize your knowledge.
|
||||
|
||||
Congrats! And let's get started with the next modules and corresponding projects!
|
Loading…
x
Reference in New Issue
Block a user