Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b298a1e59 | ||
|
|
4c30a133d3 | ||
|
|
6716129d4a | ||
|
|
85937a3fcd | ||
|
|
a2e1b7f3d2 | ||
|
|
7f6123a6e6 | ||
|
|
8a94b07955 | ||
|
|
7c9aef4324 | ||
|
|
8a7d1716e2 | ||
|
|
e3da79930c | ||
|
|
7c9e164a9a | ||
|
|
0f5d105be3 | ||
|
|
910c1912e0 | ||
|
|
fbb09e4015 | ||
|
|
cfca2bec27 | ||
|
|
142e14743a | ||
|
|
ec595da889 | ||
|
|
6c3180f203 | ||
|
|
fd0a6fb408 | ||
|
|
f72af5d45f | ||
|
|
6bbbc67a3c | ||
|
|
754a76fd5d | ||
|
|
e0ac034974 | ||
|
|
de19e675c8 |
12
.github/dependabot.yml
vendored
12
.github/dependabot.yml
vendored
@@ -1,12 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: bundler
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
124
.github/workflows/ci.yml
vendored
124
.github/workflows/ci.yml
vendored
@@ -1,124 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan_ruby:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Scan for common Rails security vulnerabilities using static analysis
|
|
||||||
run: bin/brakeman --no-pager
|
|
||||||
|
|
||||||
- name: Scan for known security vulnerabilities in gems used
|
|
||||||
run: bin/bundler-audit
|
|
||||||
|
|
||||||
scan_js:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Scan for security vulnerabilities in JavaScript dependencies
|
|
||||||
run: bin/importmap audit
|
|
||||||
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Prepare RuboCop cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
env:
|
|
||||||
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
|
|
||||||
with:
|
|
||||||
path: ${{ env.RUBOCOP_CACHE_ROOT }}
|
|
||||||
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
|
|
||||||
restore-keys: |
|
|
||||||
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
|
|
||||||
|
|
||||||
- name: Lint code for consistent style
|
|
||||||
run: bin/rubocop -f github
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
# services:
|
|
||||||
# redis:
|
|
||||||
# image: valkey/valkey:8
|
|
||||||
# ports:
|
|
||||||
# - 6379:6379
|
|
||||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
RAILS_ENV: test
|
|
||||||
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
|
||||||
# REDIS_URL: redis://localhost:6379/0
|
|
||||||
run: bin/rails db:test:prepare test
|
|
||||||
|
|
||||||
system-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
# services:
|
|
||||||
# redis:
|
|
||||||
# image: valkey/valkey:8
|
|
||||||
# ports:
|
|
||||||
# - 6379:6379
|
|
||||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Ruby
|
|
||||||
uses: ruby/setup-ruby@v1
|
|
||||||
with:
|
|
||||||
bundler-cache: true
|
|
||||||
|
|
||||||
- name: Run System Tests
|
|
||||||
env:
|
|
||||||
RAILS_ENV: test
|
|
||||||
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
|
||||||
# REDIS_URL: redis://localhost:6379/0
|
|
||||||
run: bin/rails db:test:prepare test:system
|
|
||||||
|
|
||||||
- name: Keep screenshots from failed system tests
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: screenshots
|
|
||||||
path: ${{ github.workspace }}/tmp/screenshots
|
|
||||||
if-no-files-found: ignore
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Docker set up on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# A sample post-deploy hook
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
# KAMAL_RUNTIME
|
|
||||||
|
|
||||||
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# A sample pre-build hook
|
|
||||||
#
|
|
||||||
# Checks:
|
|
||||||
# 1. We have a clean checkout
|
|
||||||
# 2. A remote is configured
|
|
||||||
# 3. The branch has been pushed to the remote
|
|
||||||
# 4. The version we are deploying matches the remote
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "Git checkout is not clean, aborting..." >&2
|
|
||||||
git status --porcelain >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
first_remote=$(git remote)
|
|
||||||
|
|
||||||
if [ -z "$first_remote" ]; then
|
|
||||||
echo "No git remote set, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
current_branch=$(git branch --show-current)
|
|
||||||
|
|
||||||
if [ -z "$current_branch" ]; then
|
|
||||||
echo "Not on a git branch, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
|
|
||||||
|
|
||||||
if [ -z "$remote_head" ]; then
|
|
||||||
echo "Branch not pushed to remote, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
|
|
||||||
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
# A sample pre-connect check
|
|
||||||
#
|
|
||||||
# Warms DNS before connecting to hosts in parallel
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
# KAMAL_RUNTIME
|
|
||||||
|
|
||||||
hosts = ENV["KAMAL_HOSTS"].split(",")
|
|
||||||
results = nil
|
|
||||||
max = 3
|
|
||||||
|
|
||||||
elapsed = Benchmark.realtime do
|
|
||||||
results = hosts.map do |host|
|
|
||||||
Thread.new do
|
|
||||||
tries = 1
|
|
||||||
|
|
||||||
begin
|
|
||||||
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
|
|
||||||
rescue SocketError
|
|
||||||
if tries < max
|
|
||||||
puts "Retrying DNS warmup: #{host}"
|
|
||||||
tries += 1
|
|
||||||
sleep rand
|
|
||||||
retry
|
|
||||||
else
|
|
||||||
puts "DNS warmup failed: #{host}"
|
|
||||||
host
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
tries
|
|
||||||
end
|
|
||||||
end.map(&:value)
|
|
||||||
end
|
|
||||||
|
|
||||||
retries = results.sum - hosts.size
|
|
||||||
nopes = results.count { |r| r == max }
|
|
||||||
|
|
||||||
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
# A sample pre-deploy hook
|
|
||||||
#
|
|
||||||
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
|
|
||||||
#
|
|
||||||
# Fails unless the combined status is "success"
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_COMMAND
|
|
||||||
# KAMAL_SUBCOMMAND
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
|
|
||||||
# Only check the build status for production deployments
|
|
||||||
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
|
|
||||||
exit 0
|
|
||||||
end
|
|
||||||
|
|
||||||
require "bundler/inline"
|
|
||||||
|
|
||||||
# true = install gems so this is fast on repeat invocations
|
|
||||||
gemfile(true, quiet: true) do
|
|
||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
gem "octokit"
|
|
||||||
gem "faraday-retry"
|
|
||||||
end
|
|
||||||
|
|
||||||
MAX_ATTEMPTS = 72
|
|
||||||
ATTEMPTS_GAP = 10
|
|
||||||
|
|
||||||
def exit_with_error(message)
|
|
||||||
$stderr.puts message
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
class GithubStatusChecks
|
|
||||||
attr_reader :remote_url, :git_sha, :github_client, :combined_status
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@remote_url = github_repo_from_remote_url
|
|
||||||
@git_sha = `git rev-parse HEAD`.strip
|
|
||||||
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
|
|
||||||
refresh!
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh!
|
|
||||||
@combined_status = github_client.combined_status(remote_url, git_sha)
|
|
||||||
end
|
|
||||||
|
|
||||||
def state
|
|
||||||
combined_status[:state]
|
|
||||||
end
|
|
||||||
|
|
||||||
def first_status_url
|
|
||||||
first_status = combined_status[:statuses].find { |status| status[:state] == state }
|
|
||||||
first_status && first_status[:target_url]
|
|
||||||
end
|
|
||||||
|
|
||||||
def complete_count
|
|
||||||
combined_status[:statuses].count { |status| status[:state] != "pending"}
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_count
|
|
||||||
combined_status[:statuses].count
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_status
|
|
||||||
if total_count > 0
|
|
||||||
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
|
|
||||||
else
|
|
||||||
"Build not started..."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def github_repo_from_remote_url
|
|
||||||
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
|
|
||||||
if url.start_with?("https://github.com/")
|
|
||||||
url.delete_prefix("https://github.com/")
|
|
||||||
elsif url.start_with?("git@github.com:")
|
|
||||||
url.delete_prefix("git@github.com:")
|
|
||||||
else
|
|
||||||
url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
$stdout.sync = true
|
|
||||||
|
|
||||||
begin
|
|
||||||
puts "Checking build status..."
|
|
||||||
|
|
||||||
attempts = 0
|
|
||||||
checks = GithubStatusChecks.new
|
|
||||||
|
|
||||||
loop do
|
|
||||||
case checks.state
|
|
||||||
when "success"
|
|
||||||
puts "Checks passed, see #{checks.first_status_url}"
|
|
||||||
exit 0
|
|
||||||
when "failure"
|
|
||||||
exit_with_error "Checks failed, see #{checks.first_status_url}"
|
|
||||||
when "pending"
|
|
||||||
attempts += 1
|
|
||||||
end
|
|
||||||
|
|
||||||
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
|
|
||||||
|
|
||||||
puts checks.current_status
|
|
||||||
sleep(ATTEMPTS_GAP)
|
|
||||||
checks.refresh!
|
|
||||||
end
|
|
||||||
rescue Octokit::NotFound
|
|
||||||
exit_with_error "Build status could not be found"
|
|
||||||
end
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets,
|
|
||||||
# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either
|
|
||||||
# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.
|
|
||||||
|
|
||||||
# Example of extracting secrets from 1password (or another compatible pw manager)
|
|
||||||
# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
|
|
||||||
# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS})
|
|
||||||
# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS})
|
|
||||||
|
|
||||||
# Example of extracting secrets from Rails credentials
|
|
||||||
# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password)
|
|
||||||
|
|
||||||
# Use a GITHUB_TOKEN if private repositories are needed for the image
|
|
||||||
# GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
|
|
||||||
|
|
||||||
# Grab the registry password from ENV
|
|
||||||
# KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
|
|
||||||
|
|
||||||
# Improve security by using a password manager. Never check config/master.key into git!
|
|
||||||
RAILS_MASTER_KEY=$(cat config/master.key)
|
|
||||||
2
Gemfile
2
Gemfile
@@ -64,3 +64,5 @@ group :test do
|
|||||||
gem "capybara"
|
gem "capybara"
|
||||||
gem "selenium-webdriver"
|
gem "selenium-webdriver"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "tailwindcss", "~> 0.1.1"
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ GEM
|
|||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.2.0)
|
||||||
|
tailwindcss (0.1.1)
|
||||||
thor (1.5.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.17)
|
thruster (0.1.17)
|
||||||
thruster (0.1.17-aarch64-linux)
|
thruster (0.1.17-aarch64-linux)
|
||||||
@@ -415,6 +416,7 @@ DEPENDENCIES
|
|||||||
solid_queue
|
solid_queue
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
|
tailwindcss (~> 0.1.1)
|
||||||
thruster
|
thruster
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
@@ -552,6 +554,7 @@ CHECKSUMS
|
|||||||
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
||||||
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
||||||
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
||||||
|
tailwindcss (0.1.1) sha256=cdd453b13267d6441cba86cc6bf2b91e06bf01171983d90701b8d9b634d3be75
|
||||||
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
||||||
thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235
|
thruster (0.1.17) sha256=6f3f1de43e22f0162d81cbc363f45ee42a1b8460213856c1a899cbf0d3297235
|
||||||
thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
|
thruster (0.1.17-aarch64-linux) sha256=1b3a34b2814185c2aeaf835b5ecff5348cdcf8e77809f7a092d46e4b962a16ba
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
/*
|
/* application.css */
|
||||||
* This is a manifest file that'll be compiled into application.css.
|
|
||||||
*
|
body {
|
||||||
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
|
font-family: sans-serif;
|
||||||
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
|
background-color: #f9f9f9;
|
||||||
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
|
margin: 18px;
|
||||||
* depending on specificity.
|
padding: 0;
|
||||||
*
|
}
|
||||||
* Consider organizing styles into separate files for maintainability.
|
|
||||||
*/
|
li {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-task {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-task .task-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
48
app/controllers/projects_controller.rb
Normal file
48
app/controllers/projects_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
class ProjectsController < ApplicationController
|
||||||
|
def index
|
||||||
|
@projects = Project.all
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@project = Project.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@project = Project.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@project = Project.new(project_params)
|
||||||
|
if @project.save
|
||||||
|
redirect_to @project
|
||||||
|
else
|
||||||
|
render :new
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
@project = Project.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@project = Project.find(params[:id])
|
||||||
|
if @project.update(project_params)
|
||||||
|
redirect_to @project
|
||||||
|
else
|
||||||
|
render :edit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@project = Project.find(params[:id])
|
||||||
|
@project.destroy
|
||||||
|
redirect_to projects_path
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def project_params
|
||||||
|
params.require(:project).permit(:project_name, :description)
|
||||||
|
end
|
||||||
|
end
|
||||||
53
app/controllers/tasks_controller.rb
Normal file
53
app/controllers/tasks_controller.rb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
class TasksController < ApplicationController
|
||||||
|
before_action :set_project
|
||||||
|
|
||||||
|
def create
|
||||||
|
@task = @project.tasks.build(task_params)
|
||||||
|
|
||||||
|
if @task.save
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream
|
||||||
|
format.html { redirect_to @project, notice: "Task created!" }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.replace("new_task", partial: "tasks/form", locals: { project: @project, task: @task }) }
|
||||||
|
format.html { render "projects/show", status: :unprocessable_entity }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@task = @project.tasks.find_by(id: params[:id])
|
||||||
|
return unless @task
|
||||||
|
|
||||||
|
task_dom_id = "task_#{@task.id}"
|
||||||
|
@task.destroy
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream { render turbo_stream: turbo_stream.remove(task_dom_id) }
|
||||||
|
format.html { redirect_to @project }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
def complete
|
||||||
|
@task = @project.tasks.find(params[:id])
|
||||||
|
@task.update(status: "done")
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.turbo_stream
|
||||||
|
format.html { redirect_to @project }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_project
|
||||||
|
@project = Project.find(params[:project_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def task_params
|
||||||
|
params.require(:task).permit(:title, :status)
|
||||||
|
end
|
||||||
|
end
|
||||||
2
app/helpers/articles_helper.rb
Normal file
2
app/helpers/articles_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module ArticlesHelper
|
||||||
|
end
|
||||||
2
app/helpers/projects_helper.rb
Normal file
2
app/helpers/projects_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module ProjectsHelper
|
||||||
|
end
|
||||||
2
app/helpers/tasks_helper.rb
Normal file
2
app/helpers/tasks_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
module TasksHelper
|
||||||
|
end
|
||||||
5
app/models/project.rb
Normal file
5
app/models/project.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class Project < ApplicationRecord
|
||||||
|
has_many :tasks, dependent: :destroy
|
||||||
|
|
||||||
|
validates :project_name, :description, presence: true
|
||||||
|
end
|
||||||
7
app/models/task.rb
Normal file
7
app/models/task.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class Task < ApplicationRecord
|
||||||
|
belongs_to :project
|
||||||
|
|
||||||
|
enum :status, { to_do: 0, in_progress: 1, done: 2 }
|
||||||
|
|
||||||
|
validates :title, presence: true
|
||||||
|
end
|
||||||
2
app/views/articles/index.html.erb
Normal file
2
app/views/articles/index.html.erb
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Articles#index</h1>
|
||||||
|
<p>Find me in app/views/articles/index.html.erb</p>
|
||||||
35
app/views/projects/index.html.erb
Normal file
35
app/views/projects/index.html.erb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<h1>Projects</h1>
|
||||||
|
|
||||||
|
<div class="projects-list">
|
||||||
|
<ul>
|
||||||
|
<% @projects.each do |project| %>
|
||||||
|
<% total_tasks = project.tasks.count %>
|
||||||
|
<% done_tasks_count = project.tasks.where(status: "done").count %>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<div class="project-item">
|
||||||
|
<%= link_to project.project_name, project_path(project) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="project-actions">
|
||||||
|
<%= link_to "Destroy", project_path(project), data: {
|
||||||
|
turbo_method: :delete,
|
||||||
|
turbo_confirm: "Are you sure?"
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-done">
|
||||||
|
<% if total_tasks.zero? %>
|
||||||
|
No tasks yet
|
||||||
|
<% else %>
|
||||||
|
Done tasks: <%= ((done_tasks_count.to_f / total_tasks) * 100).round %>%
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<h2><%= link_to "Add Project", new_project_path %></h2>
|
||||||
23
app/views/projects/new.html.erb
Normal file
23
app/views/projects/new.html.erb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<h1>New Project</h1>
|
||||||
|
|
||||||
|
<%= form_with model: @project do |form| %>
|
||||||
|
<div>
|
||||||
|
<%= form.label :project_name %><br>
|
||||||
|
<%= form.text_field :project_name %>
|
||||||
|
<% @project.errors.full_messages_for(:project_name).each do |message| %>
|
||||||
|
<p><%= message %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :description %><br>
|
||||||
|
<%= form.text_area :description %>
|
||||||
|
<% @project.errors.full_messages_for(:description).each do |message| %>
|
||||||
|
<p><%= message %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.submit %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
22
app/views/projects/show.html.erb
Normal file
22
app/views/projects/show.html.erb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<h1><%= @project.project_name %></h1>
|
||||||
|
<p><%= @project.description %></p>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag "tasks" do %>
|
||||||
|
|
||||||
|
<ul id="active_tasks">
|
||||||
|
<% @project.tasks.where(status: %w[to_do in_progress]).each do |task| %>
|
||||||
|
<%= render partial: "tasks/task", locals: { task: task } %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul id="done_tasks">
|
||||||
|
<% @project.tasks.where(status: "done").each do |task| %>
|
||||||
|
<%= render partial: "tasks/task", locals: { task: task } %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= turbo_frame_tag "new_task" do %>
|
||||||
|
<%= render "tasks/form", project: @project, task: Task.new %>
|
||||||
|
<% end %>
|
||||||
15
app/views/tasks/_form.html.erb
Normal file
15
app/views/tasks/_form.html.erb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<h2>Add New Task</h2>
|
||||||
|
|
||||||
|
<%= form_with model: [project, task], local: false do |f| %>
|
||||||
|
<p>
|
||||||
|
<%= f.label :title %><br>
|
||||||
|
<%= f.text_field :title %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= f.label :status %><br>
|
||||||
|
<%= f.select :status, Task.statuses.keys.reject { |s| s == "done" } %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= f.submit "Create Task" %>
|
||||||
|
<% end %>
|
||||||
18
app/views/tasks/_task.html.erb
Normal file
18
app/views/tasks/_task.html.erb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<li id="<%= dom_id(task) %>" class="task-item <%= 'done-task' if task.status == 'done' %>">
|
||||||
|
<div class="task-title"><%= task.title %></div>
|
||||||
|
<div class="task-status"><%= task.status.titleize %></div>
|
||||||
|
<div class="task-actions flex gap-2">
|
||||||
|
<% unless task.status == "done" %>
|
||||||
|
<%= button_to "Complete",
|
||||||
|
complete_project_task_path(task.project, task),
|
||||||
|
method: :patch,
|
||||||
|
data: { turbo_frame: dom_id(task) },
|
||||||
|
class: "task-btn complete-btn" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to "Destroy",
|
||||||
|
project_task_path(task.project, task),
|
||||||
|
data: { turbo_method: :delete, turbo_confirm: "Are you sure?" },
|
||||||
|
class: "task-btn destroy-btn" %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
3
app/views/tasks/complete.turbo_stream.erb
Normal file
3
app/views/tasks/complete.turbo_stream.erb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<%= turbo_stream.replace dom_id(@task) do %>
|
||||||
|
<%= render partial: "tasks/task", locals: { task: @task } %>
|
||||||
|
<% end %>
|
||||||
7
app/views/tasks/create.turbo_stream.erb
Normal file
7
app/views/tasks/create.turbo_stream.erb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<%= turbo_stream.prepend "active_tasks" do %>
|
||||||
|
<%= render partial: "tasks/task", locals: { task: @task } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= turbo_stream.replace "new_task" do %>
|
||||||
|
<%= render "tasks/form", project: @project, task: Task.new %>
|
||||||
|
<% end %>
|
||||||
1
app/views/tasks/destroy.turbo_stream.erb
Normal file
1
app/views/tasks/destroy.turbo_stream.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= turbo_stream.remove dom_id(@task) %>
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
|
||||||
|
root "projects#index"
|
||||||
|
|
||||||
|
resources :projects do
|
||||||
|
resources :tasks do
|
||||||
|
member do
|
||||||
|
patch :complete
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
|
|||||||
9
db/migrate/20260126072711_create_projects.rb
Normal file
9
db/migrate/20260126072711_create_projects.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class CreateProjects < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :projects do |t|
|
||||||
|
t.string :project_name
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
10
db/migrate/20260126072814_create_tasks.rb
Normal file
10
db/migrate/20260126072814_create_tasks.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class CreateTasks < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :tasks do |t|
|
||||||
|
t.string :title
|
||||||
|
t.string :status
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20260126082816_add_description_to_projects.rb
Normal file
5
db/migrate/20260126082816_add_description_to_projects.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AddDescriptionToProjects < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :projects, :description, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
6
db/migrate/20260126091632_change_status_type_in_tasks.rb
Normal file
6
db/migrate/20260126091632_change_status_type_in_tasks.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class ChangeStatusTypeInTasks < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
remove_column :tasks, :status, :string
|
||||||
|
add_column :tasks, :status, :integer, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
5
db/migrate/20260126094649_add_project_to_tasks.rb
Normal file
5
db/migrate/20260126094649_add_project_to_tasks.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class AddProjectToTasks < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_reference :tasks, :project, null: false, foreign_key: true
|
||||||
|
end
|
||||||
|
end
|
||||||
31
db/schema.rb
generated
Normal file
31
db/schema.rb
generated
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[8.1].define(version: 2026_01_26_094649) do
|
||||||
|
create_table "projects", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "description"
|
||||||
|
t.string "project_name"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "tasks", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "project_id", null: false
|
||||||
|
t.integer "status", default: 0, null: false
|
||||||
|
t.string "title"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["project_id"], name: "index_tasks_on_project_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "tasks", "projects"
|
||||||
|
end
|
||||||
40
db/seeds.rb
40
db/seeds.rb
@@ -7,3 +7,43 @@
|
|||||||
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
||||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||||
# end
|
# end
|
||||||
|
# db/seeds.rb
|
||||||
|
|
||||||
|
Task.destroy_all
|
||||||
|
Project.destroy_all
|
||||||
|
|
||||||
|
project1 = Project.create!(
|
||||||
|
project_name: "project one",
|
||||||
|
description: "This is project one"
|
||||||
|
)
|
||||||
|
|
||||||
|
project2 = Project.create!(
|
||||||
|
project_name: "project two",
|
||||||
|
description: "This is project two"
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.create!(
|
||||||
|
title: "first task of project one",
|
||||||
|
status: :to_do,
|
||||||
|
project: project1
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.create!(
|
||||||
|
title: "second task of project one",
|
||||||
|
status: :in_progress,
|
||||||
|
project: project1
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.create!(
|
||||||
|
title: "third task of project one",
|
||||||
|
status: :done,
|
||||||
|
project: project1
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.create!(
|
||||||
|
title: "first task of project two",
|
||||||
|
status: :to_do,
|
||||||
|
project: project2
|
||||||
|
)
|
||||||
|
|
||||||
|
puts "Seeding complete"
|
||||||
8
test/controllers/projects_controller_test.rb
Normal file
8
test/controllers/projects_controller_test.rb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ProjectsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "should get index" do
|
||||||
|
get projects_index_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
7
test/controllers/tasks_controller_test.rb
Normal file
7
test/controllers/tasks_controller_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TasksControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/fixtures/projects.yml
vendored
Normal file
7
test/fixtures/projects.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
project_name: MyString
|
||||||
|
|
||||||
|
two:
|
||||||
|
project_name: MyString
|
||||||
9
test/fixtures/tasks.yml
vendored
Normal file
9
test/fixtures/tasks.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
one:
|
||||||
|
title: MyString
|
||||||
|
status: MyString
|
||||||
|
|
||||||
|
two:
|
||||||
|
title: MyString
|
||||||
|
status: MyString
|
||||||
7
test/models/project_test.rb
Normal file
7
test/models/project_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ProjectTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/task_test.rb
Normal file
7
test/models/task_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TaskTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user