Consolidate all the error messages - add some stimulus controller.

This commit is contained in:
Dan Milne
2025-11-07 16:58:28 +11:00
parent 631b2b53bb
commit f02665f690
13 changed files with 417 additions and 167 deletions

View File

@@ -0,0 +1,85 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages flash message display, auto-dismissal, and user interactions
* Supports different flash types with appropriate styling and behavior
*/
export default class extends Controller {
static values = {
autoDismiss: String, // "false" or delay in milliseconds
type: String
}
connect() {
// Auto-dismiss if enabled
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
// Smooth entrance animation
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Animate in
requestAnimationFrame(() => {
this.element.style.opacity = '1'
this.element.style.transform = 'translateY(0)'
})
}
/**
* Dismisses the flash message with smooth animation
*/
dismiss() {
// Add dismiss animation
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Remove from DOM after animation
setTimeout(() => {
this.element.remove()
}, 300)
}
/**
* Schedules auto-dismissal based on the configured delay
*/
scheduleAutoDismiss() {
const delay = parseInt(this.autoDismissValue)
if (delay > 0) {
setTimeout(() => {
this.dismiss()
}, delay)
}
}
/**
* Pause auto-dismissal on hover (for user reading)
*/
mouseEnter() {
if (this.autoDismissTimer) {
clearTimeout(this.autoDismissTimer)
this.autoDismissTimer = null
}
}
/**
* Resume auto-dismissal when hover ends
*/
mouseLeave() {
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
}
/**
* Handle keyboard interactions
*/
keydown(event) {
if (event.key === 'Escape' || event.key === 'Enter') {
this.dismiss()
}
}
}

View File

@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages form error display and dismissal
* Provides consistent error handling across all forms
*/
export default class extends Controller {
static targets = ["container"]
/**
* Dismisses the error container with a smooth fade-out animation
*/
dismiss() {
if (!this.hasContainerTarget) return
// Add transition classes
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
// Remove from DOM after animation completes
setTimeout(() => {
this.containerTarget.remove()
}, 300)
}
/**
* Shows server-side validation errors after form submission
* Auto-focuses the first error field for better accessibility
*/
connect() {
// Auto-focus first error field if errors exist
this.focusFirstErrorField()
// Scroll to errors if needed
this.scrollToErrors()
}
/**
* Focuses the first field with validation errors
*/
focusFirstErrorField() {
if (!this.hasContainerTarget) return
// Find first form field with errors (look for error classes or aria-invalid)
const form = this.element.closest('form')
if (!form) return
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
if (errorField) {
setTimeout(() => {
errorField.focus()
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
/**
* Scrolls error container into view if it's not visible
*/
scrollToErrors() {
if (!this.hasContainerTarget) return
const rect = this.containerTarget.getBoundingClientRect()
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
if (!isInViewport) {
setTimeout(() => {
this.containerTarget.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
}, 100)
}
}
/**
* Auto-dismisses success messages after a delay
* Can be called from other controllers
*/
autoDismiss(delay = 5000) {
if (!this.hasContainerTarget) return
setTimeout(() => {
this.dismiss()
}, delay)
}
}

View File

@@ -1,7 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -0,0 +1,81 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["textarea", "status"]
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
connect() {
this.validate()
}
validate() {
const value = this.textareaTarget.value.trim()
if (!value) {
this.clearStatus()
return true
}
try {
JSON.parse(value)
this.showValid()
return true
} catch (error) {
this.showInvalid(error.message)
return false
}
}
format() {
const value = this.textareaTarget.value.trim()
if (!value) return
try {
const parsed = JSON.parse(value)
const formatted = JSON.stringify(parsed, null, 2)
this.textareaTarget.value = formatted
this.showValid()
} catch (error) {
this.showInvalid(error.message)
}
}
clearStatus() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.remove(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = ""
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
}
}
showValid() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.add(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = "✓ Valid JSON"
this.statusTarget.classList.remove(...this.invalidStatusClasses)
this.statusTarget.classList.add(...this.validStatusClasses)
}
}
showInvalid(errorMessage) {
this.textareaTarget.classList.remove(...this.validClasses)
this.textareaTarget.classList.add(...this.invalidClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
this.statusTarget.classList.remove(...this.validStatusClasses)
this.statusTarget.classList.add(...this.invalidStatusClasses)
}
}
insertSample(event) {
event.preventDefault()
const sample = event.params.json || event.target.dataset.jsonSample
if (sample) {
this.textareaTarget.value = sample
this.format()
}
}
}

View File

@@ -1,51 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["userSelect", "assignLink", "editForm"]
connect() {
console.log("Role management controller connected")
}
assignRole(event) {
event.preventDefault()
const link = event.currentTarget
const roleId = link.dataset.roleId
const select = document.getElementById(`assign-user-${roleId}`)
if (!select.value) {
alert("Please select a user")
return
}
// Update the href with the selected user ID
const originalHref = link.href
const newHref = originalHref.replace("PLACEHOLDER", select.value)
// Navigate to the updated URL
window.location.href = newHref
}
toggleEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.toggle("hidden")
}
}
hideEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.add("hidden")
}
}
}

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %> <%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
<% if application.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% application.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -73,12 +56,25 @@
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p> <p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :headers_config, rows: 10, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}' %> <%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1"> <div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p> <p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p> <p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2"> <details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary> <summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs"> <div class="mt-2 ml-4 space-y-1 text-xs">

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %> <%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<% if group.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% group.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -49,10 +32,25 @@
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p> <p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"roles": ["admin", "editor"]}' %> <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p> class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"roles": ["admin", "editor"]}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %> <%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<% if user.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %> <%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
@@ -52,10 +35,25 @@
<% end %> <% end %>
</div> </div>
<div> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: '{"department": "engineering", "level": "senior"}' %> <%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p> class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"department": "engineering", "level": "senior"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">

