Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/channels/import_channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class ImportChannel < ApplicationCable::Channel
def subscribed
import = Import.find_by(id: params[:id])
return reject unless import && current_user&.admin?

stream_from Imports::ProgressBroadcaster.stream_for(import)
end
end
44 changes: 44 additions & 0 deletions app/controllers/admin/imports_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module Admin
class ImportsController < ApplicationController
before_action :authorize_admin!

def index
render inertia: "Admin/Imports/Index", props: {
imports: -> { ImportSerializer.collection(scope.recent.with_attached_file.limit(25)) }
}
end

def new
render inertia: "Admin/Imports/New"
end

def show
import = scope.find(params[:id])

render inertia: "Admin/Imports/Show", props: {
import: -> { ImportSerializer.new(import).as_json }
}
end

def create
import = Current.user.imports.new(import_params)

if import.save
ProcessImportJob.perform_later(import)
redirect_to admin_import_path(import), notice: "Import queued."
else
redirect_to new_admin_import_path, inertia: { errors: import.errors }
end
end

private

def scope = Import.all

def import_params = params.expect(import: [ :file ])

def authorize_admin!
raise Authorization::NotAuthorizedError unless Current.user&.admin?
end
end
end
25 changes: 25 additions & 0 deletions app/imports/imports/csv_row_set.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Imports
class CsvRowSet < RowSet
def each
return enum_for(:each) unless block_given?

headers = nil
index = 0

CSV.foreach(@path, encoding: "bom|utf-8", liberal_parsing: true) do |values|
if headers.nil?
headers = UserRow.normalize_headers(values)
raise MalformedFile, "No recognisable columns found" if headers.compact.empty?
next
end

next if values.all?(&:blank?)

index += 1
yield index, UserRow.from(headers, values)
end
rescue CSV::MalformedCSVError => error
raise MalformedFile, error.message
end
end
end
9 changes: 9 additions & 0 deletions app/imports/imports/progress_broadcaster.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Imports
class ProgressBroadcaster
def self.stream_for(import) = "import:#{import.id}"

def self.call(import)
ActionCable.server.broadcast(stream_for(import), { type: "import.changed", id: import.id })
end
end
end
20 changes: 20 additions & 0 deletions app/imports/imports/row_set.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Imports
class RowSet
include Enumerable

class MalformedFile < StandardError; end

def self.for(import, path)
case import.format
when :xlsx then SpreadsheetRowSet.new(path)
else CsvRowSet.new(path)
end
end

def initialize(path)
@path = path
end

def count = @count ||= each.count
end
end
28 changes: 28 additions & 0 deletions app/imports/imports/spreadsheet_row_set.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module Imports
class SpreadsheetRowSet < RowSet
def each
return enum_for(:each) unless block_given?

sheet = Roo::Excelx.new(@path)
headers = nil
index = 0

sheet.each_row_streaming(pad_cells: true) do |row|
values = row.map { _1&.value }

if headers.nil?
headers = UserRow.normalize_headers(values)
raise MalformedFile, "No recognisable columns found" if headers.compact.empty?
next
end

next if values.all?(&:blank?)

index += 1
yield index, UserRow.from(headers, values)
end
rescue Roo::Error, Zip::Error => error
raise MalformedFile, error.message
end
end
end
27 changes: 27 additions & 0 deletions app/imports/imports/user_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Imports
class UserImporter
Result = Data.define(:outcome, :errors) do
def created? = outcome == :created
def skipped? = outcome == :skipped
def failed? = outcome == :failed
end

def call(row)
return Result.new(outcome: :failed, errors: row.errors.full_messages) if row.invalid?

user = User.find_or_initialize_by(email_address: row.normalized_email)
return Result.new(outcome: :skipped, errors: []) if user.persisted?

user.assign_attributes(row.to_user_attributes)

if user.save
Result.new(outcome: :created, errors: [])
else
Result.new(outcome: :failed, errors: user.errors.full_messages)
end
rescue ActiveRecord::RecordNotUnique
# Lost a race with a concurrent import or signup on the unique index.
Result.new(outcome: :skipped, errors: [])
end
end
end
49 changes: 49 additions & 0 deletions app/imports/imports/user_row.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
module Imports
class UserRow
include ActiveModel::Model
include ActiveModel::Attributes

HEADER_ALIASES = {
"full_name" => :full_name, "name" => :full_name, "fullname" => :full_name, "nome" => :full_name,
"email" => :email_address, "email_address" => :email_address, "e_mail" => :email_address,
"role" => :role, "perfil" => :role,
"avatar" => :avatar_url, "avatar_url" => :avatar_url, "photo" => :avatar_url
}.freeze

attribute :full_name, :string
attribute :email_address, :string
attribute :role, :string, default: "member"
attribute :avatar_url, :string

