Add custom repository table [SCI-1274]

This commit is contained in:
Oleksii Kriuchykhin 2017-06-06 17:35:29 +02:00
parent 2ebcdedf57
commit 8b1190060e
32 changed files with 2683 additions and 11 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,3 +3,12 @@
max-height: 400px;
overflow-x: hidden;
}
.repository-table {
margin-top: 20px;
// Datatables generated name
.dataTables_length {
display: inline-block;
}
}

View file

@ -8,8 +8,11 @@ class MyModulesController < ApplicationController
results samples activities activities_tab
assign_samples unassign_samples delete_samples
toggle_task_state samples_index archive
complete_my_module repository]
complete_my_module repository repository_index
assign_repository_records unassign_repository_records]
before_action :load_vars_nested, only: %I[new create]
before_action :load_repository, only: %I[assign_repository_records
unassign_repository_records]
before_action :check_edit_permissions,
only: %I[update description due_date]
before_action :check_destroy_permissions, only: :destroy
@ -363,6 +366,123 @@ class MyModulesController < ApplicationController
end
end
# AJAX actions
def repository_index
@repository = Repository.find_by_id(params[:repository_id])
if @repository.nil? || !can_view_repository(@repository)
render_403
else
respond_to do |format|
format.html
format.json do
render json: ::RepositoryDatatable.new(view_context,
@repository,
@my_module,
current_user)
end
end
end
end
# Submit actions
def assign_repository_records
render_403 && return unless can_assign_repository_records(@my_module,
@repository)
if params[:selected_rows].present? && params[:repository_id].present?
records_names = []
params[:selected_rows].each do |id|
record = RepositoryRow.find_by_id(id)
next if !record || @my_module.repository_rows.include?(record)
record.last_modified_by = current_user
record.save
records_names << record.name
MyModulesRepositoryRow.create!(
my_module: @my_module,
repository_row: record,
assigned_by: current_user
)
end
if records_names.any?
Activity.create(
type_of: :assign_repository_record,
project: @project,
experiment: @experiment,
my_module: @my_module,
user: current_user,
message: I18n.t(
'activities.assign_repository_records',
user: current_user.full_name,
task: @my_module.name,
repository: @repository,
records: records_names.join(', ')
)
)
flash = I18n.t('repositories.assigned_records_flash',
records: records_names.join(', '))
respond_to do |format|
format.json { render json: { flash: flash }, status: :ok }
end
else
respond_to do |format|
format.json do
render json: {
flash: t('repositories.no_records_assigned_flash')
}, status: :bad_request
end
end
end
end
end
def unassign_repository_records
render_403 && return unless can_unassign_repository_records(@my_module,
@repository)
if params[:selected_rows].present? && params[:repository_id].present?
records = []
params[:selected_rows].each do |id|
record = RepositoryRow.find_by_id(id)
next unless record && @my_module.repository_rows.include?(record)
record.last_modified_by = current_user
record.save
records << record
end
@my_module.repository_rows.destroy(records & @my_module.repository_rows)
if records.any?
Activity.create(
type_of: :unassign_repository_record,
project: @project,
experiment: @experiment,
my_module: @my_module,
user: current_user,
message: I18n.t(
'activities.unassign_repository_records',
user: current_user.full_name,
task: @my_module.name,
repository: @repository,
records: records.map(&:name).join(', ')
)
)
flash = I18n.t('repositories.unassigned_records_flash',
records: records.map(&:name).join(', '))
respond_to do |format|
format.json { render json: { flash: flash }, status: :ok }
end
else
respond_to do |format|
format.json do
render json: {
flash: t('repositories.no_records_unassigned_flash')
}, status: :bad_request
end
end
end
end
end
# Complete/uncomplete task
def toggle_task_state
respond_to do |format|
@ -456,6 +576,11 @@ class MyModulesController < ApplicationController
end
end
def load_repository
@repository = Repository.find_by_id(params[:repository_id])
render_404 unless @repository && can_view_repository(@repository)
end
def check_edit_permissions
unless can_edit_module(@my_module)
render_403

View file

@ -99,6 +99,24 @@ class RepositoriesController < ApplicationController
end
end
# AJAX actions
def repository_table_index
@repository = Repository.find_by_id(params[:repository_id])
if @repository.nil? || !can_view_repository(@repository)
render_403
else
respond_to do |format|
format.html
format.json do
render json: ::RepositoryDatatable.new(view_context,
@repository,
nil,
current_user)
end
end
end
end
private
def load_vars

View file

@ -0,0 +1,126 @@
class RepositoryColumnsController < ApplicationController
include InputSanitizeHelper
before_action :load_vars, except: :create
before_action :load_vars_nested, only: :create
before_action :check_create_permissions, only: :create
before_action :check_update_permissions, only: :update
before_action :check_destroy_permissions, only: %i(destroy destroy_html)
def create
@repository_column = RepositoryColumn.new(repository_column_params)
@repository_column.repository = @repository
@repository_column.created_by = current_user
@repository_column.data_type = :RepositoryTextValue
respond_to do |format|
if @repository_column.save
format.json do
render json: {
id: @repository_column.id,
name: escape_input(@repository_column.name),
edit_url:
edit_repository_repository_column_path(@repository,
@repository_column),
update_url:
repository_repository_column_path(@repository,
@repository_column),
destroy_html_url:
repository_columns_destroy_html_path(
@repository, @repository_column
)
},
status: :ok
end
else
format.json do
render json: @repository_column.errors.to_json,
status: :unprocessable_entity
end
end
end
end
def edit
respond_to do |format|
format.json do
render json: { status: :ok }
end
end
end
def update
respond_to do |format|
format.json do
@repository_column.update_attributes(repository_column_params)
if @repository_column.save
render json: { status: :ok }
else
render json: @repository_column.errors.to_json,
status: :unprocessable_entity
end
end
end
end
def destroy_html
respond_to do |format|
format.json do
render json: {
html: render_to_string(
partial: 'repositories/delete_column_modal_body.html.erb',
locals: { column_index: params[:column_index] }
)
}
end
end
end
def destroy
@del_repository_column = @repository_column.dup
respond_to do |format|
format.json do
if @repository_column.destroy
RepositoryTable.update_state(
@del_repository_column,
params[:repository_column][:column_index],
current_user
)
render json: { status: :ok }
else
render json: { status: :unprocessable_entity }
end
end
end
end
private
def load_vars
@repository = Repository.find_by_id(params[:repository_id])
render_404 unless @repository
@repository_column = RepositoryColumn.find_by_id(params[:id])
render_404 unless @repository_column
end
def load_vars_nested
@repository = Repository.find_by_id(params[:repository_id])
render_404 unless @repository
end
def check_create_permissions
render_403 unless can_create_columns_in_repository(@repository)
end
def check_update_permissions
render_403 unless can_edit_columns_in_repository(@repository)
end
def check_destroy_permissions
render_403 unless can_delete_columns_in_repository(@repository)
end
def repository_column_params
params.require(:repository_column).permit(:name)
end
end