View File

@@ -5,7 +5,7 @@
<h1 class="font-bold text-4xl">Forgot your password?</h1> <h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %> <%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<div class="my-5"> <div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div> </div>

View File

@@ -3,7 +3,7 @@
<h1 class="font-bold text-4xl">Sign in to Clinch</h1> <h1 class="font-bold text-4xl">Sign in to Clinch</h1>
</div> </div>
<%= form_with url: signin_path, class: "contents" do |form| %> <%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>

View File

@@ -1,29 +1,86 @@
<% if flash[:alert] %> <%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert"> <% flash.each do |type, message| %>
<% next if message.blank? %>
<%
# Map flash types to styling
case type.to_s
when 'notice'
bg_class = 'bg-green-50'
text_class = 'text-green-800'
icon_class = 'text-green-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
auto_dismiss = true
when 'alert', 'error'
bg_class = 'bg-red-50'
text_class = 'text-red-800'
icon_class = 'text-red-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
auto_dismiss = false
when 'warning'
bg_class = 'bg-yellow-50'
text_class = 'text-yellow-800'
icon_class = 'text-yellow-400'
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
auto_dismiss = false
when 'info'
bg_class = 'bg-blue-50'
text_class = 'text-blue-800'
icon_class = 'text-blue-400'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = true
else
# Default styling for unknown types
bg_class = 'bg-gray-50'
text_class = 'text-gray-800'
icon_class = 'text-gray-400'
icon_path = 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
auto_dismiss = false
end
%>
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
role="alert"
data-controller="flash"
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
data-flash-type-value="<%= type %>">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3 flex-1">
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p> <p class="text-sm font-medium <%= text_class %>"><%= message %></p>
</div> </div>
<% if auto_dismiss || type.to_s != 'alert' %>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button"
data-action="click->flash#dismiss"
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
aria-label="Dismiss">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
</div>
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
<% if flash[:notice] %> <%# Helper method for border colors %>
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert"> <%
<div class="flex"> def border_class_for(type)
<div class="flex-shrink-0"> case type.to_s
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor"> when 'notice' then 'border-green-200'
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> when 'alert', 'error' then 'border-red-200'
</svg> when 'warning' then 'border-yellow-200'
</div> when 'info' then 'border-blue-200'
<div class="ml-3"> else 'border-gray-200'
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p> end
</div> end
</div> %>
</div>
<% end %>

View File

@@ -1,23 +1,36 @@
<% if form.object.errors.any? %> <%# Usage: <%= render "shared/form_errors", object: @user %> %>
<div class="rounded-md bg-red-50 p-4"> <%# Usage: <%= render "shared/form_errors", form: form %> %>
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %>
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3"> <div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-red-800"> <h3 id="form-errors-title" class="text-sm font-medium text-red-800">
There were <%= pluralize(form.object.errors.count, "error") %> with your submission: <%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
</h3> </h3>
<div class="mt-2 text-sm text-red-700"> <div class="mt-2">
<ul class="list-disc space-y-1 pl-5"> <ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
<% form.object.errors.full_messages.each do |message| %> <% form_object.errors.full_messages.each do |message| %>
<li><%= message %></li> <li><%= message %></li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
</div> </div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -4,17 +4,8 @@
<p class="mt-2 text-gray-600">Create your admin account to get started</p> <p class="mt-2 text-gray-600">Create your admin account to get started</p>
</div> </div>
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %> <%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<% if @user.errors.any? %> <%= render "shared/form_errors", form: form %>
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
<ul class="list-disc list-inside">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>