validates :full_name, presence: true, length: { in: 2..120 }
validates :email_address, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: User.roles.keys, message: "must be admin or member" }
validates :avatar_url, format: { with: %r{\Ahttps://\S+\z} }, allow_blank: true

def self.normalize_headers(headers)
headers.map do |header|
key = header.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
HEADER_ALIASES[key]
end
end

def self.from(headers, values)
attributes = headers.zip(values).to_h.compact.except(nil)
new(attributes.transform_values { sanitize(_1) })
end

def self.sanitize(value)
text = value.is_a?(String) ? value : value.to_s
# Strip leading =, +, -, @ so a cell like "=cmd|..." cannot become a live
# formula if this data is ever re-exported to a spreadsheet.
text.squish.sub(/\A[=+\-@\t\r]+/, "")
end

def normalized_email = email_address.to_s.strip.downcase

def to_user_attributes
{ full_name:, email_address: normalized_email, role:, avatar_url: avatar_url.presence,
password: SecureRandom.base58(24) }
end
end
end
20 changes: 20 additions & 0 deletions app/javascript/components/ImportStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ImportStatus } from '@/types'

const STYLES: Record<ImportStatus, string> = {
pending: 'bg-slate-100 text-slate-600 ring-slate-200',
parsing: 'bg-amber-50 text-amber-700 ring-amber-200',
processing: 'bg-amber-50 text-amber-700 ring-amber-200',
completed: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
failed: 'bg-red-50 text-red-700 ring-red-200',
cancelled: 'bg-slate-100 text-slate-500 ring-slate-200',
}

export default function ImportStatusBadge({ status }: { status: ImportStatus }) {
return (
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium capitalize ring-1 ring-inset ${STYLES[status]}`}
>
{status}
</span>
)
}
9 changes: 6 additions & 3 deletions app/javascript/components/UserForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P
email_address: user?.email_address ?? '',
password: '',
password_confirmation: '',
avatar_url: user?.avatar_url ?? '',
avatar_url: user?.remote_avatar_url ?? '',
avatar_image: null as File | null,
role: user?.role ?? ('member' as UserRole),
})
Expand All @@ -26,8 +26,11 @@ export default function UserForm({ user, roles, action, method, submitLabel }: P
const submit = (event: FormEvent) => {
event.preventDefault()

// Inertia cannot send multipart over PATCH. Spoof the verb and force FormData.
form.transform((current) => (method === 'patch' ? { ...current, _method: 'patch' } : current))

form.transform(({ avatar_image, ...fields }) => ({
user: avatar_image ? { ...fields, avatar_image } : fields,
...(method === 'patch' ? { _method: 'patch' } : {}),
}))
form.post(action, { forceFormData: true, preserveScroll: true })
}

Expand Down
5 changes: 1 addition & 4 deletions app/javascript/hooks/useDashboardStream.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { createConsumer, type Consumer } from '@rails/actioncable'
import { router } from '@inertiajs/react'
import { useEffect, useRef } from 'react'

let consumer: Consumer | null = null
const getConsumer = () => (consumer ??= createConsumer())
import { getConsumer } from '@/lib/cable'

export function useDashboardStream() {
const pending = useRef<number | null>(null)
Expand Down
10 changes: 10 additions & 0 deletions app/javascript/lib/cable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createConsumer, type Consumer } from '@rails/actioncable'

let consumer: Consumer | null = null

/**
* One cable connection for the whole app. Every createConsumer() opens its own
* WebSocket and unsubscribing never closes it, so a consumer per component
* leaves a socket behind each time that component's effect runs.
*/
export const getConsumer = () => (consumer ??= createConsumer())
86 changes: 86 additions & 0 deletions app/javascript/pages/Admin/Imports/Index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Head, Link } from '@inertiajs/react'
import AppLayout from '@/layouts/AppLayout'
import ImportStatusBadge from '@/components/ImportStatusBadge'
import type { Import } from '@/types'

/** Props from Admin::ImportsController#index. */
type Props = { imports: Import[] }

const uploadedAt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })

export default function Index({ imports }: Props) {
return (
<>
<Head title="Imports" />

<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Imports</h1>
<div className="flex items-center gap-4">
<Link href="/admin/users" className="text-sm text-slate-600 hover:underline">
Back to users
</Link>
<Link
href="/admin/imports/new"
className="rounded-md bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700"
>
New import
</Link>
</div>
</div>
<p className="mt-1 text-sm text-slate-500">The 25 most recent uploads.</p>

<div className="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500">
<tr>
<th scope="col" className="px-4 py-3">File</th>
<th scope="col" className="px-4 py-3">Status</th>
<th scope="col" className="px-4 py-3">Rows</th>
<th scope="col" className="px-4 py-3 text-right">Created</th>
<th scope="col" className="px-4 py-3 text-right">Skipped</th>
<th scope="col" className="px-4 py-3 text-right">Failed</th>
<th scope="col" className="px-4 py-3">Uploaded</th>
</tr>
</thead>
<tbody>
{imports.map((record) => (
<tr key={record.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3">
<Link href={`/admin/imports/${record.id}`} className="font-medium hover:underline">
{record.filename}
</Link>
</td>
<td className="px-4 py-3"><ImportStatusBadge status={record.status} /></td>
<td className="px-4 py-3 tabular-nums text-slate-600">
{record.processed_rows.toLocaleString()} of {record.total_rows.toLocaleString()}
<span className="text-slate-400"> ({record.progress}%)</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">{record.created_count.toLocaleString()}</td>
<td className="px-4 py-3 text-right tabular-nums">{record.skipped_count.toLocaleString()}</td>
<td className={`px-4 py-3 text-right tabular-nums ${record.failed_count > 0 ? 'text-red-600' : ''}`}>
{record.failed_count.toLocaleString()}
</td>
<td className="px-4 py-3 text-slate-600">
<time dateTime={record.created_at}>{uploadedAt.format(new Date(record.created_at))}</time>
</td>
</tr>
))}
{imports.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
No imports yet.{' '}
<Link href="/admin/imports/new" className="text-slate-900 underline">
Upload a spreadsheet
</Link>{' '}
to add users in bulk.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)
}

Index.layout = AppLayout
Loading
Loading