feat: ability to create a project (#9)

This commit is contained in:
Matouš Volf 2024-08-18 23:36:05 +02:00 committed by GitHub
commit e0a6dcd2ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 10043 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
/.dioxus/
/.git/
/.github/
/dist/
/debug/
/node_modules/
/static/
/target/
/docker-compose-dev.yml
/docker-compose-prod.yml

1
.env.dev Normal file
View File

@ -0,0 +1 @@
DATABASE_URL=postgres://app:app@db/todo_baggins

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
/target/
/dist/
/static/
/.dioxus/
/node_modules/
**/*.rs.bk
.env
.env.prod

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

20
.idea/dataSources.local.xml generated Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="RR-242.20224.309">
<data-source name="todo_baggins@localhost" uuid="1658668c-c2b8-426d-a22f-16fbad9eff0b">
<database-info product="PostgreSQL" version="16.4 (Debian 16.4-1.pgdg120+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.6.0" dbms="POSTGRES" exact-version="16.4" exact-driver-version="42.6">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<secret-storage>master_key</secret-storage>
<user-name>app</user-name>
<schema-mapping>
<introspection-scope>
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

12
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="todo_baggins@localhost" uuid="1658668c-c2b8-426d-a22f-16fbad9eff0b">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/todo_baggins</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
#n:information_schema
!<md> [null, 0, null, null, -2147483648, -2147483648]

View File

@ -0,0 +1,2 @@
#n:pg_catalog
!<md> [null, 0, null, null, -2147483648, -2147483648]

View File

@ -0,0 +1,2 @@
#n:public
!<md> [767, 0, null, null, -2147483648, -2147483648]

6
.idea/jsLibraryMappings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/todo-baggins.iml" filepath="$PROJECT_DIR$/.idea/todo-baggins.iml" />
</modules>
</component>
</project>

11
.idea/runConfigurations/dev.xml generated Normal file
View File

@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="dev" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="envFilePath" value="" />
<option name="sourceFilePath" value="docker-compose-dev.yml" />
</settings>
</deployment>
<method v="2" />
</configuration>
</component>

8
.idea/sqldialects.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/00000000000000_diesel_initial_setup/down.sql" dialect="GenericSQL" />
<file url="file://$PROJECT_DIR$/migrations/00000000000000_diesel_initial_setup/up.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

11
.idea/todo-baggins.iml generated Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

12
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="ERROR" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3041
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "todo-baggins"
version = "0.1.0"
authors = ["Matouš Volf <66163112+matous-volf@users.noreply.github.com>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
diesel = { version = "2.2.2", features = ["postgres"] }
dioxus = { version = "0.5", features = ["fullstack", "router"] }
# Debug
dioxus-logger = "0.5.1"
dotenvy = "0.15.7"
serde = "1.0.208"
validator = { version = "0.18.1", features = ["derive"] }
[features]
default = []
server = ["dioxus/axum"]
web = ["dioxus/web"]

43
Dioxus.toml Normal file
View File

@ -0,0 +1,43 @@
[application]
# App (Project) Name
name = "todo-baggins"
# Dioxus App Default Platform
# web, desktop, fullstack
default_platform = "fullstack"
# `build` & `serve` dist path
out_dir = "dist"
# resource (assets) file folder
asset_dir = "assets"
[web.app]
# HTML title tag content
title = "Todo Baggins"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "assets"]
# include `assets` in web platform
[web.resource]
# CSS style file
style = ["/styles/tailwind_output.css"]
# Javascript code file
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

1
assets/styles/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/tailwind_output.css

6
diesel.toml Normal file
View File

@ -0,0 +1,6 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema/mod.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]

31
docker-compose-dev.yml Normal file
View File

@ -0,0 +1,31 @@
services:
app:
build:
dockerfile: docker/dev/app/Dockerfile
volumes:
- ./.env.dev:/srv/app/.env
- ./assets:/srv/app/assets
- ./src:/srv/app/src
- ./migrations:/srv/app/migrations
- ./Cargo.lock:/srv/app/Cargo.lock
- ./Cargo.toml:/srv/app/Cargo.toml
- ./diesel.toml:/srv/app/diesel.toml
- ./Dioxus.toml:/srv/app/Dioxus.toml
- ./package.json:/srv/app/package.json
- ./package-lock.json:/srv/app/package-lock.json
restart: always
ports: [ "8000:8000" ]
depends_on: [ "db" ]
db:
image: postgres:16.4-bookworm
volumes: [ "db_data:/var/lib/postgresql/data" ]
ports: [ "5432:5432" ]
environment:
POSTGRES_DB: todo_baggins
POSTGRES_USER: app
POSTGRES_PASSWORD: app
restart: always
volumes:
db_data:

0
docker-compose-prod.yml Executable file
View File

23
docker/dev/app/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM rust:1.80-bookworm
RUN rustup target add wasm32-unknown-unknown && \
cargo install dioxus-cli diesel_cli && \
apt-get update && apt-get install -y nodejs=18.19.0+dfsg-6~deb12u2 npm=9.2.0~ds1-1 supervisor=4.2.5-1
COPY . /srv/app
WORKDIR /srv/app
RUN npm install
COPY docker/dev/app/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN chown -R 1000:1000 /srv/app && \
chown -R 1000:1000 /usr/local/cargo && \
mkdir -p /.local/share/dioxus && \
chown -R 1000:1000 /.local/share/dioxus
HEALTHCHECK CMD curl --fail http://localhost:8000 || exit 1
USER 1000:1000
CMD ["sh", "docker/dev/app/entrypoint.sh"]

5
docker/dev/app/entrypoint.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
diesel migration run
supervisord -c /etc/supervisor/conf.d/supervisord.conf

View File

@ -0,0 +1,23 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/dev/null
[program:npm]
command=npm run watch
directory=/srv/app
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:dx]
command=dx serve
directory=/srv/app
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true

View File

@ -0,0 +1,6 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();

View File

@ -0,0 +1,36 @@
-- This file was automatically created by Diesel to setup helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS "projects";

View File

@ -0,0 +1,6 @@
-- Your SQL goes here
CREATE TABLE "projects"(
"id" SERIAL NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL
);

1386
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

9
package.json Normal file
View File

@ -0,0 +1,9 @@
{
"dependencies": {
"tailwindcss": "^3.4.6"
},
"scripts": {
"build": "tailwindcss -c src/styles/tailwind.config.js -i src/styles/tailwind.css -o assets/styles/tailwind_output.css",
"watch": "npm run build -- --watch"
}
}

11
src/components/app.rs Normal file
View File

@ -0,0 +1,11 @@
use crate::route::Route;
use dioxus::core_macro::rsx;
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
#[component]
pub(crate) fn App() -> Element {
rsx! {
Router::<Route> {}
}
}

11
src/components/home.rs Normal file
View File

@ -0,0 +1,11 @@
use crate::components::project_form::ProjectForm;
use dioxus::core_macro::rsx;
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
#[component]
pub(crate) fn Home() -> Element {
rsx! {
ProjectForm {}
}
}

3
src/components/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod app;
pub(crate) mod home;
pub(crate) mod project_form;

View File

@ -0,0 +1,30 @@
use crate::models::project::NewProject;
use crate::server::projects::create_project;
use dioxus::core_macro::{component, rsx};
use dioxus::dioxus_core::Element;
use dioxus::prelude::*;
#[component]
pub(crate) fn ProjectForm() -> Element {
rsx! {
form {
onsubmit: move |event| {
async move {
let new_project = NewProject::new(
event.values().get("title").unwrap().as_value()
);
let _ = create_project(new_project).await;
}
},
input {
r#type: "text",
name: "title",
placeholder: "title"
}
button {
r#type: "submit",
"create"
}
}
}
}

29
src/errors/error.rs Normal file
View File

@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::str::FromStr;
#[derive(Serialize, Deserialize, Debug)]
pub enum Error {
ServerInternal,
}
// has to be implemented for Dioxus server functions
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::ServerInternal => write!(f, "internal server error"),
}
}
}
// has to be implemented for Dioxus server functions
impl FromStr for Error {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"internal server error" => Error::ServerInternal,
_ => return Err(()),
})
}
}

43
src/errors/error_vec.rs Normal file
View File

@ -0,0 +1,43 @@
use std::fmt::Display;
use std::str::FromStr;
#[derive(Debug)]
pub struct ErrorVec<T> {
errors: Vec<T>,
}
impl<T> From<ErrorVec<T>> for Vec<T> {
fn from(e: ErrorVec<T>) -> Self {
e.errors
}
}
impl<T> From<Vec<T>> for ErrorVec<T> {
fn from(e: Vec<T>) -> Self {
ErrorVec { errors: e }
}
}
// has to be implemented for Dioxus server functions
impl<T: Display> Display for ErrorVec<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
self.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<String>>()
.join("\n")
)
}
}
// has to be implemented for Dioxus server functions
impl<T> FromStr for ErrorVec<T> {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(ErrorVec { errors: Vec::new() })
}
}

3
src/errors/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod error;
pub(crate) mod error_vec;
pub(crate) mod project_create_error;

View File

@ -0,0 +1,51 @@
use crate::errors::error::Error;
use crate::errors::error_vec::ErrorVec;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::str::FromStr;
use validator::{ValidationErrors, ValidationErrorsKind};
#[derive(Serialize, Deserialize, Debug)]
pub enum ProjectCreateError {
TitleLengthInvalid,
Error(Error),
}
impl From<ValidationErrors> for ErrorVec<ProjectCreateError> {
fn from(e: ValidationErrors) -> Self {
e.errors()
.iter()
.flat_map(|(&field, error_kind)| match field {
"title" => match error_kind {
ValidationErrorsKind::Field(validation_errors) => validation_errors
.iter()
.map(|validation_error| validation_error.code.as_ref())
.map(|code| match code {
"title_length" => ProjectCreateError::TitleLengthInvalid,
_ => panic!("unexpected validation error code: {code}"),
})
.collect::<Vec<ProjectCreateError>>(),
_ => panic!("unexpected validation error kind"),
},
_ => panic!("unexpected validation field name: {field}"),
})
.collect::<Vec<ProjectCreateError>>()
.into()
}
}
// has to be implemented for Dioxus server functions
impl Display for ProjectCreateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
// has to be implemented for Dioxus server functions
impl FromStr for ProjectCreateError {
type Err = ();
fn from_str(_: &str) -> Result<Self, Self::Err> {
Ok(ProjectCreateError::TitleLengthInvalid)
}
}

21
src/main.rs Normal file
View File

@ -0,0 +1,21 @@
mod components;
mod errors;
mod models;
mod route;
mod schema;
mod server;
use components::app::App;
use dioxus::prelude::*;
use dioxus_logger::tracing::{info, Level};
fn main() {
dioxus_logger::init(Level::INFO).expect("failed to initialize logger");
info!("starting app");
let cfg = server_only!(
dioxus::fullstack::Config::new().addr(std::net::SocketAddr::from(([0, 0, 0, 0], 8000)))
);
LaunchBuilder::fullstack().with_cfg(cfg).launch(App);
}

1
src/models/mod.rs Normal file
View File

@ -0,0 +1 @@
pub(crate) mod project;

38
src/models/project.rs Normal file
View File

@ -0,0 +1,38 @@
use crate::schema::projects;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use validator::Validate;
const TITLE_LENGTH_MIN: u64 = 1;
const TITLE_LENGTH_MAX: u64 = 255;
#[derive(Queryable, Selectable, Serialize, Deserialize, Debug)]
#[diesel(table_name = crate::schema::projects)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Project {
id: i32,
title: String,
}
impl Project {
pub fn id(&self) -> i32 {
self.id
}
pub fn title(&self) -> &str {
&self.title
}
}
#[derive(Insertable, Serialize, Deserialize, Validate, Clone, Debug)]
#[diesel(table_name = projects)]
pub struct NewProject {
#[validate(length(min = "TITLE_LENGTH_MIN", max = "TITLE_LENGTH_MAX", code = "title_length"))]
pub title: String,
}
impl NewProject {
pub fn new(title: String) -> Self {
Self { title }
}
}

8
src/route/mod.rs Normal file
View File

@ -0,0 +1,8 @@
use crate::components::home::Home;
use dioxus::prelude::*;
#[derive(Clone, Routable, Debug, PartialEq)]
pub(crate) enum Route {
#[route("/")]
Home {},
}

8
src/schema/mod.rs Normal file
View File

@ -0,0 +1,8 @@
// @generated automatically by Diesel CLI.
diesel::table! {
projects (id) {
id -> Int4,
title -> Text,
}
}

View File

@ -0,0 +1,12 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;
pub(crate) fn establish_database_connection() -> ConnectionResult<PgConnection> {
dotenv().ok();
let database_url =
env::var("DATABASE_URL").expect("The environment variable DATABASE_URL must be set.");
PgConnection::establish(&database_url)
}

2
src/server/mod.rs Normal file
View File

@ -0,0 +1,2 @@
mod database_connection;
pub(crate) mod projects;

34
src/server/projects.rs Normal file
View File

@ -0,0 +1,34 @@
use crate::errors::error::Error;
use crate::errors::error_vec::ErrorVec;
use crate::errors::project_create_error::ProjectCreateError;
use crate::models::project::{NewProject, Project};
use crate::server::database_connection::establish_database_connection;
use diesel::{RunQueryDsl, SelectableHelper};
use dioxus::prelude::*;
use validator::Validate;
#[server]
pub(crate) async fn create_project(
new_project: NewProject,
) -> Result<Project, ServerFnError<ErrorVec<ProjectCreateError>>> {
use crate::schema::projects;
new_project
.validate()
.map_err::<ErrorVec<ProjectCreateError>, _>(|errors| errors.into())?;
let mut connection = establish_database_connection()
.map_err::<ErrorVec<ProjectCreateError>, _>(
|_| vec![ProjectCreateError::Error(Error::ServerInternal), ].into()
)?;
let new_project = diesel::insert_into(projects::table)
.values(&new_project)
.returning(Project::as_returning())
.get_result(&mut connection)
.map_err::<ErrorVec<ProjectCreateError>, _>(
|_| vec![ProjectCreateError::Error(Error::ServerInternal), ].into()
)?;
Ok(new_project)
}

View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
mode: "all",
content: ["./src/**/*.{rs,html,css}", "./dist/**/*.html"],
theme: {
extend: {},
},
plugins: [],
};

8
src/styles/tailwind.css Normal file
View File

@ -0,0 +1,8 @@
/* stylelint-disable */
/* noinspection CssInvalidAtRule */
@tailwind base;
/* noinspection CssInvalidAtRule */
@tailwind components;
/* noinspection CssInvalidAtRule */
@tailwind utilities;
/* stylelint-enable */