View file

@ -0,0 +1,248 @@
class RepositoryRowsController < ApplicationController
include InputSanitizeHelper
include ActionView::Helpers::TextHelper
include ApplicationHelper
before_action :load_vars, only: %i(edit update)
before_action :load_repository, only: %i(create delete_records)
before_action :check_create_permissions, only: :create
before_action :check_edit_permissions, only: %i(edit update)
before_action :check_destroy_permissions, only: :delete_records
def create
record = RepositoryRow.new(name: record_params[:name],
repository: @repository,
created_by: current_user,
last_modified_by: current_user)
errors = { default_fields: [],
custom_cells: [] }
record.transaction do
unless record.save
errors[:default_fields] = record.errors.messages
raise ActiveRecord::RecordInvalid
end
if params[:repository_cells]
params[:repository_cells].each do |key, value|
column = @repository.repository_columns.detect do |c|
c.id == key.to_i
end
cell_value = RepositoryTextValue.new(
data: value,
created_by: current_user,
last_modified_by: current_user,
repository_cell_attributes: {
repository_row: record,
repository_column: column
}
)
unless cell_value.save
errors[:custom_cells] << {
"#{cell.repository_column.id}": cell_value.errors.messages
}
raise ActiveRecord::RecordInvalid
end
record_annotation_notification(record, cell_value.repository_cell)
end
end
end
respond_to do |format|
format.json do
render json: { id: record.id,
flash: t('repositories.create.success_flash',
record: escape_input(record.name),
repository: escape_input(@repository.name)) },
status: :ok
end
end
rescue ActiveRecord::RecordInvalid
respond_to do |format|
format.json { render json: errors, status: :bad_request }
end
end
def edit
json = {
repository_row: {
name: escape_input(@record.name),
repository_cells: {}
}
}
# Add custom cells ids as key (easier lookup on js side)
@record.repository_cells.each do |cell|
json[:repository_row][:repository_cells][cell.repository_column_id] = {
repository_cell_id: cell.id,
value: escape_input(cell.value.data)
}
end
respond_to do |format|
format.html
format.json { render json: json }
end
end
def update
errors = {
default_fields: [],
custom_cells: []
}
@record.transaction do
@record.name = record_params[:name]
unless @record.save
errors[:default_fields] = sample.errors.messages
raise ActiveRecord::RecordInvalid
end
if params[:repository_cells]
params[:repository_cells].each do |key, value|
existing = @record.repository_cells.detect do |c|
c.repository_column_id == key.to_i
end
if existing
# Cell exists and new value present, so update value
existing.value.data = value
unless existing.value.save
errors[:custom_cells] << {
"#{cell.repository_column_id}": existing.value.errors.messages
}
raise ActiveRecord::RecordInvalid
end
record_annotation_notification(@record, existing)
else
# Looks like it is a new cell, so we need to create new value, cell
# will be created automatically
column = @repository.repository_columns.detect do |c|
c.id == key.to_i
end
value = RepositoryTextValue.new(
data: value,
created_by: current_user,
last_modified_by: current_user,
repository_cell_attributes: {
repository_row: @record,
repository_column: column
}
)
unless value.save
errors[:custom_cells] << {
"#{cell.repository_column_id}": value.errors.messages
}
raise ActiveRecord::RecordInvalid
end
record_annotation_notification(@record, value.repository_cell)
end
end
# Clean up empty cells, not present in updated record
@record.repository_cells.each do |cell|
cell.value.destroy unless params[:repository_cells]
.key?(cell.repository_column_id.to_s)
end
else
@record.repository_cells.each { |c| c.value.destroy }
end
end
# Row sucessfully updated, so sending response to client
respond_to do |format|
format.json do
render json: {
id: @record.id,
flash: t(
'repositories.update.success_flash',
record: escape_input(@record.name),
repository: escape_input(@repository.name)
)
},
status: :ok
end
end
rescue ActiveRecord::RecordInvalid
respond_to do |format|
format.json { render json: errors, status: :bad_request }
end
end
def delete_records
deleted_count = 0
if params[:selected_rows]
params[:selected_rows].each do |row_id|
row = @repository.repository_rows.find_by_id(row_id)
if row && can_delete_repository_record(row)
row.destroy && deleted_count += 1
end
end
if deleted_count.zero?
flash = t('repositories.destroy.no_deleted_records_flash')
elsif deleted_count != params[:selected_rows].count
not_deleted_count = params[:selected_rows].count - deleted_count
flash = t('repositories.destroy.contains_other_records_flash',
records_number: deleted_count,
other_records_number: not_deleted_count)
else
flash = t('repositories.destroy.success_flash',
records_number: deleted_count)
end
respond_to do |format|
format.json { render json: { flash: flash }, status: :ok }
end
else
respond_to do |format|
format.json do
render json: {
flash: t('repositories.destroy.no_records_selected_flash')
}, status: :bad_request
end
end
end
end
private
def load_vars
@repository = Repository.eager_load(:repository_columns)
.find_by_id(params[:repository_id])
@record = RepositoryRow.eager_load(:repository_columns)
.find_by_id(params[:id])
render_404 unless @repository && @record
end
def load_repository
@repository = Repository.find_by_id(params[:repository_id])
render_404 unless @repository
end
def check_create_permissions
render_403 unless can_create_repository_records(@repository)
end
def check_edit_permissions
render_403 unless can_edit_repository_records(@repository)
end
def check_destroy_permissions
render_403 unless can_delete_repository_records(@repository)
end
def record_params
params.require(:repository_row).permit(:name)
end
def record_annotation_notification(record, cell, old_text = nil)
table_url = params.fetch(:request_url) { :request_url_must_be_present }
smart_annotation_notification(
old_text: (old_text if old_text),
new_text: cell.value.data,
title: t('notifications.repository_annotation_title',
user: current_user.full_name,
column: cell.repository_column.name,
record: record.name,
repository: record.repository),
message: t('notifications.repository_annotation_message_html',
record: link_to(record.name, table_url),
column: link_to(cell.repository_column.name, table_url))
)
end
end

View file

@ -0,0 +1,48 @@
class UserRepositoriesController < ApplicationController
before_action :load_vars
def save_table_state
repository_table = RepositoryTable.where(user: current_user,
repository: @repository).first
if repository_table
repository_table.update(state: params[:state])
else
RepositoryTable.create(user: current_user,
repository: @repository,
state: params[:state])
end
respond_to do |format|
format.json do
render json: {
status: :ok
}
end
end
end
def load_table_state
table_state = RepositoryTable.load_state(current_user,
@repository).first
if table_state.nil?
RepositoryTable.create_state(current_user, @repository)
table_state = RepositoryTable.load_state(current_user,
@repository).first
end
respond_to do |format|
if table_state
format.json do
render json: {
state: table_state
}
end
end
end
end
private
def load_vars
@repository = Repository.find_by_id(params[:repository_id])
render_403 if @repository.nil? || !can_view_repository(@repository)
end
end

View file

@ -0,0 +1,346 @@
require 'active_record'
class RepositoryDatatable < AjaxDatatablesRails::Base
include ActionView::Helpers::TextHelper
include SamplesHelper
include InputSanitizeHelper
include Rails.application.routes.url_helpers
include ActionView::Helpers::UrlHelper
include ApplicationHelper
include ActiveRecord::Sanitization::ClassMethods
ASSIGNED_SORT_COL = 'assigned'.freeze
REPOSITORY_TABLE_DEFAULT_STATE = {
'time' => 0,
'start' => 0,
'length' => 5,
'order' => [[2, 'desc']],
'search' => { 'search' => '',
'smart' => true,
'regex' => false,
'caseInsensitive' => true },
'columns' => [],
'ColReorder' => [*0..4]
}
5.times do
REPOSITORY_TABLE_DEFAULT_STATE['columns'] << {
'visible' => true,
'search' => { 'search' => '',
'smart' => true,
'regex' => false,
'caseInsensitive' => true }
}
end
REPOSITORY_TABLE_DEFAULT_STATE.freeze
def initialize(view,
repository,
my_module = nil,
user = nil)
super(view)
@repository = repository
@team = repository.team
@my_module = my_module
@user = user
end
# Define sortable columns, so 1st column will be sorted by attribute
# in sortable_columns[0]
def sortable_columns
sort_array = [
ASSIGNED_SORT_COL,
'RepositoryRow.name',
'RepositoryRow.created_at',
'User.full_name'
]
sort_array.push(*repository_columns_sort_by)
@sortable_columns = sort_array
end
# Define attributes on which we perform search
def searchable_columns
search_array = [
'RepositoryRow.name',
'RepositoryRow.created_at',
'User.full_name'
]
# search_array.push(*repository_columns_sort_by)
@searchable_columns ||= filter_search_array search_array
end
private
# filters the search array by checking if the the column is visible
def filter_search_array(input_array)
param_index = 2
filtered_array = []
input_array.each do |col|
next if params[:columns].to_a[param_index].nil?
params_col =
params[:columns].to_a.find { |v| v[1]['data'] == param_index.to_s }
filtered_array.push(col) unless params_col[1]['searchable'] == 'false'
param_index += 1
end
filtered_array
end
# Get array of columns to sort by (for custom columns)
def repository_columns_sort_by
array = []
@repository.repository_columns.count.times do
array << 'RepositoryCell.value'
end
array
end
# Returns json of current repository rows (already paginated)
def data
records.map do |record|
row = {
'DT_RowId': record.id,
'1': @my_module ? assigned_row(record) : '',
'2': escape_input(record.name),
'3': I18n.l(record.created_at, format: :full),
'4': escape_input(record.created_by.full_name),
'recordEditUrl':
Rails.application.routes.url_helpers
.edit_repository_repository_row_path(@repository,
record.id),
'recordUpdateUrl':
Rails.application.routes.url_helpers
.repository_repository_row_path(@repository, record.id)
}
# Add custom columns
record.repository_cells.each do |cell|
row[@columns_mappings[cell.repository_column.id]] =
custom_auto_link(cell.value.data,
simple_format: true,
team: @team)
end
row
end
end
def assigned_row(record)
if @assigned_rows && @assigned_rows.include?(record)
"<span class='circle'>&nbsp;</span>"
else
"<span class='circle disabled'>&nbsp;</span>"
end
end
# Query database for records (this will be later paginated and filtered)
# after that "data" function will return json
def get_raw_records
repository_rows = RepositoryRow
.includes(
:repository_columns,
:created_by
# repository_cells: :value
).references(
:repository_columns,
:created_by
)
.where(repository: @repository)
if @my_module
@assigned_rows = @my_module.repository_rows
# repository_rows.joins(
# "LEFT OUTER JOIN my_modules_repository_rows ON
# (repository_row.id = my_modules_repository_rows.repository_row_id AND
# (my_modules_repository_rows.my_module_id = #{@my_module.id} OR
# my_modules_repository_rows.id IS NULL))"
# )
end
# Make mappings of custom columns, so we have same id for every column
i = 5
@columns_mappings = {}
@repository.repository_columns.each do |column|
@columns_mappings[column.id] = i.to_s
i += 1
end
repository_rows
end
# Override default behaviour
# Don't filter and paginate records when sorting by custom column - everything
# is done in sort_records method - you might ask why, well if you want the
# number of samples/all samples it's dependant upon sort_record query
def fetch_records
records = get_raw_records
records = sort_records(records) if params[:order].present?
escape_special_chars
records = filter_records(records) if params[:search].present? &&
!sorting_by_custom_column
records = paginate_records(records) if !(params[:length].present? &&
params[:length] == '-1') &&
!sorting_by_custom_column
records
end
# Override default sort method if needed
def sort_records(records)
if params[:order].present? && params[:order].length == 1
if sort_column(params[:order].values[0]) == ASSIGNED_SORT_COL
# If "assigned" column is sorted
if @my_module
# Depending on the sort, order nulls first or
# nulls last on repository_cells association
direction = sort_null_direction(params[:order].values[0])
records.joins(
"LEFT OUTER JOIN my_modules_repository_rows ON
(repository_rows.id = my_modules_repository_rows.repository_row_id
AND (my_modules_repository_rows.my_module_id = #{@my_module.id} OR
my_modules_repository_rows.id IS NULL))"
).order("my_modules_repository_rows.id NULLS #{direction}")
end
elsif sorting_by_custom_column
# Check if have to filter records first
# if params[:search].present? && params[:search][:value].present?
# # Couldn't force ActiveRecord to yield the same query as below because
# # Rails apparently forgets to join stuff in subqueries -
# # #justrailsthings
# conditions = build_conditions_for(params[:search][:value])
#
# filter_query = %(SELECT "samples"."id" FROM "samples"
# LEFT OUTER JOIN "sample_custom_fields" ON
# "sample_custom_fields"."sample_id" = "samples"."id"
# LEFT OUTER JOIN "users" ON "users"."id" = "repository_row"."user_id"
# WHERE "samples"."team_id" = #{@team.id} AND #{conditions.to_sql})
#
# records = records.where("samples.id IN (#{filter_query})")
# end
ci = sortable_displayed_columns[
params[:order].values[0][:column].to_i - 1
]
column_id = @columns_mappings.key((ci.to_i + 1).to_s)
dir = sort_direction(params[:order].values[0])
# Because repository records can have multiple custom cells,
# we first group them by samples.id and inside that group we sort them by column_id. Because
# we sort them ASC, sorted columns will be on top. Distinct then only
# takes the first row and cuts the rest of every group and voila we have
# 1 row for every sample, which are not sorted yet ...
# records = records.select('DISTINCT ON (repository_rows.id) *')
# .order("repository_rows.id, CASE WHEN repository_cells.repository_column_id = #{column_id} THEN 1 ELSE 2 END ASC")
# ... this little gem (pun intended) then takes the records query, sorts it again
# and paginates it. sq.t0_* are determined empirically and are crucial -
# imagine A -> B -> C transitive relation but where A and C are the
# same. Useless right? But not when you acknowledge that find_by_sql
# method does some funky stuff when your query spans multiple queries -
# Sample object might have id from SampleType, name from
# User ... chaos ensues basically. If something changes in db this might
# change.
# formated_date = (I18n.t 'time.formats.datatables_date').gsub!(/^\"|\"?$/, '')
# Sample.find_by_sql("SELECT sq.t0_r0 as id, sq.t0_r1 as name, to_char( sq.t0_r4, '#{ formated_date }' ) as created_at, sq.t0_r5, s, sq.t0_r2 as user_id, sq.custom_field_id FROM (#{records.to_sql})
# as sq ORDER BY CASE WHEN sq.custom_field_id = #{column_id} THEN 1 ELSE 2 END #{dir}, sq.value #{dir}
# LIMIT #{per_page} OFFSET #{offset}")
RepositoryRow.find_by_sql(
"SELECT repository_rows.*, values.value AS value
FROM repository_rows
LEFT OUTER JOIN (SELECT repository_cells.*,
repository_text_values.data AS value FROM repository_cells
INNER JOIN repository_text_values
ON repository_text_values.id = repository_cells.value_id
WHERE repository_cells.repository_column_id = #{column_id}) AS values
ON values.repository_row_id = repository_rows.id
WHERE repository_rows.repository_id = #{@repository.id}
ORDER BY value #{dir} LIMIT #{per_page} OFFSET #{offset}"
)
else
super(records)
end
else
super(records)
end
end
# A hack that overrides the new_search_contition method default behavior of the ajax-datatables-rails gem
# now the method checks if the column is the created_at and generate a custom SQL to parse
# it back to the caller method
# def new_search_condition(column, value)
# model, column = column.split('.')
# model = model.constantize
# formated_date = (I18n.t 'time.formats.datatables_date').gsub!(/^\"|\"?$/, '')
# if model == SampleCustomField
# # Find visible (searchable) custom field IDs, and only perform filter
# # on those custom fields
# searchable_cfs = params[:columns].select do |_, v|
# v['searchable'] == 'true' && @cf_mappings.values.include?(v['data'])
# end
# cfmi = @cf_mappings.invert
# cf_ids = searchable_cfs.map { |_, v| cfmi[v['data']] }
#
# # Do an ILIKE on 'value', as well as make sure to only include
# # custom fields that have 'custom_field_id' among visible custom fields
# casted_column = ::Arel::Nodes::NamedFunction.new(
# 'CAST',
# [model.arel_table[column.to_sym].as(typecast)]
# )
# casted_column = casted_column.matches("%#{value}%")
# casted_column = casted_column.and(
# model.arel_table['custom_field_id'].in(cf_ids)
# )
# casted_column
# elsif column == 'created_at'
# casted_column = ::Arel::Nodes::NamedFunction.new('CAST',
# [ Arel.sql("to_char( samples.created_at, '#{ formated_date }' ) AS VARCHAR") ] )
# casted_column.matches("%#{sanitize_sql_like(value)}%")
# else
# casted_column = ::Arel::Nodes::NamedFunction.new('CAST',
# [model.arel_table[column.to_sym].as(typecast)])
# casted_column.matches("%#{sanitize_sql_like(value)}%")
# end
# end
def sort_null_direction(item)
val = sort_direction(item)
val == 'ASC' ? 'LAST' : 'FIRST'
end
def inverse_sort_direction(item)
val = sort_direction(item)
val == 'ASC' ? 'DESC' : 'ASC'
end
def sorting_by_custom_column
sort_column(params[:order].values[0]) == 'repository_cells.value'
end
# Escapes special characters in search query
def escape_special_chars
if params[:search].present?
params[:search][:value] = ActiveRecord::Base
.__send__(:sanitize_sql_like,
params[:search][:value])
end
end
def new_sort_column(item)
coli = item[:column].to_i - 1
model, column = sortable_columns[sortable_displayed_columns[coli].to_i]
.split('.')
return model if model == ASSIGNED_SORT_COL
col = [model.constantize.table_name, column].join('.')
end
def generate_sortable_displayed_columns
sort_order = RepositoryTable.where(user: @user, repository: @repository)
.pluck(:state)
.first['ColReorder']
sort_order.shift
sort_order.map! { |i| (i.to_i - 1).to_s }
@sortable_displayed_columns = sort_order
end
end

View file

@ -1069,4 +1069,44 @@ module PermissionHelper
def can_edit_and_destroy_repository(repository)
is_admin_of_team(repository.team)
end
def can_create_columns_in_repository(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_delete_columns_in_repository(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_edit_columns_in_repository(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_create_repository_records(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_edit_repository_records(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_delete_repository_records(repository)
is_normal_user_or_admin_of_team(repository.team)
end
def can_delete_repository_record(record)
team = record.repository.team
is_admin_of_team(team) || (is_normal_user_of_team(team) &&
record.created_by == current_user)
end
def can_assign_repository_records(my_module, repository)
can_edit_repository_records(repository) &&
is_technician_or_higher_of_project(my_module.experiment.project)
end
def can_unassign_repository_records(my_module, repository)
can_edit_repository_records(repository) &&
is_technician_or_higher_of_project(my_module.experiment.project)
end
end

View file

@ -58,7 +58,9 @@ class Activity < ActiveRecord::Base
:assign_sample,
:unassign_sample,
:complete_task,
:uncomplete_task
:uncomplete_task,
:assign_repository_record,
:unassign_repository_record
]
validates :type_of, presence: true

View file

@ -30,6 +30,9 @@ class MyModule < ActiveRecord::Base
has_many :my_module_antecessors, through: :inputs, source: :from, class_name: 'MyModule'
has_many :sample_my_modules, inverse_of: :my_module, :dependent => :destroy
has_many :samples, through: :sample_my_modules
has_many :my_modules_repository_rows,
inverse_of: :my_module, dependent: :destroy
has_many :repository_rows, through: :my_modules_repository_rows
has_many :user_my_modules, inverse_of: :my_module, :dependent => :destroy
has_many :users, through: :user_my_modules
has_many :activities, inverse_of: :my_module

View file

@ -0,0 +1,8 @@
class MyModulesRepositoryRow < ActiveRecord::Base
belongs_to :assigned_by, foreign_key: 'assigned_by_id', class_name: 'User'
belongs_to :repository_row
belongs_to :my_module
validates :repository_row, :my_module, presence: true
validates :repository_row, uniqueness: { scope: :my_module }
end

View file

@ -3,6 +3,7 @@ class Repository < ActiveRecord::Base
belongs_to :created_by, foreign_key: :created_by_id, class_name: 'User'
has_many :repository_columns
has_many :repository_rows
has_many :repository_tables, inverse_of: :repository, dependent: :destroy
auto_strip_attributes :name, nullify: false
validates :name,

View file

@ -3,6 +3,7 @@ class RepositoryCell < ActiveRecord::Base
belongs_to :repository_column
belongs_to :value, polymorphic: true, dependent: :destroy
validates :repository_column, presence: true
validate :repository_column_data_type
validates :repository_row, uniqueness: { scope: :repository_column }

View file

@ -14,4 +14,10 @@ class RepositoryColumn < ActiveRecord::Base
validates :created_by, presence: true
validates :repository, presence: true
validates :data_type, presence: true
after_create :update_repository_table_state
def update_repository_table_state
RepositoryTable.update_state(self, nil, created_by)
end
end

View file

@ -1,6 +1,15 @@
class RepositoryDateValue < ActiveRecord::Base
has_one :repository_cell, as: :value
belongs_to :created_by, foreign_key: :created_by_id, class_name: 'User'
belongs_to :last_modified_by, foreign_key: :last_modified_by_id,
class_name: 'User'
has_one :repository_cell, as: :value, dependent: :destroy
accepts_nested_attributes_for :repository_cell
validates :repository_cell, presence: true
validates :data,
presence: true
def formatted
data
end
end

View file

@ -1,8 +1,13 @@
class RepositoryRow < ActiveRecord::Base
belongs_to :repository
belongs_to :created_by, foreign_key: :created_by_id, class_name: 'User'
belongs_to :last_modified_by, foreign_key: :last_modified_by_id,
class_name: 'User'
has_many :repository_cells, dependent: :destroy
has_many :repository_columns, through: :repository_cells
has_many :my_modules_repository_rows,
inverse_of: :repository_row, dependent: :destroy
has_many :my_modules, through: :my_modules_repository_rows
auto_strip_attributes :name, nullify: false
validates :name,

View file

@ -0,0 +1,60 @@
class RepositoryTable < ActiveRecord::Base
belongs_to :user, inverse_of: :repository_tables
belongs_to :repository, inverse_of: :repository_tables
validates :user, :repository, presence: true
scope :load_state, (lambda { |user, repository|
where(user: user, repository: repository).pluck(:state)
})
def self.update_state(custom_column, column_index, user)
repository_table = RepositoryTable.where(
user: user,
repository: custom_column.repository
)
repository_state = repository_table.first['state']
if column_index
# delete column
repository_state['columns'].delete(column_index)
repository_state['columns'].keys.each do |index|
if index.to_i > column_index.to_i
repository_state['columns'][(index.to_i - 1).to_s] =
repository_state['columns'].delete(index)
else
index
end
end
repository_state['ColReorder'].delete(column_index)
repository_state['ColReorder'].map! do |index|
if index.to_i > column_index.to_i
(index.to_i - 1).to_s
else
index
end
end
else
# add column
index = repository_state['columns'].count
repository_state['columns'][index] = RepositoryDatatable::
REPOSITORY_TABLE_DEFAULT_STATE['columns'].first
repository_state['ColReorder'].insert(2, index)
end
repository_table.first.update(state: repository_state)
end
def self.create_state(user, repository)
default_columns_num = RepositoryDatatable::
REPOSITORY_TABLE_DEFAULT_STATE['columns'].count
repository_state =
RepositoryDatatable::REPOSITORY_TABLE_DEFAULT_STATE.deep_dup
repository.repository_columns.each_with_index do |_, index|
repository_state['columns'] << RepositoryDatatable::
REPOSITORY_TABLE_DEFAULT_STATE['columns'].first
repository_state['ColReorder'] << (default_columns_num + index)
end
RepositoryTable.create(user: user,
repository: repository,
state: repository_state)
end
end

View file

@ -1,9 +1,16 @@
class RepositoryTextValue < ActiveRecord::Base
has_one :repository_cell, as: :value
belongs_to :created_by, foreign_key: :created_by_id, class_name: 'User'
belongs_to :last_modified_by, foreign_key: :last_modified_by_id,
class_name: 'User'
has_one :repository_cell, as: :value, dependent: :destroy
accepts_nested_attributes_for :repository_cell
validates :repository_cell, presence: true
validates :value,
validates :data,
presence: true,
length: { maximum: Constants::TEXT_MAX_LENGTH }
def formatted
data
end
end

View file

@ -48,6 +48,8 @@ class User < ActiveRecord::Base
has_many :results, inverse_of: :user
has_many :samples, inverse_of: :user
has_many :samples_tables, inverse_of: :user, dependent: :destroy
has_many :repositories, inverse_of: :user
has_many :repository_tables, inverse_of: :user, dependent: :destroy
has_many :steps, inverse_of: :user
has_many :custom_fields, inverse_of: :user
has_many :reports, inverse_of: :user
@ -175,6 +177,9 @@ class User < ActiveRecord::Base
class_name: 'Protocol',
foreign_key: 'restored_by_id',
inverse_of: :restored_by
has_many :assigned_repository_rows_my_modules,
class_name: 'RepositoryRowsMyModules',
foreign_key: 'assigned_by_id'
has_many :user_notifications, inverse_of: :user
has_many :notifications, through: :user_notifications

View file

@ -5,6 +5,33 @@
<%= render partial: "shared/sidebar" %>
<%= render partial: "shared/secondary_navigation" %>
<div id="content">
<%= render partial: "shared/repository" %>
<h3><%= @repository.name %></h3>
<div class="toolbarButtons" style="display:none">
<% if can_assign_repository_records(@my_module, @repository) %>
<button type="button" class="btn btn-default"
data-assign-url="<%= assign_repository_records_my_module_path(@my_module, @repository)%>"
id="assignRepositoryRecords" onclick="onClickAssignRecords()" disabled>
<span class="glyphicon glyphicon-ok-circle"></span>
<span class="hidden-xs-custom"><%= t'repositories.assign_records_to_module' %></span>
</button>
<% end %>
<% if can_unassign_repository_records(@my_module, @repository) %>
<button type="button" class="btn btn-default"
data-unassign-url="<%= unassign_repository_records_my_module_path(@my_module, @repository)%>"
id="unassignRepositoryRecords" onclick="onClickUnassignRecords()" disabled>
<span class="glyphicon glyphicon-ban-circle"></span>
<span class="hidden-xs-custom"><%= t'repositories.unassign_records_from_module' %></span>
</button>
<% end %>
</div>
<div id="content">
<%= render partial: "repositories/repository_table",
locals: {
repository: @repository,
my_module: @my_module,
repository_index_link: repository_index_my_module_path(@my_module, @repository, format: :json)
}
%>
</div>

View file

@ -0,0 +1,16 @@
<div class="modal fade" id="deleteRepositoryColumn" tabindex="-1" role="dialog" aria-labelledby="deleteRepositoryColumnLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title"><%= t("repositories.modal_delete_column.title") %></h4>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-action="delete"><%= t("repositories.modal_delete_column.delete") %></button>
<button type="button" class="btn btn-default" data-dismiss="modal"><%= t("general.cancel")%></button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,20 @@
<%= bootstrap_form_for @repository_column,
url: repository_repository_column_path(@repository,
@repository_column,
format: :json),
remote: :true,
method: :delete,
data: { role: "destroy-repository-column-form",
id: @repository_column.id } do |f| %>
<%= f.hidden_field :column_index, value: column_index %>
<p><%= t("repositories.modal_delete_column.message", column: @repository_column.name) %></p>
<div class="alert alert-danger" role="alert">
<span class="glyphicon glyphicon-exclamation-sign"></span>
&nbsp;
<%= t("repositories.modal_delete_column.alert_heading") %>
<ul>
<li><%= t("repositories.modal_delete_column.alert_line_1", nr: @repository_column.repository_cells.count) %></li>
<li><%= t("repositories.modal_delete_column.alert_line_2") %></li>
</ul>
</div>
<% end %>

View file

@ -0,0 +1,20 @@
<div class="modal fade" id="deleteRepositoryRecord" tabindex="-1" role="dialog" aria-labelledby="deleteRepositoryRecordLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title"><%= t("repositories.modal_delete_record.title") %></h4>
<%= t("repositories.modal_delete_record.notice") %>
</div>
<div class="modal-body">
<%= t("repositories.modal_delete_record.other_samples") %>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal" onclick="onClickDeleteRecord();">
<%= t("repositories.modal_delete_record.delete") %>
</button>
<button type="button" class="btn btn-default" data-dismiss="modal"><%= t("general.cancel")%></button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,75 @@
<%= render partial: "repositories/delete_record_modal" %>
<%= render partial: "repositories/delete_column_modal" %>
<div id="alert-container"></div>
<div id="repository-toolbar">
<% if can_create_repository_records(repository) %>
<button type="button" class="btn btn-default editAdd" id="addRepositoryRecord" onclick="onClickAddRecord()">
<span class="glyphicon glyphicon-plus"></span>
<span class="hidden-xs"><%= t("repositories.add_new_record") %></span>
</button>
<% end %>
<div id="datatables-buttons" style="display: inline;">
<div id="repository-columns-dropdown" class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<%= t('repositories.columns') %>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right smart-dropdown" id="repository-columns-list">
<% if can_create_columns_in_repository(repository) %>
<li class="add-new-column-form">
<div id="new-column-form" class="form-group" data-action="<%= repository_repository_columns_path(repository) %>">
<div class="input-group">
<input class="form-control" id="new-column-name" placeholder="<%= t("repositories.column_new_text") %>">
<span class="input-group-btn">
<a id="add-new-column-button" class="btn btn-primary">
<%= t("repositories.column_create") %>
</a>
</span>
</div>
</div>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<div class="btn-group inline" id="saveCancel" data-toggle="buttons" style="display:none">
<button type="button" class="btn btn-primary" id="saveRecord" onclick="onClickSave()">
<span class="glyphicon glyphicon-save"></span>
<%= t("repositories.save_record") %>
</button>
<button type="button" class="btn btn-default" id="cancelSave" onclick="onClickCancel()">
<span class="glyphicon glyphicon-remove visible-xs-inline"></span>
<span class="hidden-xs"><%= t("repositories.cancel_save") %></span>
</button>
</div>
<!-- These buttons are appended to table in javascript, after table initialization. -->
<div class="toolbarButtons" style="display:none">
<button type="button" class="btn btn-default editAdd" id="editRepositoryRecord" onclick="onClickEdit()" disabled>
<span class="glyphicon glyphicon-pencil"></span>
<span class="hidden-xs-custom"><%= t("repositories.edit_record") %></span>
</button>
<% if can_delete_repository_records(repository) %>
<button type="button" class="btn btn-default"
id="deleteRepositoryRecordsButton" data-target="#deleteRepositoryRecord" data-toggle="modal" disabled>
<span class="glyphicon glyphicon-trash"></span>
<span class="hidden-xs-custom"><%= t'repositories.delete_record' %></span>
<%= submit_tag I18n.t('repositories.delete_record'), :class => "hidden
delete_repository_records_submit" %>
</button>
<% end %>
</div>
<%= render partial: "repository_table",
locals: {
repository: repository,
repository_index_link: repository_table_index_path(repository)
}
%>

View file

@ -0,0 +1,44 @@
<div class="repository-table">
<table id="repository-table" class="table"
data-current-uri="<%= request.original_url %>"
data-repository-id="<%= repository.id %>"
data-source="<%= repository_index_link %>"
data-num-columns="<%= 5 + repository.repository_columns.count %>"
data-create-record="<%= repository_repository_rows_path(repository) %>"
data-delete-record="<%= repository_delete_records_path(repository) %>"
data-max-dropdown-length="<%= Constants::NAME_TRUNCATION_LENGTH_DROPDOWN %>"
data-save-text="<%= I18n.t('general.save') %>"
data-edit-text="<%= I18n.t('general.edit') %>"
data-cancel-text="<%= I18n.t('general.cancel') %>"
data-columns-visibility-text="<%= I18n.t('repositories.columns_visibility') %>"
data-columns-delete-text="<%= I18n.t('repositories.columns_delete') %>">
<thead>
<tr>
<th id="checkbox"><input name="select_all" value="1" type="checkbox"></th>
<% if @my_module %>
<th id="assigned"><%= t("repositories.table.assigned") %></th>
<% else %>
<th id="assigned"></th>
<% end %>
<th id="row-name"><%= t("repositories.table.row_name") %></th>
<th id="added-on"><%= t("repositories.table.added_on") %></th>
<th id="added-by"><%= t("repositories.table.added_by") %></th>
<% repository.repository_columns.each do |column| %>
<th class="repository-column" id="<%= column.id %>"
<%= 'data-editable' if can_edit_columns_in_repository(repository) %>
<%= 'data-deletable' if can_delete_columns_in_repository(repository) %>
<%= "data-edit-url='#{edit_repository_repository_column_path(repository, column)}'" %>
<%= "data-update-url='#{repository_repository_column_path(repository, column)}'" %>
<%= "data-destroy-html-url='#{repository_columns_destroy_html_path(repository, column)}'" %>
>
<%= display_tooltip(column.name) %>
</th>
<% end %>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<%= stylesheet_link_tag 'datatables' %>
<%= javascript_include_tag('repositories/repository_datatable') %>

View file

@ -74,6 +74,7 @@
</div>
</div>
<%= render partial: 'repository', locals: { repository: repo } %>
</div>
<% end %>
</div>

View file

@ -1,2 +0,0 @@
<div id="alert-container"></div>
<h2><%= @repository.name %></h2>

View file

@ -73,6 +73,8 @@ Rails.application.config.assets.precompile += %w(samples/sample_types_groups.js)
Rails.application.config.assets.precompile += %w(highlightjs-github-theme.css)
Rails.application.config.assets.precompile += %w(search.js)
Rails.application.config.assets.precompile += %w(repositories/index.js)
Rails.application.config.assets.precompile +=
%w(repositories/repository_datatable.js)
# Libraries needed for Handsontable formulas
Rails.application.config.assets.precompile += %w(lodash.js)

View file

@ -872,6 +872,65 @@ en:
destroy:
success_flash: "Table result successfully deleted."
repositories:
index:
head_title: "Repositories"
title: "Repositories"
no_repositories: "No repositories"
no_teams:
title: "Your dashboard is empty!"
text: "It seems you're not a member of any team. See team management to sort it out."
nav:
breadcrumbs:
repositories: "Repositories"
table:
assigned: "Assigned"
row_name: "Name"
added_on: "Added on"
added_by: "Added by"
add_new_record: "Add record"
edit_record: "Edit"
delete_record: "Delete"
save_record: "Save"
cancel_save: "Cancel"
assign_records_to_module: "Assign"
unassign_records_from_module: "Unassign"
columns: "Columns"
column_new_text: "New column"
column_create: "Create"
columns_delete: "Delete"
columns_visibility: "Visible columns"
modal_delete_record:
title: "Delete record"
notice: "Are you sure you want to delete the selected records?"
other_samples: "Only records created by you will be deleted."
delete: "Delete records"
modal_delete_column:
title: "Delete a column"
message: "Are you sure you wish to permanently delete selected column %{column}? This action is irreversible."
alert_heading: "Deleting a column has following consequences:"
alert_line_1: "you will lose information in this column for %{nr} records;"
alert_line_2: "the column will be deleted for all team members."
delete: "Delete column"
js:
permission_error: "You don't have permission to edit this record."
not_found_error: "This repository record does not exist."
column_added: "New column was sucessfully created."
empty_column_name: "Please enter column name."
create:
success_flash: "Successfully added record <strong>%{record}</strong> to repository <strong>%{repository}</strong>"
update:
success_flash: "Successfully updated record <strong>%{record}</strong> in repository <strong>%{repository}</strong>"
destroy:
success_flash: "%{records_number} record(s) successfully deleted."
contains_other_records_flash: "%{records_number} record(s) successfully deleted. %{other_records_number} of the selected record(s) were created by other users and were not deleted."
no_records_selected_flash: "There were no selected records."
no_deleted_records_flash: "No records were deleted. %{other_records_number} of the selected records were created by other users and were not deleted."
assigned_records_flash: "Successfully assigned record(s) <strong>%{records}</strong> to task"
unassigned_records_flash: "Successfully unassigned record(s) <strong>%{records}</strong> from task"
no_records_assigned_flash: "No records were assigned to task"
no_records_unassigned_flash: "No records were unassigned from task"
samples:
columns: "Columns"
columns_visibility: "Toggle visibility"
@ -1059,6 +1118,8 @@ en:
uncomplete_module: "<i>%{user}</i> uncompleted task <strong>%{module}</strong>."
assign_sample: "<i>%{user}</i> assigned sample(s) <strong>%{samples}</strong> to task(s) <strong>%{tasks}</strong>"
unassign_sample: "<i>%{user}</i> unassigned sample(s) <strong>%{samples}</strong> from task(s) <strong>%{tasks}</strong>"
assign_repository_records: "<i>%{user}</i> assigned <strong>%{repository}</strong> repository records(s) <strong>%{records}</strong> to task <strong>%{task}</strong>"
unassign_repository_records: "<i>%{user}</i> unassigned <strong>%{repository}</strong> repository records(s) <strong>%{records}</strong> from task <strong>%{task}</strong>"
create_step: "<i>%{user}</i> created Step %{step} <strong>%{step_name}</strong>."
destroy_step: "<i>%{user}</i> deleted Step %{step} <strong>%{step_name}</strong>."
add_comment_to_step: "<i>%{user}</i> commented on Step %{step} <strong>%{step_name}</strong>."
@ -1594,6 +1655,8 @@ en:
result_annotation_message_html: "Project: %{project} | Experiment: %{experiment} | Task: %{my_module}"
sample_annotation_title: "%{user} mentioned you in Column: %{column} of Sample %{sample}"
sample_annotation_message_html: "Sample: %{sample} | Column: %{column}"
repository_annotation_title: "%{user} mentioned you in Column: %{column} of Record %{record} in Repository %{repository}"
repository_annotation_message_html: "Record: %{record} | Column: %{column}"
protocol_step_annotation_message_html: "Protocol: %{protocol}"
email_title: "You've received a sciNote notification!"
assign_user_to_team: "<i>%{assigned_user}</i> was added as %{role} to team <strong>%{team}</strong> by <i>%{assigned_by_user}</i>."

View file

@ -296,6 +296,15 @@ Rails.application.routes.draw do
get 'repository/:repository_id',
to: 'my_modules#repository',
as: :repository
post 'repository_index/:repository_id',
to: 'my_modules#repository_index',
as: :repository_index
post 'assign_repository_records/:repository_id',
to: 'my_modules#assign_repository_records',
as: :assign_repository_records
post 'unassign_repository_records/:repository_id',
to: 'my_modules#unassign_repository_records',
as: :unassign_repository_records
get 'archive' # Archive view for single module
get 'complete_my_module'
post 'toggle_task_state'
@ -403,6 +412,35 @@ Rails.application.routes.draw do
end
end
resources :repositories do
post 'repository_index',
to: 'repositories#repository_table_index',
as: 'table_index',
defaults: { format: 'json' }
# Save repository table state
post 'state_save',
to: 'user_repositories#save_table_state',
as: 'save_table_state',
defaults: { format: 'json' }
# Load repository table state
post 'state_load',
to: 'user_repositories#load_table_state',
as: 'load_table_state',
defaults: { format: 'json' }
# Delete records from repository
post 'delete_records',
to: 'repository_rows#delete_records',
as: 'delete_records',
defaults: { format: 'json' }
post 'repository_columns/:id/destroy_html',
to: 'repository_columns#destroy_html',
as: 'columns_destroy_html'
resources :repository_columns, only: %i(create edit update destroy)
resources :repository_rows, only: %i(create edit update)
end
get 'search' => 'search#index'
get 'search/new' => 'search#new', as: :new_search

View file

@ -22,11 +22,13 @@ class AddCustomRepositories < ActiveRecord::Migration
create_table :repository_rows do |t|
t.belongs_to :repository, index: true
t.integer :created_by_id, null: false
t.integer :last_modified_by_id, null: false
t.string :name, index: true
t.timestamps null: true
end
add_foreign_key :repository_rows, :users, column: :created_by_id
add_foreign_key :repository_rows, :users, column: :last_modified_by_id
create_table :repository_cells do |t|
t.belongs_to :repository_row, index: true
@ -36,13 +38,45 @@ class AddCustomRepositories < ActiveRecord::Migration
end
create_table :repository_date_values do |t|
t.datetime :value
t.datetime :data
t.timestamps null: true
t.integer :created_by_id, null: false
t.integer :last_modified_by_id, null: false
end
add_foreign_key :repository_date_values, :users, column: :created_by_id
add_foreign_key :repository_date_values,
:users,
column: :last_modified_by_id
create_table :repository_text_values do |t|
t.string :value
t.string :data
t.timestamps null: true
t.integer :created_by_id, null: false
t.integer :last_modified_by_id, null: false
end
add_foreign_key :repository_text_values, :users, column: :created_by_id
add_foreign_key :repository_text_values,
:users,
column: :last_modified_by_id
create_table :my_modules_repository_rows do |t|
t.integer :repository_row_id, index: true, null: false
t.integer :my_module_id, null: :false
t.integer :assigned_by_id, null: false
t.timestamps null: true
t.index %i(my_module_id repository_row_id),
name: 'index_my_module_ids_repository_row_ids'
end
add_foreign_key :my_modules_repository_rows, :users, column: :assigned_by_id
create_table :repository_tables do |t|
t.jsonb :state, null: false
t.references :user, index: true, null: false
t.references :repository, index: true, null: false
t.timestamps null: false
end
end
end