Merge branch 'features/storage-locations' into develop

This commit is contained in:
Martin Artnik 2024-09-12 12:38:32 +02:00
commit 2039a65e9b
101 changed files with 4045 additions and 326 deletions

4
.gitignore vendored
View file

@ -95,3 +95,7 @@ public/marvin4js-license.cxl
/app/assets/builds/*
!/app/assets/builds/.keep
# Ignore automatically generated js-routes files.
/app/javascript/routes.js
/app/javascript/routes.d.ts

View file

@ -13,6 +13,7 @@ before_install:
- curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 > docker-compose
- chmod +x docker-compose
- sudo mv docker-compose /usr/local/bin
- sudo chown --recursive 1000 .
- make docker-ci
script:
- make tests-ci

View file

@ -1,4 +1,4 @@
FROM ruby:3.2.2-bookworm
FROM ruby:3.2.5-bookworm
MAINTAINER SciNote <info@scinote.net>
# additional dependecies
@ -20,7 +20,8 @@ RUN apt-get update -qq && \
fonts-wqy-microhei \
fonts-wqy-zenhei \
libfile-mimeinfo-perl \
chromium-driver \
chromium \
chromium-sandbox \
yarnpkg && \
ln -s /usr/lib/x86_64-linux-gnu/libvips.so.42 /usr/lib/x86_64-linux-gnu/libvips.so && \
rm -rf /var/lib/apt/lists/*
@ -35,6 +36,10 @@ ENV BUNDLE_PATH /usr/local/bundle/
ENV APP_HOME /usr/src/app
ENV PATH $APP_HOME/bin:$PATH
RUN mkdir $APP_HOME
RUN adduser --uid 1000 scinote
RUN chown scinote:scinote $APP_HOME
USER scinote
ENV CHROMIUM_PATH /usr/bin/chromium
WORKDIR $APP_HOME
CMD rails s -b 0.0.0.0

View file

@ -1,5 +1,5 @@
# Building stage
FROM ruby:3.2.2-bookworm AS builder
FROM ruby:3.2.5-bookworm AS builder
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN \
@ -23,7 +23,7 @@ COPY . $APP_HOME
RUN rm -f $APP_HOME/config/application.yml $APP_HOME/production.env
WORKDIR $APP_HOME
RUN \
--mount=target=$APP_HOME/tmp/bundle,type=cache \
--mount=target=/usr/src/app/tmp/bundle,type=cache \
bundle config set without 'development test' && \
bundle config set path '/usr/src/app/tmp/bundle' && \
bundle install --jobs `nproc` && \
@ -34,14 +34,14 @@ RUN \
RUN \
--mount=type=cache,target=/usr/local/share/.cache/yarn/v6,sharing=locked \
--mount=type=cache,target=$APP_HOME/node_modules,sharing=locked \
--mount=type=cache,target=/usr/src/app/node_modules,sharing=locked \
DATABASE_URL=postgresql://postgres@db/scinote_production \
SECRET_KEY_BASE=dummy \
DEFACE_ENABLED=true \
bash -c "rake assets:precompile && rake deface:precompile"
bash -c "rake assets:precompile && rake deface:precompile && rm -rf ./tmp/cache"
# Final stage
FROM ruby:3.2.2-bookworm AS runner
FROM ruby:3.2.5-bookworm AS runner
MAINTAINER SciNote <info@scinote.net>
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
@ -76,6 +76,7 @@ RUN \
libvips42 \
graphviz \
chromium \
chromium-sandbox \
libfile-mimeinfo-perl \
yarnpkg && \
/usr/share/nodejs/yarn/bin/yarn add puppeteer@npm:puppeteer-core@^22.15.0 && \
@ -98,8 +99,12 @@ ENV GEM_HOME=$APP_HOME/vendor/bundle/ruby/3.2.0
ENV PATH=$GEM_HOME/bin:$PATH
ENV BUNDLE_APP_CONFIG=.bundle
COPY --from=builder $APP_HOME $APP_HOME
RUN adduser --uid 1000 scinote
USER scinote
COPY --from=builder --chown=scinote:scinote $APP_HOME $APP_HOME
ENV CHROMIUM_PATH /usr/bin/chromium
WORKDIR $APP_HOME
CMD rails s -b 0.0.0.0

View file

@ -2,7 +2,7 @@
source 'http://rubygems.org'
ruby '3.2.2'
ruby '~> 3.2.2'
gem 'activerecord-session_store'
gem 'bootsnap', require: false
@ -94,6 +94,7 @@ gem 'graphviz'
gem 'cssbundling-rails'
gem 'jsbundling-rails'
gem 'js-routes'
gem 'tailwindcss-rails', '~> 2.4'

View file

@ -388,6 +388,8 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
js-routes (2.2.8)
railties (>= 4)
jsbundling-rails (1.1.1)
railties (>= 6.0.0)
json (2.6.3)
@ -828,6 +830,7 @@ DEPENDENCIES
image_processing
img2zpl!
jbuilder
js-routes
jsbundling-rails
json-jwt
json_matchers
@ -898,4 +901,4 @@ RUBY VERSION
ruby 3.2.2p53
BUNDLED WITH
2.4.10
2.5.11

View file

@ -22,13 +22,13 @@ heroku:
@echo "Set environment variables, DATABASE_URL, RAILS_SERVE_STATIC_FILES, RAKE_ENV, RAILS_ENV, SECRET_KEY_BASE"
docker:
@docker-compose build
@docker-compose --progress plain build
docker-ci:
@docker-compose --progress plain build web
docker-production:
@docker-compose -f docker-compose.production.yml build --build-arg BUILD_TIMESTAMP=$(BUILD_TIMESTAMP)
@docker-compose --progress plain -f docker-compose.production.yml build --build-arg BUILD_TIMESTAMP=$(BUILD_TIMESTAMP)
config-production:
ifeq (production.env,$(wildcard production.env))

View file

@ -5,3 +5,5 @@ require File.expand_path('../config/application', __FILE__)
Rails.application.load_tasks
Doorkeeper::Rake.load_tasks
# Update js-routes file before javascript build
task 'javascript:build' => 'js:routes:typescript'

View file

@ -2,6 +2,7 @@
@import "tailwind/buttons";
@import "tailwind/modals";
@import "tailwind/flyouts";
@import "tailwind/radio";
@import "tailwind/loader.css";
@tailwind base;
@ -69,6 +70,6 @@ html {
@keyframes shine-lines {
0% { background-position: -150px }
40%, 100% { background-position: 320px }
}

View file

@ -26,5 +26,11 @@
border-width: 0;
height: 1px;
margin: 0 16px 10px;
}
}
}
.reminders-view-mode {
.row-reminders-footer {
display: none;
}
}

View file

@ -1,5 +1,5 @@
// scss-lint:disable SelectorDepth QualifyingElement
/*
:root {
--sci-radio-size: 16px;
}
@ -85,3 +85,4 @@ input[type="radio"].sci-radio {
}
}
}
*/

View file

@ -0,0 +1,42 @@
@layer components {
.sci-radio-container {
@apply inline-block h-4 w-4 relative;
}
input[type="radio"].sci-radio {
@apply cursor-pointer shrink-0 h-4 w-4 m-0 opacity-0 relative z-[2];
}
input[type="radio"].sci-radio + .sci-radio-label {
@apply inline-block shrink-0 h-4 w-4 absolute left-0;
}
input[type="radio"].sci-radio + .sci-radio-label::before {
@apply border-[1px] border-solid border-sn-black rounded-full text-white text-center transition-all
h-4 w-4 left-0 absolute;
content: "";
}
input[type="radio"].sci-radio + .sci-radio-label::after{
@apply bg-white rounded-full text-white text-center transition-all
absolute w-2.5 h-2.5 top-[3px] left-[3px] ;
content: "";
}
input[type="radio"].sci-radio:checked + .sci-radio-label::before {
@apply !border-sn-blue;
}
input[type="radio"].sci-radio:checked + .sci-radio-label::after {
@apply !bg-sn-science-blue;
}
input[type="radio"].sci-radio:disabled + .sci-radio-label::before {
@apply !border-sn-sleepy-grey;
}
input[type="radio"].sci-radio:checked:disabled + .sci-radio-label::after {
@apply !bg-sn-sleepy-grey;
}
}

View file

@ -10,18 +10,17 @@ class RepositoriesController < ApplicationController
include MyModulesHelper
before_action :load_repository, except: %i(index create create_modal sidebar archive restore actions_toolbar
export_modal export_repositories)
before_action :load_repositories, only: :index
export_modal export_repositories list)
before_action :load_repositories, only: %i(index list)
before_action :load_repositories_for_archiving, only: :archive
before_action :load_repositories_for_restoring, only: :restore
before_action :check_view_all_permissions, only: %i(index sidebar)
before_action :check_view_all_permissions, only: %i(index sidebar list)
before_action :check_view_permissions, except: %i(index create_modal create update destroy parse_sheet
import_records sidebar archive restore actions_toolbar
export_modal export_repositories)
export_modal export_repositories list)
before_action :check_manage_permissions, only: %i(rename_modal update)
before_action :check_delete_permissions, only: %i(destroy destroy_modal)
before_action :check_archive_permissions, only: %i(archive restore)
before_action :check_share_permissions, only: :share_modal
before_action :check_create_permissions, only: %i(create_modal create)
before_action :check_copy_permissions, only: %i(copy_modal copy)
before_action :set_inline_name_editing, only: %i(show)
@ -44,6 +43,16 @@ class RepositoriesController < ApplicationController
end
end
def list
results = @repositories
results = results.name_like(params[:query]) if params[:query].present?
render json: { data: results.map { |r| [r.id, r.name] } }
end
def rows_list
render json: { data: @repository.repository_rows.map { |r| [r.id, r.name] } }
end
def sidebar
render json: {
html: render_to_string(partial: 'repositories/sidebar', locals: {
@ -101,15 +110,6 @@ class RepositoriesController < ApplicationController
}
end
def share_modal
render json: { html: render_to_string(partial: 'share_repository_modal', formats: :html) }
end
def shareable_teams
teams = current_user.teams.order(:name) - [@repository.team]
render json: teams, each_serializer: ShareableTeamSerializer, repository: @repository
end
def hide_reminders
# synchronously hide currently visible reminders
if params[:visible_reminder_repository_row_ids].present?
@ -522,10 +522,6 @@ class RepositoriesController < ApplicationController
render_403 unless can_delete_repository?(@repository)
end
def check_share_permissions
render_403 unless can_share_repository?(@repository)
end
def repository_params
params.require(:repository).permit(:name)
end

View file

@ -328,7 +328,7 @@ class RepositoryRowsController < ApplicationController
def active_reminder_repository_cells
reminder_cells = @repository_row.repository_cells.with_active_reminder(current_user).distinct
render json: {
html: render_to_string(partial: 'shared/repository_row_reminder', locals: {
html: render_to_string(partial: 'shared/repository_row_reminder', formats: :html, locals: {
reminders: reminder_cells
})
}

View file

@ -0,0 +1,142 @@
# frozen_string_literal: true
class StorageLocationRepositoryRowsController < ApplicationController
before_action :check_storage_locations_enabled, except: :destroy
before_action :load_storage_location_repository_row, only: %i(update destroy move)
before_action :load_storage_location
before_action :load_repository_row, only: %i(create update destroy move)
before_action :check_read_permissions, except: %i(create actions_toolbar)
before_action :check_manage_permissions, only: %i(create update destroy)
def index
storage_location_repository_row = Lists::StorageLocationRepositoryRowsService.new(
current_team, params
).call
render json: storage_location_repository_row,
each_serializer: Lists::StorageLocationRepositoryRowSerializer,
meta: (pagination_dict(storage_location_repository_row) unless @storage_location.with_grid?)
end
def create
ActiveRecord::Base.transaction do
@storage_location_repository_row = StorageLocationRepositoryRow.new(
repository_row: @repository_row,
storage_location: @storage_location,
metadata: storage_location_repository_row_params[:metadata] || {},
created_by: current_user
)
if @storage_location_repository_row.save
log_activity(:storage_location_repository_row_created)
render json: @storage_location_repository_row,
serializer: Lists::StorageLocationRepositoryRowSerializer
else
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
end
end
end
def update
ActiveRecord::Base.transaction do
@storage_location_repository_row.update(storage_location_repository_row_params)
if @storage_location_repository_row.save
log_activity(:storage_location_repository_row_moved)
render json: @storage_location_repository_row,
serializer: Lists::StorageLocationRepositoryRowSerializer
else
render json: @storage_location_repository_row.errors, status: :unprocessable_entity
end
end
end
def move
ActiveRecord::Base.transaction do
@storage_location_repository_row.discard
@storage_location_repository_row = StorageLocationRepositoryRow.create!(
repository_row: @repository_row,
storage_location: @storage_location,
metadata: storage_location_repository_row_params[:metadata] || {},
created_by: current_user
)
log_activity(:storage_location_repository_row_moved)
render json: @storage_location_repository_row,
serializer: Lists::StorageLocationRepositoryRowSerializer
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def destroy
ActiveRecord::Base.transaction do
if @storage_location_repository_row.discard
log_activity(:storage_location_repository_row_deleted)
render json: {}
else
render json: { errors: @storage_location_repository_row.errors.full_messages }, status: :unprocessable_entity
end
end
end
def actions_toolbar
render json: {
actions: Toolbars::StorageLocationRepositoryRowsService.new(
current_user,
items_ids: JSON.parse(params[:items]).pluck('id')
).actions
}
end
private
def check_storage_locations_enabled
render_403 unless StorageLocation.storage_locations_enabled?
end
def load_storage_location_repository_row
@storage_location_repository_row = StorageLocationRepositoryRow.find(
storage_location_repository_row_params[:id]
)
render_404 unless @storage_location_repository_row
end
def load_storage_location
@storage_location = StorageLocation.viewable_by_user(current_user).find(
storage_location_repository_row_params[:storage_location_id]
)
render_404 unless @storage_location
end
def load_repository_row
@repository_row = RepositoryRow.find(storage_location_repository_row_params[:repository_row_id])
render_404 unless @repository_row
end
def storage_location_repository_row_params
params.permit(:id, :storage_location_id, :repository_row_id,
metadata: { position: [] })
end
def check_read_permissions
render_403 unless can_read_storage_location?(@storage_location)
end
def check_manage_permissions
render_403 unless can_manage_storage_location?(@storage_location)
end
def log_activity(type_of, message_items = {})
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
team: @storage_location.team,
subject: @storage_location_repository_row.repository_row,
message_items: {
storage_location: @storage_location_repository_row.storage_location_id,
repository_row: @storage_location_repository_row.repository_row_id,
position: @storage_location_repository_row.human_readable_position,
user: current_user.id
}.merge(message_items))
end
end

View file

@ -0,0 +1,259 @@
# frozen_string_literal: true
class StorageLocationsController < ApplicationController
before_action :check_storage_locations_enabled, except: :unassign_rows
before_action :load_storage_location, only: %i(update destroy duplicate move show available_positions unassign_rows export_container import_container)
before_action :check_read_permissions, except: %i(index create tree actions_toolbar)
before_action :check_create_permissions, only: :create
before_action :check_manage_permissions, only: %i(update destroy duplicate move unassign_rows import_container)
before_action :set_breadcrumbs_items, only: %i(index show)
def index
respond_to do |format|
format.html
format.json do
storage_locations = Lists::StorageLocationsService.new(current_user, current_team, params).call
render json: storage_locations, each_serializer: Lists::StorageLocationSerializer,
user: current_user, meta: pagination_dict(storage_locations)
end
end
end
def show; end
def create
ActiveRecord::Base.transaction do
@storage_location = StorageLocation.new(
storage_location_params.merge({ created_by: current_user })
)
@storage_location.team = @storage_location.root_storage_location.team || current_team
@storage_location.image.attach(params[:signed_blob_id]) if params[:signed_blob_id]
if @storage_location.save
log_activity('storage_location_created')
render json: @storage_location, serializer: Lists::StorageLocationSerializer
else
render json: { error: @storage_location.errors.full_messages }, status: :unprocessable_entity
end
end
end
def update
ActiveRecord::Base.transaction do
@storage_location.image.purge if params[:file_name].blank?
@storage_location.image.attach(params[:signed_blob_id]) if params[:signed_blob_id]
@storage_location.update(storage_location_params)
if @storage_location.save
log_activity('storage_location_edited')
render json: @storage_location, serializer: Lists::StorageLocationSerializer
else
render json: { error: @storage_location.errors.full_messages }, status: :unprocessable_entity
end
end
end
def destroy
ActiveRecord::Base.transaction do
if @storage_location.discard
log_activity('storage_location_deleted')
render json: {}
else
render json: { error: @storage_location.errors.full_messages }, status: :unprocessable_entity
end
end
end
def duplicate
ActiveRecord::Base.transaction do
new_storage_location = @storage_location.duplicate!
if new_storage_location
@storage_location = new_storage_location
log_activity('storage_location_created')
render json: @storage_location, serializer: Lists::StorageLocationSerializer
else
render json: { errors: :failed }, status: :unprocessable_entity
end
end
end
def move
ActiveRecord::Base.transaction do
original_storage_location = @storage_location.parent
destination_storage_location =
if move_params[:destination_storage_location_id] == 'root_storage_location'
nil
else
current_team.storage_locations.find(move_params[:destination_storage_location_id])
end
@storage_location.update!(parent: destination_storage_location)
log_activity('storage_location_moved', { storage_location_original: original_storage_location.id, storage_location_destination: destination_storage_location.id })
end
render json: { message: I18n.t('storage_locations.index.move_modal.success_flash') }
rescue StandardError => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.join("\n")
render json: { error: I18n.t('storage_locations.index.move_modal.error_flash') }, status: :bad_request
end
def tree
records = current_team.storage_locations.where(parent: nil, container: [false, params[:container] == 'true'])
render json: storage_locations_recursive_builder(records)
end
def available_positions
render json: { positions: @storage_location.available_positions }
end
def unassign_rows
ActiveRecord::Base.transaction do
@storage_location_repository_rows = @storage_location.storage_location_repository_rows.where(id: params[:ids])
@storage_location_repository_rows.each(&:discard)
log_unassign_activities
end
render json: { status: :ok }
end
def export_container
xlsx = StorageLocations::ExportService.new(@storage_location, current_user).to_xlsx
send_data(
xlsx,
filename: "#{@storage_location.name.gsub(/\s/, '_')}_export_#{Date.current}.xlsx",
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
end
def import_container
result = StorageLocations::ImportService.new(@storage_location, params[:file], current_user).import_items
if result[:status] == :ok
render json: result
else
render json: result, status: :unprocessable_entity
end
end
def actions_toolbar
render json: {
actions:
Toolbars::StorageLocationsService.new(
current_user,
storage_location_ids: JSON.parse(params[:items]).pluck('id')
).actions
}
end
private
def check_storage_locations_enabled
render_403 unless StorageLocation.storage_locations_enabled?
end
def storage_location_params
params.permit(:id, :parent_id, :name, :container, :description,
metadata: [:display_type, { dimensions: [], parent_coordinations: [] }])
end
def move_params
params.permit(:id, :destination_storage_location_id)
end
def load_storage_location
@storage_location = StorageLocation.viewable_by_user(current_user).find_by(id: storage_location_params[:id])
render_404 unless @storage_location
end
def check_read_permissions
render_403 unless can_read_storage_location?(@storage_location)
end
def check_create_permissions
if storage_location_params[:container]
render_403 unless can_create_storage_location_containers?(current_team)
else
render_403 unless can_create_storage_locations?(current_team)
end
end
def check_manage_permissions
render_403 unless can_manage_storage_location?(@storage_location)
end
def set_breadcrumbs_items
@breadcrumbs_items = []
@breadcrumbs_items.push({
label: t('breadcrumbs.inventories')
})
@breadcrumbs_items.push({
label: t('breadcrumbs.locations'),
url: storage_locations_path
})
storage_locations = []
if params[:parent_id] || @storage_location
location = (current_team.storage_locations.find_by(id: params[:parent_id]) || @storage_location)
if location
storage_locations.unshift(breadcrumbs_item(location))
while location.parent
location = location.parent
storage_locations.unshift(breadcrumbs_item(location))
end
end
end
@breadcrumbs_items += storage_locations
end
def breadcrumbs_item(location)
{
label: location.name,
url: storage_locations_path(parent_id: location.id)
}
end
def storage_locations_recursive_builder(storage_locations)
storage_locations.map do |storage_location|
{
storage_location: storage_location,
children: storage_locations_recursive_builder(
storage_location.storage_locations.where(container: [false, params[:container] == 'true'])
)
}
end
end
def log_activity(type_of, message_items = {})
Activities::CreateActivityService
.call(activity_type: "#{'container_' if @storage_location.container}#{type_of}",
owner: current_user,
team: @storage_location.team,
subject: @storage_location,
message_items: {
storage_location: @storage_location.id,
user: current_user.id
}.merge(message_items))
end
def log_unassign_activities
@storage_location_repository_rows.each do |storage_location_repository_row|
Activities::CreateActivityService
.call(activity_type: :storage_location_repository_row_deleted,
owner: current_user,
team: @storage_location.team,
subject: storage_location_repository_row.repository_row,
message_items: {
storage_location: storage_location_repository_row.storage_location_id,
repository_row: storage_location_repository_row.repository_row_id,
position: storage_location_repository_row.human_readable_position,
user: current_user.id
})
end
end
end

View file

@ -0,0 +1,165 @@
# frozen_string_literal: true
class TeamSharedObjectsController < ApplicationController
before_action :load_vars
before_action :check_sharing_permissions
def update
ActiveRecord::Base.transaction do
@activities_to_log = []
# Global share
if @model.globally_shareable?
permission_level =
if params[:select_all_teams]
params[:select_all_write_permission] ? :shared_write : :shared_read
else
:not_shared
end
@model.permission_level = permission_level
if @model.permission_level_changed?
@model.save!
@model.team_shared_objects.each(&:destroy!) unless permission_level == :not_shared
case @model
when Repository
setup_repository_global_share_activity
end
log_activities and next
end
end
# Share to specific teams
params[:team_share_params].each do |t|
@model.update!(permission_level: :not_shared) if @model.globally_shareable?
team_shared_object = @model.team_shared_objects.find_or_initialize_by(team_id: t['id'])
new_record = team_shared_object.new_record?
team_shared_object.update!(
permission_level: t['private_shared_with_write'] ? :shared_write : :shared_read
)
setup_team_share_activity(team_shared_object, new_record) if team_shared_object.saved_changes?
end
# Unshare
@model.team_shared_objects.where.not(
team_id: params[:team_share_params].filter { |t| t['private_shared_with'] }.pluck('id')
).each do |team_shared_object|
team_shared_object.destroy!
setup_team_share_activity(team_shared_object, false)
end
log_activities
end
end
def shareable_teams
teams = current_user.teams.order(:name) - [@model.team]
render json: teams, each_serializer: ShareableTeamSerializer, model: @model
end
private
def load_vars
case params[:object_type]
when 'Repository'
@model = Repository.viewable_by_user(current_user).find_by(id: params[:object_id])
when 'StorageLocation'
@model = StorageLocation.viewable_by_user(current_user).find_by(id: params[:object_id])
end
render_404 unless @model
end
def create_params
params.permit(:team_id, :object_type, :object_id, :target_team_id, :permission_level)
end
def destroy_params
params.permit(:team_id, :id)
end
def update_params
params.permit(permission_changes: {}, share_team_ids: [], write_permissions: [])
end
def check_sharing_permissions
object_name = @model.is_a?(RepositoryBase) ? 'repository' : @model.model_name.param_key
render_403 unless public_send("can_share_#{object_name}?", @model)
render_403 if !@model.shareable_write? && update_params[:write_permissions].present?
end
def share_all_params
{
shared_with_all: params[:select_all_teams].present?,
shared_permissions_level: params[:select_all_write_permission].present? ? 'shared_write' : 'shared_read'
}
end
def setup_team_share_activity(team_shared_object, new_record)
type =
case @model
when Repository
if team_shared_object.destroyed?
:unshare_inventory
elsif new_record
:share_inventory
else
:update_share_inventory
end
when StorageLocation
if team_shared_object.destroyed?
"#{'container_' if @model.container?}storage_location_unshared"
elsif new_record
"#{'container_' if @model.container?}storage_location_shared"
else
"#{'container_' if @model.container?}storage_location_sharing_updated"
end
end
@activities_to_log << {
type: type,
message_items: {
@model.model_name.param_key.to_sym => team_shared_object.shared_object.id,
team: team_shared_object.team.id,
permission_level: Extends::SHARED_INVENTORIES_PL_MAPPINGS[team_shared_object.permission_level.to_sym]
}
}
end
def setup_repository_global_share_activity
message_items = {
repository: @model.id,
team: @model.team.id,
permission_level: Extends::SHARED_INVENTORIES_PL_MAPPINGS[@model.permission_level.to_sym]
}
activity_params =
if @model.saved_changes['permission_level'][0] == 'not_shared'
{ type: :share_inventory_with_all, message_items: message_items }
elsif @model.saved_changes['permission_level'][1] == 'not_shared'
{ type: :unshare_inventory_with_all, message_items: message_items }
else
{ type: :update_share_with_all_permission_level, message_items: message_items }
end
@activities_to_log << activity_params
end
def log_activities
@activities_to_log.each do |activity_params|
Activities::CreateActivityService
.call(activity_type: activity_params[:type],
owner: current_user,
team: @model.team,
subject: @model,
message_items: {
user: current_user.id
}.merge(activity_params[:message_items]))
end
end
end

View file

@ -17,8 +17,8 @@ module Users
next unless Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
case key.to_s
when 'task_step_states'
update_task_step_states(data)
when 'task_step_states', 'result_states'
update_object_states(data, key.to_s)
else
current_user.settings[key] = data
end
@ -34,18 +34,18 @@ module Users
private
def update_task_step_states(task_step_states_data)
current_states = current_user.settings.fetch('task_step_states', {})
def update_object_states(object_states_data, object_state_key)
current_states = current_user.settings.fetch(object_state_key, {})
task_step_states_data.each do |step_id, collapsed|
object_states_data.each do |object_id, collapsed|
if collapsed
current_states[step_id] = true
current_states[object_id] = true
else
current_states.delete(step_id)
current_states.delete(object_id)
end
end
current_user.settings['task_step_states'] = current_states
current_user.settings[object_state_key] = current_states
end
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
module ActiveStorageHelper
def image_preview_format(blob)
if ['image/jpeg', 'image/jpg'].include?(blob&.content_type)
:jpeg
else
:png
end
end
end

View file

@ -108,6 +108,8 @@ module GlobalActivitiesHelper
else
project_folder_path(obj, team: obj.team.id)
end
when StorageLocation
path = storage_location_path(obj)
else
return current_value
end

View file

@ -19,8 +19,16 @@ module LeftMenuBarHelper
url: repositories_path,
name: t('left_menu_bar.repositories'),
icon: 'sn-icon-inventory',
active: repositories_are_selected?,
submenu: []
active: repositories_are_selected? || storage_locations_are_selected?,
submenu: [{
url: repositories_path,
name: t('left_menu_bar.items'),
active: repositories_are_selected?
}, {
url: storage_locations_path,
name: t('left_menu_bar.locations'),
active: storage_locations_are_selected?
}]
}, {
url: "#",
name: t('left_menu_bar.templates'),
@ -63,6 +71,10 @@ module LeftMenuBarHelper
controller_name == 'repositories'
end
def storage_locations_are_selected?
controller_name == 'storage_locations'
end
def protocols_are_selected?
controller_name == 'protocols'
end

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import StorageLocationsContainer from '../../vue/storage_locations/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('StorageLocationsContainer', StorageLocationsContainer);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#StorageLocationsContainer');

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import StorageLocations from '../../vue/storage_locations/table.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('StorageLocations', StorageLocations);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#storageLocationsTable');

View file

@ -98,20 +98,19 @@ export default {
if (this.query === '') {
return this.foldersTree;
}
return this.foldersTree.map((folder) => (
{
folder: folder.folder,
children: folder.children.filter((child) => (
child.folder.name.toLowerCase().includes(this.query.toLowerCase())
)),
}
)).filter((folder) => (
folder.folder.name.toLowerCase().includes(this.query.toLowerCase())
|| folder.children.length > 0
));
return this.filteredFoldersTreeHelper(this.foldersTree);
},
},
methods: {
filteredFoldersTreeHelper(foldersTree) {
return foldersTree.map(({ folder, children }) => {
if (folder.name.toLowerCase().includes(this.query.toLowerCase())) {
return { folder, children };
}
const filteredChildren = this.filteredFoldersTreeHelper(children);
return filteredChildren.length ? { folder, children: filteredChildren } : null;
}).filter(Boolean);
},
selectFolder(folderId) {
this.selectedFolderId = folderId;
},

View file

@ -50,9 +50,10 @@
:repository="duplicateRepository"
@close="duplicateRepository = null"
@duplicate="updateTable" />
<ShareRepositoryModal
<ShareObjectModal
v-if="shareRepository"
:repository="shareRepository"
:object="shareRepository"
:globalShareEnabled="true"
@close="shareRepository = null"
@share="updateTable" />
</template>
@ -66,7 +67,7 @@ import ExportRepositoryModal from './modals/export.vue';
import NewRepositoryModal from './modals/new.vue';
import EditRepositoryModal from './modals/edit.vue';
import DuplicateRepositoryModal from './modals/duplicate.vue';
import ShareRepositoryModal from './modals/share.vue';
import ShareObjectModal from '../shared/share_modal.vue';
import DataTable from '../shared/datatable/table.vue';
import NameRenderer from './renderers/name.vue';
@ -79,8 +80,8 @@ export default {
NewRepositoryModal,
EditRepositoryModal,
DuplicateRepositoryModal,
ShareRepositoryModal,
NameRenderer
NameRenderer,
ShareObjectModal
},
props: {
dataSource: {

View file

@ -312,6 +312,11 @@
</div>
</section>
<div v-if="!repository?.is_snapshot" id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<!-- Locations -->
<section v-if="!repository?.is_snapshot" id="locations-section" ref="locationsSectionRef" data-e2e="e2e-CO-itemCard-locations">
<Locations :repositoryRow="repositoryRow" :repository="repository" @reloadRow="reload" />
</section>
<div v-if="!repository?.is_snapshot" id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<!-- QR -->
@ -367,6 +372,7 @@ import ScrollSpy from './repository_values/ScrollSpy.vue';
import CustomColumns from './customColumns.vue';
import RepositoryItemSidebarTitle from './Title.vue';
import UnlinkModal from './unlink_modal.vue';
import Locations from './locations.vue';
import axios from '../../packs/custom_axios.js';
const items = [
@ -405,6 +411,14 @@ const items = [
{
id: 'highlight-item-5',
textId: 'text-item-5',
labelAlias: 'locations_label',
label: 'locations-label',
sectionId: 'locations-section',
showInSnapshot: false
},
{
id: 'highlight-item-6',
textId: 'text-item-6',
labelAlias: 'QR_label',
label: 'QR-label',
sectionId: 'qr-section',
@ -416,6 +430,7 @@ export default {
name: 'RepositoryItemSidebar',
components: {
CustomColumns,
Locations,
'repository-item-sidebar-title': RepositoryItemSidebarTitle,
'inline-edit': InlineEdit,
'scroll-spy': ScrollSpy,
@ -433,6 +448,7 @@ export default {
repository: null,
defaultColumns: null,
customColumns: null,
repositoryRow: null,
parentsCount: 0,
childrenCount: 0,
parents: null,
@ -591,6 +607,7 @@ export default {
{ params: { my_module_id: this.myModuleId } }
).then((response) => {
const result = response.data;
this.repositoryRow = result;
this.repositoryRowId = result.id;
this.repository = result.repository;
this.optionsPath = result.options_path;

View file

@ -0,0 +1,77 @@
<template>
<div v-if="repositoryRow">
<div class="flex items-center gap-4">
<h4 data-e2e="e2e-TX-itemCard-locations-title">{{ i18n.t('repositories.locations.title', { count: repositoryRow.storage_locations.locations.length }) }}</h4>
<button v-if="repositoryRow.permissions.can_manage && repositoryRow.storage_locations.enabled"
@click="openAssignModal = true" class="btn btn-light ml-auto" data-e2e="e2e-BT-itemCard-assignLocation">
{{ i18n.t('repositories.locations.assign') }}
</button>
</div>
<template v-for="(location, index) in repositoryRow.storage_locations.locations" :key="location.id">
<div>
<div class="sci-divider my-4" v-if="index > 0"></div>
<div class="flex items-center gap-2 mb-3">
{{ i18n.t('repositories.locations.container') }}:
<a v-if="location.readable" :href="containerUrl(location.id)">{{ location.name }}</a>
<span v-else>{{ location.name }}</span>
<span v-if="location.metadata.display_type !== 'grid'">
({{ location.positions.length }})
</span>
</div>
<div v-if="location.metadata.display_type === 'grid'" class="flex items-center gap-2 flex-wrap">
<div v-for="(position) in location.positions" :key="position.id">
<div v-if="position.metadata.position" class="flex items-center font-sm gap-1 uppercase bg-sn-grey-300 rounded pl-1.5 pr-2">
{{ formatPosition(position.metadata.position) }}
<i v-if="repositoryRow.permissions.can_manage" class="sn-icon sn-icon-unlink-italic-s cursor-pointer"></i>
</div>
</div>
</div>
</div>
</template>
<Teleport to="body">
<AssignModal
v-if="openAssignModal"
assignMode="assign"
:selectedRow="repositoryRow.id"
@close="openAssignModal = false; $emit('reloadRow')"
></AssignModal>
</Teleport>
</div>
</template>
<script>
import AssignModal from '../storage_locations/modals/assign.vue';
import {
storage_location_path
} from '../../routes.js';
export default {
name: 'RepositoryItemLocations',
props: {
repositoryRow: Object,
repository: Object
},
components: {
AssignModal
},
data() {
return {
openAssignModal: false
};
},
methods: {
containerUrl(id) {
return storage_location_path(id);
},
formatPosition(position) {
if (position) {
return `${this.numberToLetter(position[0])}${position[1]}`;
}
return '';
},
numberToLetter(number) {
return String.fromCharCode(96 + number);
}
}
};
</script>

View file

@ -169,6 +169,9 @@ export default {
resultToReload: { type: Number, required: false },
activeDragResult: {
required: false
},
userSettingsUrl: {
required: false
}
},
data() {
@ -227,6 +230,17 @@ export default {
deep: true
}
},
mounted() {
this.$nextTick(() => {
const resultId = `#resultBody${this.result.id}`;
this.isCollapsed = this.result.attributes.collapsed;
if (this.isCollapsed) {
$(resultId).collapse('hide');
} else {
$(resultId).collapse('show');
}
});
},
computed: {
reorderableElements() {
return this.orderedElements.map((e) => ({ id: e.id, attributes: e.attributes.orderable }));
@ -337,6 +351,13 @@ export default {
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
this.result.attributes.collapsed = this.isCollapsed;
const settings = {
key: 'result_states',
data: { [this.result.id]: this.isCollapsed }
};
axios.put(this.userSettingsUrl, { settings: [settings] });
},
dragEnter(e) {
if (!this.urls.upload_attachment_url) return;

View file

@ -22,6 +22,7 @@
:result="result"
:resultToReload="resultToReload"
:activeDragResult="activeDragResult"
:userSettingsUrl="userSettingsUrl"
@result:elements:loaded="resultToReload = null"
@result:move_element="reloadResult"
@result:attachments:loaded="resultToReload = null"
@ -64,7 +65,8 @@ export default {
canCreate: { type: String, required: true },
archived: { type: String, required: true },
active_url: { type: String, required: true },
archived_url: { type: String, required: true }
archived_url: { type: String, required: true },
userSettingsUrl: { type: String, required: false }
},
data() {
return {
@ -74,10 +76,12 @@ export default {
resultToReload: null,
nextPageUrl: null,
loadingPage: false,
activeDragResult: null
activeDragResult: null,
userSettingsUrl: null
};
},
mounted() {
this.userSettingsUrl = document.querySelector('meta[name="user-settings-url"]').getAttribute('content');
window.addEventListener('scroll', this.loadResults, false);
window.addEventListener('scroll', this.initStackableHeaders, false);
this.nextPageUrl = this.url;

View file

@ -364,7 +364,7 @@ export default {
}
},
handleScroll() {
if (this.scrollMode === 'pages') return;
if (this.scrollMode === 'pages' || this.scrollMode === 'none') return;
let target = null;
if (this.currentViewRender === 'cards') {
@ -506,15 +506,18 @@ export default {
this.rowData = [];
}
if (this.scrollMode === 'pages') {
if (this.scrollMode === 'pages' || this.scrollMode === 'none') {
if (this.gridApi) this.gridApi.setRowData(this.formatData(response.data.data));
this.rowData = this.formatData(response.data.data);
} else {
this.handleInfiniteScroll(response);
}
this.totalPage = response.data.meta.total_pages;
this.totalEntries = response.data.meta.total_count;
this.$emit('tableReloaded');
if (this.scrollMode !== 'none') {
this.totalPage = response.data.meta.total_pages;
this.totalEntries = response.data.meta.total_count;
}
this.$emit('tableReloaded', this.rowData);
this.dataLoading = false;
this.restoreSelection();
@ -577,8 +580,11 @@ export default {
this.gridApi.forEachNode((node) => {
if (this.selectedRows.find((row) => row.id === node.data.id)) {
node.setSelected(true);
} else {
node.setSelected(false);
}
});
this.$emit('selectionChanged', this.selectedRows);
}
},
setSelectedRows(e) {
@ -591,6 +597,7 @@ export default {
} else {
this.selectedRows = this.selectedRows.filter((row) => row.id !== e.data.id);
}
this.$emit('selectionChanged', this.selectedRows);
},
emitAction(action) {
this.$emit(action.name, action, this.selectedRows);
@ -602,6 +609,7 @@ export default {
clickCell(e) {
if (e.column.colId !== 'rowMenu' && e.column.userProvidedColDef.notSelectable !== true) {
e.node.setSelected(true);
this.$emit('selectionChanged', this.selectedRows);
}
},
applyFilters(filters) {

View file

@ -20,7 +20,7 @@
{{ i18n.t('repositories.import_records.dragAndDropUpload.importText.firstPart') }}
</span> {{ i18n.t('repositories.import_records.dragAndDropUpload.importText.secondPart') }}
</div>
<div class="text-sn-grey">
<div class="text-sn-grey text-center">
{{ supportingText }}
</div>
</div>
@ -32,6 +32,7 @@
</template>
<script>
/* global GLOBAL_CONSTANTS I18n */
export default {
name: 'DragAndDropUpload',
@ -69,7 +70,9 @@ export default {
// check if it's a correct file type
const fileExtension = file.name.split('.')[1];
if (!this.supportedFormats.includes(fileExtension)) {
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError');
const error = I18n.t('repositories.import_records.dragAndDropUpload.wrongFileTypeError', {
extensions: this.supportedFormats.join(', ')
});
this.$emit('file:error', error);
return false;
}

View file

@ -7,29 +7,29 @@
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t('repositories.index.modal_share.title', {name: repository.name }) }}
{{ i18n.t('modal_share.title', {object_name: object.name }) }}
</h4>
</div>
<div class="modal-body">
<div class="grid grid-cols-3 gap-2">
<div class="col-span-2">
{{ i18n.t("repositories.index.modal_share.share_with_team") }}
{{ i18n.t("modal_share.share_with_team") }}
</div>
<div class="text-center">
{{ i18n.t("repositories.index.modal_share.can_edit") }}
{{ i18n.t("modal_share.can_edit") }}
</div>
<div class="col-span-2 flex items-center h-9 gap-1">
<div v-if="globalShareEnabled" class="col-span-2 flex items-center h-9 gap-1">
<span class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="sharedWithAllRead" />
<span class="sci-checkbox-label"></span>
</span>
{{ i18n.t("repositories.index.modal_share.all_teams") }}
{{ i18n.t("modal_share.all_teams") }}
</div>
<div class="flex justify-center items-center">
<div v-if="globalShareEnabled" class="flex justify-center items-center">
<span v-if="sharedWithAllRead" class="sci-toggle-checkbox-container">
<input type="checkbox"
class="sci-toggle-checkbox"
:disabled="!repository.shareable_write"
:disabled="!object.shareable_write"
v-model="sharedWithAllWrite" />
<span class="sci-toggle-checkbox-label"></span>
</span>
@ -48,8 +48,7 @@
class="sci-toggle-checkbox-container">
<input type="checkbox"
class="sci-toggle-checkbox"
@change="permission_changes[team.id] = true"
:disabled="!repository.shareable_write"
:disabled="!object.shareable_write"
v-model="team.attributes.private_shared_with_write" />
<span class="sci-toggle-checkbox-label"></span>
</span>
@ -60,7 +59,7 @@
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">
{{ i18n.t('repositories.index.modal_share.submit') }}
{{ i18n.t('modal_share.submit') }}
</button>
</div>
</div>
@ -71,19 +70,20 @@
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
import axios from '../../packs/custom_axios.js';
import modalMixin from './modal_mixin';
export default {
name: 'ShareRepositoryModal',
name: 'ShareObjectModal',
props: {
repository: Object
object: Object,
globalShareEnabled: { type: Boolean, default: false }
},
mixins: [modalMixin],
data() {
return {
sharedWithAllRead: this.repository.shared_read || this.repository.shared_write,
sharedWithAllWrite: this.repository.shared_write,
sharedWithAllRead: this.object.shared_read || this.object.shared_write,
sharedWithAllWrite: this.object.shared_write,
shareableTeams: [],
permission_changes: {}
};
@ -93,7 +93,7 @@ export default {
},
methods: {
getTeams() {
axios.get(this.repository.urls.shareable_teams).then((response) => {
axios.get(this.object.urls.shareable_teams).then((response) => {
this.shareableTeams = response.data.data;
});
},
@ -101,14 +101,12 @@ export default {
const data = {
select_all_teams: this.sharedWithAllRead,
select_all_write_permission: this.sharedWithAllWrite,
share_team_ids: this.shareableTeams.filter((team) => team.attributes.private_shared_with).map((team) => team.id),
write_permissions: this.shareableTeams.filter((team) => team.attributes.private_shared_with_write).map((team) => team.id),
permission_changes: this.permission_changes
team_share_params: this.shareableTeams.map((team) => { return { id: team.id, ...team.attributes } })
};
axios.post(this.repository.urls.share, data).then(() => {
axios.post(this.object.urls.share, data).then(() => {
HelperModule.flashAlertMsg(this.i18n.t(
'repositories.index.modal_share.success_message',
{ inventory_name: this.repository.name }
'modal_share.success_message',
{ object_name: this.object.name }
), 'success');
this.$emit('share');
});

View file

@ -0,0 +1,249 @@
<template>
<div class="grid w-full h-full gap-6" :class="{ 'grid-cols-[auto_1fr]': withGrid }">
<div v-if="withGrid" class="max-w-[40vw]">
<Grid
:gridSize="gridSize"
:assignedItems="assignedItems"
:selectedItems="selectedItems"
@assign="assignRowToPosition"
@select="selectRow"
/>
</div>
<div class="h-full bg-white px-4">
<DataTable :columnDefs="columnDefs"
tableId="StorageLocationsContainer"
:dataUrl="dataSource"
ref="table"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
:scrollMode="paginationMode"
@assign="assignRow"
@move="moveRow"
@import="openImportModal = true"
@unassign="unassignRows"
@tableReloaded="handleTableReload"
@selectionChanged="selectedItems = $event"
/>
</div>
<Teleport to="body">
<AssignModal
v-if="openAssignModal"
:assignMode="assignMode"
:selectedContainer="assignToContainer"
:selectedPosition="assignToPosition"
:selectedRow="rowIdToMove"
:cellId="cellIdToUnassign"
@close="openAssignModal = false; this.reloadingTable = true"
></AssignModal>
<ImportModal
v-if="openImportModal"
:containerId="containerId"
@close="openImportModal = false"
@reloadTable="reloadingTable = true"
></ImportModal>
<ConfirmationModal
:title="i18n.t('storage_locations.show.unassign_modal.title')"
:description="storageLocationUnassignDescription"
confirmClass="btn btn-danger"
:confirmText="i18n.t('storage_locations.show.unassign_modal.button')"
ref="unassignStorageLocationModal"
></ConfirmationModal>
</Teleport>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import Grid from './grid.vue';
import AssignModal from './modals/assign.vue';
import ImportModal from './modals/import.vue';
import ConfirmationModal from '../shared/confirmation_modal.vue';
import RemindersRender from './renderers/reminders.vue';
export default {
name: 'StorageLocationsContainer',
components: {
DataTable,
Grid,
AssignModal,
ConfirmationModal,
RemindersRender,
ImportModal
},
props: {
canManage: {
type: String,
required: true
},
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
withGrid: {
type: Boolean,
default: false
},
containerId: {
type: Number,
default: null
},
gridSize: Array
},
data() {
return {
reloadingTable: false,
openEditModal: false,
openImportModal: false,
editModalMode: null,
editStorageLocation: null,
objectToMove: null,
moveToUrl: null,
assignedItems: [],
selectedItems: [],
openAssignModal: false,
assignToPosition: null,
assignToContainer: null,
rowIdToMove: null,
cellIdToUnassign: null,
assignMode: 'assign',
storageLocationUnassignDescription: ''
};
},
computed: {
paginationMode() {
return this.withGrid ? 'none' : 'pages';
},
columnDefs() {
const columns = [{
field: 'position_formatted',
headerName: this.i18n.t('storage_locations.show.table.position'),
sortable: true,
notSelectable: true
},
{
field: 'reminders',
headerName: this.i18n.t('storage_locations.show.table.reminders'),
sortable: true,
cellRenderer: RemindersRender
},
{
field: 'row_id',
headerName: this.i18n.t('storage_locations.show.table.row_id'),
sortable: true
},
{
field: 'row_name',
headerName: this.i18n.t('storage_locations.show.table.row_name'),
sortable: true,
cellRenderer: this.rowNameRenderer
},
{
field: 'stock',
headerName: this.i18n.t('storage_locations.show.table.stock'),
sortable: true
}];
return columns;
},
toolbarActions() {
const left = [];
if (this.canManage) {
left.push({
name: 'assign',
icon: 'sn-icon sn-icon-new-task',
label: this.i18n.t('storage_locations.show.toolbar.assign'),
type: 'emit',
buttonStyle: 'btn btn-primary'
});
}
left.push({
name: 'import',
icon: 'sn-icon sn-icon-import',
label: this.i18n.t('storage_locations.show.import_modal.import_button'),
type: 'emit',
buttonStyle: 'btn btn-light'
});
return {
left,
right: []
};
}
},
methods: {
rowNameRenderer(params) {
const { row_name: rowName, hidden } = params.data;
if (hidden) {
return `
<span class="text-sn-grey-700">
<i class="sn-icon sn-icon-locked-task"></i> ${this.i18n.t('storage_locations.show.hidden')}
</span>
`;
}
return rowName;
},
handleTableReload(items) {
this.reloadingTable = false;
this.assignedItems = items;
},
selectRow(row) {
if (this.$refs.table.selectedRows.includes(row)) {
this.$refs.table.selectedRows = this.$refs.table.selectedRows.filter((r) => r !== row);
} else {
this.$refs.table.selectedRows.push(row);
}
this.$refs.table.restoreSelection();
},
assignRow() {
this.openAssignModal = true;
this.rowIdToMove = null;
this.assignToContainer = this.containerId;
this.assignToPosition = null;
this.cellIdToUnassign = null;
this.assignMode = 'assign';
},
assignRowToPosition(position) {
this.openAssignModal = true;
this.rowIdToMove = null;
this.assignToContainer = this.containerId;
this.assignToPosition = position;
this.cellIdToUnassign = null;
this.assignMode = 'assign';
},
moveRow(_event, data) {
this.openAssignModal = true;
this.rowIdToMove = data[0].row_id;
this.assignToContainer = null;
this.assignToPosition = null;
this.cellIdToUnassign = data[0].id;
this.assignMode = 'move';
},
async unassignRows(event, rows) {
this.storageLocationUnassignDescription = this.i18n.t(
'storage_locations.show.unassign_modal.description',
{ items: rows.length }
);
const ok = await this.$refs.unassignStorageLocationModal.show();
if (ok) {
axios.post(event.path).then(() => {
this.reloadingTable = true;
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
}
}
};
</script>

View file

@ -0,0 +1,150 @@
<template>
<div class="grid grid-cols-[1.5rem_auto] grid-rows-[1.5rem_auto] overflow-hidden">
<div class="z-10 bg-sn-super-light-grey"></div>
<div ref="columnsContainer" class="overflow-x-hidden">
<div :style="{'width': `${columnsList.length * 54}px`}">
<div v-for="column in columnsList" :key="column" class="uppercase float-left flex items-center justify-center w-[54px] ">
<span>{{ column }}</span>
</div>
</div>
</div>
<div ref="rowContainer" class="overflow-y-hidden max-h-[70vh]">
<div v-for="row in rowsList" :key="row" class="uppercase flex items-center justify-center h-[54px]">
<span>{{ row }}</span>
</div>
</div>
<div ref="cellsContainer" class="overflow-hidden max-h-[70vh] relative">
<div class="grid" :style="{
'grid-template-columns': `repeat(${columnsList.length}, 1fr)`,
'width': `${columnsList.length * 54}px`
}">
<div v-for="cell in cellsList" :key="cell.row + cell.column" class="cell">
<div class="w-[54px] h-[54px] uppercase items-center flex justify-center p-1
border border-solid !border-transparent !border-b-sn-grey !border-r-sn-grey"
:class="{ '!border-t-sn-grey': cell.row === 0, '!border-l-sn-grey': cell.column === 0 }"
>
<div
class="h-full w-full rounded-full items-center flex justify-center"
@click="assignRow(cell)"
:class="{
'bg-sn-background-green': cellIsOccupied(cell),
'bg-sn-grey-100': cellIsHidden(cell),
'bg-white': cellIsAvailable(cell),
'bg-white border-sn-science-blue border-solid border-[1px]': cellIsSelected(cell),
'cursor-pointer': !cellIsHidden(cell)
}"
>
<template v-if="cellIsHidden(cell)">
<i class="sn-icon sn-icon-locked-task"></i>
</template>
<template v-else>
{{ rowsList[cell.row] }}{{ columnsList[cell.column] }}
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
/* global PerfectScrollbar */
export default {
name: 'StorageLocationsGrid',
props: {
gridSize: {
type: Array,
required: true
},
assignedItems: {
type: Array,
default: () => []
},
selectedItems: {
type: Array,
default: () => []
}
},
data() {
return {
scrollBar: null
};
},
mounted() {
this.$refs.cellsContainer.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleScroll);
this.scrollBar = new PerfectScrollbar(this.$refs.cellsContainer, { wheelSpeed: 0.5, minScrollbarLength: 20 });
},
beforeUnmount() {
this.$refs.cellsContainer.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleScroll);
},
computed: {
columnsList() {
return Array.from({ length: this.gridSize[1] }, (v, i) => i + 1);
},
rowsList() {
return Array.from({ length: this.gridSize[0] }, (v, i) => String.fromCharCode(97 + i));
},
cellsList() {
const cells = [];
for (let i = 0; i < this.gridSize[0]; i++) {
for (let j = 0; j < this.gridSize[1]; j++) {
cells.push({ row: i, column: j });
}
}
return cells;
}
},
methods: {
cellObject(cell) {
return this.assignedItems.find((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1);
},
cellIsOccupied(cell) {
return this.cellObject(cell) && !this.cellObject(cell)?.hidden;
},
cellIsHidden(cell) {
return this.cellObject(cell)?.hidden;
},
cellIsSelected(cell) {
return this.selectedItems.some((item) => item.position[0] === cell.row + 1 && item.position[1] === cell.column + 1);
},
cellIsAvailable(cell) {
return !this.cellIsOccupied(cell) && !this.cellIsHidden(cell);
},
assignRow(cell) {
if (this.cellIsOccupied(cell)) {
this.$emit('select', this.cellObject(cell));
return;
}
if (this.cellIsHidden(cell)) {
return;
}
this.$emit('assign', [cell.row + 1, cell.column + 1]);
},
handleScroll() {
this.$refs.columnsContainer.scrollLeft = this.$refs.cellsContainer.scrollLeft;
this.$refs.rowContainer.scrollTop = this.$refs.cellsContainer.scrollTop;
if (this.$refs.cellsContainer.scrollLeft > this.$refs.columnsContainer.scrollLeft) {
this.$refs.cellsContainer.scrollLeft = this.$refs.columnsContainer.scrollLeft;
}
if (this.$refs.rowContainer.scrollTop > 0) {
this.$refs.columnsContainer.style.boxShadow = '0px 0px 20px 0px rgba(16, 24, 40, 0.20)';
} else {
this.$refs.columnsContainer.style.boxShadow = 'none';
}
if (this.$refs.columnsContainer.scrollLeft > 0) {
this.$refs.rowContainer.style.boxShadow = '0px 0px 20px 0px rgba(16, 24, 40, 0.20)';
} else {
this.$refs.rowContainer.style.boxShadow = 'none';
}
}
}
};
</script>

View file

@ -0,0 +1,98 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_title`) }}
</h4>
</div>
<div class="modal-body">
<p class="mb-4">
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_description`) }}
</p>
<RowSelector v-if="!selectedRow" @change="this.rowId = $event" class="mb-4"></RowSelector>
<ContainerSelector v-if="!selectedContainer" @change="this.containerId = $event"></ContainerSelector>
<PositionSelector
v-if="containerId && !selectedPosition"
:key="containerId"
:selectedContainerId="containerId"
@change="this.position = $event"></PositionSelector>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit">
{{ i18n.t(`storage_locations.show.assign_modal.${assignMode}_action`) }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
import RowSelector from './assign/row_selector.vue';
import ContainerSelector from './assign/container_selector.vue';
import PositionSelector from './assign/position_selector.vue';
import {
storage_location_storage_location_repository_rows_path,
move_storage_location_storage_location_repository_row_path,
} from '../../../routes.js';
export default {
name: 'NewProjectModal',
props: {
selectedRow: Number,
selectedContainer: Number,
cellId: Number,
selectedPosition: Array,
assignMode: String
},
mixins: [modalMixin],
computed: {
createUrl() {
return storage_location_storage_location_repository_rows_path({
storage_location_id: this.containerId
});
},
moveUrl() {
return move_storage_location_storage_location_repository_row_path(this.containerId, this.cellId);
},
actionUrl() {
return this.assignMode === 'assign' ? this.createUrl : this.moveUrl;
}
},
data() {
return {
rowId: this.selectedRow,
containerId: this.selectedContainer,
position: this.selectedPosition
};
},
components: {
RowSelector,
ContainerSelector,
PositionSelector
},
methods: {
submit() {
axios.post(this.actionUrl, {
repository_row_id: this.rowId,
metadata: { position: this.position?.map((pos) => parseInt(pos, 10)) }
}).then(() => {
this.$emit('close');
});
}
}
};
</script>

View file

@ -0,0 +1,48 @@
<template>
<div>
<div class="mb-4">
<div class="sci-input-container-v2 left-icon">
<input type="text"
v-model="query"
class="sci-input-field"
ref="input"
autofocus="true"
:placeholder=" i18n.t('storage_locations.index.move_modal.placeholder.find_storage_locations')" />
<i class="sn-icon sn-icon-search"></i>
</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div class="p-2 flex items-center gap-2 cursor-pointer text-sn-blue hover:bg-sn-super-light-grey"
@click="selectStorageLocation(null)"
:class="{'!bg-sn-super-light-blue': selectedStorageLocationId == null}">
<i class="sn-icon sn-icon-projects"></i>
{{ i18n.t('storage_locations.index.move_modal.search_header') }}
</div>
<MoveTree
:storageLocationsTree="filteredStorageLocationsTree"
:moveMode="moveMode"
:value="selectedStorageLocationId"
@selectStorageLocation="selectStorageLocation" />
</div>
</div>
</template>
<script>
import MoveTreeMixin from '../move_tree_mixin';
export default {
name: 'ContainerSelector',
mixins: [MoveTreeMixin],
data() {
return {
container: true,
moveMode: 'containers'
};
},
watch: {
selectedStorageLocationId() {
this.$emit('change', this.selectedStorageLocationId);
}
}
};
</script>

View file

@ -0,0 +1,79 @@
<template>
<div v-if="availablePositions" class="grid grid-cols-2 gap-4 mb-4">
<div class="">
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.row`) }}</div>
<SelectDropdown
:options="availableRows"
:value="selectedRow"
@change="selectedRow = $event"
></SelectDropdown>
</div>
<div>
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.column`) }}</div>
<SelectDropdown
:disabled="!selectedRow"
:options="availableColumns"
:value="selectedColumn"
@change="selectedColumn= $event"
></SelectDropdown>
</div>
</div>
</template>
<script>
import SelectDropdown from '../../../shared/select_dropdown.vue';
import axios from '../../../../packs/custom_axios.js';
import {
available_positions_storage_location_path,
} from '../../../../routes.js';
export default {
name: 'PositionSelector',
components: {
SelectDropdown
},
props: {
selectedContainerId: Number
},
created() {
axios.get(this.positionsUrl)
.then((response) => {
this.availablePositions = response.data.positions;
if (this.availablePositions) {
this.$nextTick(() => {
[[this.selectedRow]] = this.availableRows;
this.$nextTick(() => {
[[this.selectedColumn]] = this.availableColumns;
});
});
}
});
},
watch: {
selectedRow() {
[[this.selectedColumn]] = this.availableColumns;
},
selectedColumn() {
this.$emit('change', [this.selectedRow, this.selectedColumn]);
}
},
computed: {
positionsUrl() {
return available_positions_storage_location_path(this.selectedContainerId);
},
availableRows() {
return Object.keys(this.availablePositions).map((row) => [row, row]);
},
availableColumns() {
return (this.availablePositions[this.selectedRow] || []).map((col) => [col, col]);
}
},
data() {
return {
availablePositions: {},
selectedRow: null,
selectedColumn: null
};
}
};
</script>

View file

@ -0,0 +1,69 @@
<template>
<div>
<div class="mb-4">
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.inventory`) }}</div>
<SelectDropdown
:optionsUrl="repositoriesUrl"
placeholder="Select inventory"
:searchable="true"
@change="selectedRepository = $event"
></SelectDropdown>
</div>
<div>
<div class="sci-label">{{ i18n.t(`storage_locations.show.assign_modal.item`) }}</div>
<SelectDropdown
:disabled="!selectedRepository"
:optionsUrl="rowsUrl"
:urlParams="{ repository_id: selectedRepository }"
placeholder="Select item"
:searchable="true"
@change="selectedRow= $event"
></SelectDropdown>
</div>
</div>
</template>
<script>
import SelectDropdown from '../../../shared/select_dropdown.vue';
import {
list_team_repositories_path,
rows_list_team_repositories_path
} from '../../../../routes.js';
export default {
name: 'RowSelector',
components: {
SelectDropdown
},
created() {
this.teamId = document.body.dataset.currentTeamId;
},
watch: {
selectedRepository() {
this.selectedRow = null;
},
selectedRow() {
this.$emit('change', this.selectedRow);
}
},
computed: {
repositoriesUrl() {
return list_team_repositories_path(this.teamId);
},
rowsUrl() {
if (!this.selectedRepository) {
return null;
}
return rows_list_team_repositories_path(this.teamId);
}
},
data() {
return {
selectedRepository: null,
selectedRow: null,
teamId: null
};
}
};
</script>

View file

@ -0,0 +1,118 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<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">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate" id="edit-project-modal-label">
{{ i18n.t('storage_locations.show.import_modal.title') }}
</h4>
</div>
<div class="modal-body flex flex-col grow">
<p>
{{ i18n.t('storage_locations.show.import_modal.description') }}
</p>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('storage_locations.show.import_modal.export') }}
</h3>
<div class="flex gap-4 mb-6">
<a
:href="exportUrl"
target="_blank"
class="btn btn-secondary btn-sm"
>
<i class="sn-icon sn-icon-export"></i>
{{ i18n.t('storage_locations.show.import_modal.export_button') }}
</a>
</div>
<h3 class="my-0 text-sn-dark-grey mb-3">
{{ i18n.t('storage_locations.show.import_modal.import') }}
</h3>
<DragAndDropUpload
class="h-60"
@file:dropped="uploadFile"
@file:error="handleError"
@file:error:clear="this.error = null"
:supportingText="`${i18n.t('storage_locations.show.import_modal.drag_n_drop')}`"
:supportedFormats="['xlsx']"
/>
</div>
<div class="modal-footer">
<div v-if="error" class="flex flex-row gap-2 my-auto mr-auto text-sn-delete-red">
<i class="sn-icon sn-icon-alert-warning"></i>
<div class="my-auto">{{ error }}</div>
</div>
<button class="btn btn-secondary" @click="close" aria-label="Close">
{{ i18n.t('general.cancel') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DragAndDropUpload from '../../shared/drag_and_drop_upload.vue';
import modalMixin from '../../shared/modal_mixin';
import axios from '../../../packs/custom_axios';
import {
export_container_storage_location_path,
import_container_storage_location_path
} from '../../../routes.js';
export default {
name: 'ImportContainer',
emits: ['uploadFile', 'close'],
components: {
DragAndDropUpload
},
mixins: [modalMixin],
props: {
containerId: {
type: Number,
required: true
}
},
data() {
return {
error: null
};
},
computed: {
exportUrl() {
return export_container_storage_location_path({
id: this.containerId
});
},
importUrl() {
return import_container_storage_location_path({
id: this.containerId
});
}
},
methods: {
handleError(error) {
this.error = error;
},
uploadFile(file) {
const formData = new FormData();
// required payload
formData.append('file', file);
axios.post(this.importUrl, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
.then(() => {
this.$emit('reloadTable');
this.close();
}).catch((error) => {
this.handleError(error.response.data.message);
});
}
}
};
</script>

View file

@ -0,0 +1,88 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t('storage_locations.index.move_modal.title', { name: this.selectedObject.name }) }}
</h4>
</div>
<div class="modal-body">
<div class="mb-4">{{ i18n.t('storage_locations.index.move_modal.description', { name: this.selectedObject.name }) }}</div>
<div class="mb-4">
<div class="sci-input-container-v2 left-icon">
<input type="text"
v-model="query"
class="sci-input-field"
ref="input"
autofocus="true"
:placeholder=" i18n.t('storage_locations.index.move_modal.placeholder.find_storage_locations')" />
<i class="sn-icon sn-icon-search"></i>
</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div class="p-2 flex items-center gap-2 cursor-pointer text-sn-blue hover:bg-sn-super-light-grey"
@click="selectStorageLocation(null)"
:class="{'!bg-sn-super-light-blue': selectedStorageLocationId == null}">
<i class="sn-icon sn-icon-projects"></i>
{{ i18n.t('storage_locations.index.move_modal.search_header') }}
</div>
<MoveTree
:storageLocationsTree="filteredStorageLocationsTree"
:moveMode="moveMode"
:value="selectedStorageLocationId"
@selectStorageLocation="selectStorageLocation" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit">
{{ i18n.t('general.move') }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
import MoveTreeMixin from './move_tree_mixin';
export default {
name: 'NewProjectModal',
props: {
selectedObject: Array,
moveToUrl: String
},
mixins: [modalMixin, MoveTreeMixin],
data() {
return {
selectedStorageLocationId: null,
storageLocationsTree: [],
query: '',
moveMode: 'locations'
};
},
methods: {
submit() {
axios.post(this.moveToUrl, {
destination_storage_location_id: this.selectedStorageLocationId || 'root_storage_location'
}).then((response) => {
this.$emit('move');
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
}
};
</script>

View file

@ -0,0 +1,58 @@
<template>
<div class="pl-6" v-if="storageLocationsTree.length" v-for="storageLocationTree in storageLocationsTree"
:key="storageLocationTree.storage_location.id">
<div class="flex items-center">
<i v-if="storageLocationTree.children.length > 0"
:class="{'sn-icon-up': opendedStorageLocations[storageLocationTree.storage_location.id],
'sn-icon-down': !opendedStorageLocations[storageLocationTree.storage_location.id]}"
@click="opendedStorageLocations[storageLocationTree.storage_location.id] = !opendedStorageLocations[storageLocationTree.storage_location.id]"
class="sn-icon p-2 pr-1 cursor-pointer"></i>
<i v-else class="sn-icon sn-icon-up p-2 pr-1 opacity-0"></i>
<div @click="selectStorageLocation(storageLocationTree)"
class="flex items-center pl-1 flex-1 gap-2"
:class="{
'!bg-sn-super-light-blue': storageLocationTree.storage_location.id == value,
'text-sn-blue cursor-pointer hover:bg-sn-super-light-grey': (
moveMode === 'locations' || storageLocationTree.storage_location.container
)
}">
<i v-if="storageLocationTree.storage_location.container" class="sn-icon sn-icon-item"></i>
<div class="flex-1 truncate p-2 pl-0" :title="storageLocationTree.storage_location.name">
{{ storageLocationTree.storage_location.name }}
</div>
</div>
</div>
<MoveTree v-if="opendedStorageLocations[storageLocationTree.storage_location.id]"
:storageLocationsTree="storageLocationTree.children"
:value="value"
:moveMode="moveMode"
@selectStorageLocation="$emit('selectStorageLocation', $event)" />
</div>
</template>
<script>
export default {
name: 'MoveTree',
emits: ['selectStorageLocation'],
props: {
storageLocationsTree: Array,
value: Number,
moveMode: String
},
components: {
MoveTree: () => import('./move_tree.vue')
},
data() {
return {
opendedStorageLocations: {}
};
},
methods: {
selectStorageLocation(storageLocationTree) {
if (this.moveMode === 'locations' || storageLocationTree.storage_location.container) {
this.$emit('selectStorageLocation', storageLocationTree.storage_location.id);
}
}
}
};
</script>

View file

@ -0,0 +1,50 @@
import axios from '../../../packs/custom_axios.js';
import MoveTree from './move_tree.vue';
import {
tree_storage_locations_path
} from '../../../routes.js';
export default {
mounted() {
axios.get(this.storageLocationsTreeUrl).then((response) => {
this.storageLocationsTree = response.data;
});
},
data() {
return {
selectedStorageLocationId: null,
storageLocationsTree: [],
query: ''
};
},
computed: {
storageLocationsTreeUrl() {
return tree_storage_locations_path({ format: 'json', container: this.container });
},
filteredStorageLocationsTree() {
if (this.query === '') {
return this.storageLocationsTree;
}
return this.filteredStorageLocationsTreeHelper(this.storageLocationsTree);
}
},
components: {
MoveTree
},
methods: {
filteredStorageLocationsTreeHelper(storageLocationsTree) {
return storageLocationsTree.map(({ storage_location, children }) => {
if (storage_location.name.toLowerCase().includes(this.query.toLowerCase())) {
return { storage_location, children };
}
const filteredChildren = this.filteredStorageLocationsTreeHelper(children);
return filteredChildren.length ? { storage_location, children: filteredChildren } : null;
}).filter(Boolean);
},
selectStorageLocation(storageLocationId) {
this.selectedStorageLocationId = storageLocationId;
}
}
};

View file

@ -0,0 +1,232 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" >
{{ i18n.t(`storage_locations.index.edit_modal.title_${mode}_${editModalMode}`) }}
</h4>
</div>
<div class="modal-body">
<p v-if="mode == 'create'" class="mb-6">{{ i18n.t(`storage_locations.index.edit_modal.description_create_${editModalMode}`) }}</p>
<div class="mb-6">
<label class="sci-label">
{{ i18n.t(`storage_locations.index.edit_modal.name_label_${editModalMode}`) }}
</label>
<div class="sci-input-container-v2">
<input
type="text"
v-model="object.name"
:placeholder="i18n.t(`storage_locations.index.edit_modal.name_placeholder`)"
>
</div>
<span v-if="this.errors.name" class="text-sn-coral text-xs">{{ this.errors.name }}</span>
</div>
<div v-if="editModalMode == 'container'" :title="warningBoxNotEmpty" class="mb-6">
<label class="sci-label">
{{ i18n.t(`storage_locations.index.edit_modal.dimensions_label`) }}
</label>
<div class="flex items-center gap-2 mb-4">
<div class="sci-radio-container">
<input type="radio" class="sci-radio" :disabled="!canChangeGrid" v-model="object.metadata.display_type" name="display_type" value="no_grid" >
<span class="sci-radio-label"></span>
</div>
<span>{{ i18n.t('storage_locations.index.edit_modal.no_grid') }}</span>
<i class="sn-icon sn-icon-info text-sn-grey" :title="i18n.t('storage_locations.index.edit_modal.no_grid_tooltip')"></i>
</div>
<div class="flex items-center gap-2 mb-4">
<div class="sci-radio-container">
<input type="radio" class="sci-radio" :disabled="!canChangeGrid" v-model="object.metadata.display_type" name="display_type" value="grid" >
<span class="sci-radio-label"></span>
</div>
<span>{{ i18n.t('storage_locations.index.edit_modal.grid') }}</span>
<div class="sci-input-container-v2 !w-28">
<input type="number" :disabled="!canChangeGrid" v-model="object.metadata.dimensions[0]" min="1" max="24">
</div>
<i class="sn-icon sn-icon-close-small"></i>
<div class="sci-input-container-v2 !w-28">
<input type="number" :disabled="!canChangeGrid" v-model="object.metadata.dimensions[1]" min="1" max="24">
</div>
</div>
</div>
<div class="mb-6">
<label class="sci-label">
{{ i18n.t(`storage_locations.index.edit_modal.image_label_${editModalMode}`) }}
</label>
<DragAndDropUpload
v-if="!attachedImage && !object.file_name"
class="h-60"
@file:dropped="addFile"
@file:error="handleError"
@file:error:clear="this.imageError = null"
:supportingText="`${i18n.t('storage_locations.index.edit_modal.drag_and_drop_supporting_text')}`"
:supportedFormats="['jpg', 'png', 'jpeg']"
/>
<div v-else class="border border-sn-light-grey rounded flex items-center p-2 gap-2">
<i class="sn-icon sn-icon-result-image text-sn-grey"></i>
<span class="text-sn-blue">{{ object.file_name || attachedImage?.name }}</span>
<i class="sn-icon sn-icon-close text-sn-blue ml-auto cursor-pointer" @click="removeImage"></i>
</div>
</div>
<div class="mb-6">
<label class="sci-label">
{{ i18n.t(`storage_locations.index.edit_modal.description_label`) }}
</label>
<div class="sci-input-container-v2 h-32">
<textarea
ref="description"
v-model="object.description"
:placeholder="i18n.t(`storage_locations.index.edit_modal.description_placeholder`)"
></textarea>
</div>
<span v-if="this.errors.description" class="text-sn-coral text-xs">{{ this.errors.description }}</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" :disabled="!validObject" type="submit">
{{ mode == 'create' ? i18n.t('general.create') : i18n.t('general.save') }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule SmartAnnotation ActiveStorage GLOBAL_CONSTANTS */
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
import DragAndDropUpload from '../../shared/drag_and_drop_upload.vue';
export default {
name: 'EditLocationModal',
props: {
createUrl: String,
editModalMode: String,
directUploadUrl: String,
editStorageLocation: Object
},
components: {
DragAndDropUpload
},
mixins: [modalMixin],
data() {
return {
object: {
metadata: {
dimensions: [9, 9],
display_type: 'grid'
}
},
attachedImage: null,
imageError: false,
errors: {}
};
},
computed: {
mode() {
return this.editStorageLocation ? 'edit' : 'create';
},
canChangeGrid() {
return !this.object.code || this.object.is_empty;
},
warningBoxNotEmpty() {
if (this.canChangeGrid) {
return '';
}
return this.i18n.t('storage_locations.index.edit_modal.warning_box_not_empty');
},
validObject() {
this.errors = {};
if (!this.object.name) {
return false;
}
if (this.object.name.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH) {
this.errors.name = this.i18n.t('storage_locations.index.edit_modal.errors.max_length', { max_length: GLOBAL_CONSTANTS.NAME_MAX_LENGTH });
return false;
}
if (this.object.description && this.object.description.length > GLOBAL_CONSTANTS.TEXT_MAX_LENGTH) {
this.errors.description = this.i18n.t('storage_locations.index.edit_modal.errors.max_length', { max_length: GLOBAL_CONSTANTS.NAME_MAX_LENGTH });
return false;
}
return true;
}
},
created() {
if (this.editStorageLocation) {
this.object = this.editStorageLocation;
}
},
mounted() {
SmartAnnotation.init($(this.$refs.description), false);
$(this.$refs.modal).on('hidden.bs.modal', this.handleAtWhoModalClose);
this.object.container = this.editModalMode === 'container';
},
methods: {
submit() {
if (this.attachedImage) {
this.uploadImage();
} else {
this.saveLocation();
}
},
saveLocation() {
if (this.object.code) {
axios.put(this.object.urls.update, this.object)
.then(() => {
this.$emit('tableReloaded');
HelperModule.flashAlertMsg(this.i18n.t(`storage_locations.index.edit_modal.success_message.edit_${this.editModalMode}`, { name: this.object.name }), 'success');
this.close();
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
} else {
axios.post(this.createUrl, this.object)
.then(() => {
this.$emit('tableReloaded');
HelperModule.flashAlertMsg(this.i18n.t(`storage_locations.index.edit_modal.success_message.create_${this.editModalMode}`, { name: this.object.name }), 'success');
this.close();
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
handleError() {
},
addFile(file) {
this.attachedImage = file;
},
removeImage() {
this.attachedImage = null;
this.object.file_name = null;
},
uploadImage() {
const upload = new ActiveStorage.DirectUpload(this.attachedImage, this.directUploadUrl);
upload.create((error, blob) => {
if (error) {
// Handle the error
} else {
this.object.signed_blob_id = blob.signed_id;
this.saveLocation();
}
});
},
handleAtWhoModalClose() {
$('.atwho-view.old').css('display', 'none');
}
}
};
</script>

View file

@ -0,0 +1,42 @@
<template>
<div v-if="params.data.have_reminders">
<GeneralDropdown ref="dropdown" position="right" @open="getReminders">
<template v-slot:field>
<i class="sn-icon sn-icon-notifications "></i>
</template>
<template v-slot:flyout>
<ul v-html="reminders.html" class="list-none p-0 reminders-view-mode"></ul>
</template>
</GeneralDropdown>
</div>
</template>
<script>
import axios from '../../../packs/custom_axios.js';
import GeneralDropdown from '../../shared/general_dropdown.vue';
export default {
name: 'RemindersRenderer',
props: {
params: {
required: true
}
},
data() {
return {
reminders: null
};
},
components: {
GeneralDropdown
},
methods: {
getReminders() {
axios.get(this.params.data.reminders_url)
.then((response) => {
this.reminders = response.data;
});
}
}
};
</script>

View file

@ -0,0 +1,280 @@
<template>
<div class="h-full">
<DataTable :columnDefs="columnDefs"
tableId="StorageLocationsTable"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
:filters="filters"
@create_location="openCreateLocationModal"
@create_container="openCreateContainerModal"
@edit="edit"
@duplicate="duplicate"
@tableReloaded="reloadingTable = false"
@move="move"
@delete="deleteStorageLocation"
@share="share"
/>
<Teleport to="body">
<EditModal v-if="openEditModal"
@close="openEditModal = false"
@tableReloaded="reloadingTable = true"
:createUrl="createUrl"
:editModalMode="editModalMode"
:directUploadUrl="directUploadUrl"
:editStorageLocation="editStorageLocation"
/>
<MoveModal v-if="objectToMove" :moveToUrl="moveToUrl"
:selectedObject="objectToMove"
@close="objectToMove = null" @move="updateTable()" />
<ConfirmationModal
:title="storageLocationDeleteTitle"
:description="storageLocationDeleteDescription"
confirmClass="btn btn-danger"
:confirmText="i18n.t('general.delete')"
ref="deleteStorageLocationModal"
></ConfirmationModal>
<ShareObjectModal
v-if="shareStorageLocation"
:object="shareStorageLocation"
@close="shareStorageLocation = null"
@share="updateTable" />
</Teleport>
</div>
</template>
<script>
/* global HelperModule */
import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import EditModal from './modals/new_edit.vue';
import MoveModal from './modals/move.vue';
import ConfirmationModal from '../shared/confirmation_modal.vue';
import ShareObjectModal from '../shared/share_modal.vue';
export default {
name: 'RepositoriesTable',
components: {
DataTable,
EditModal,
MoveModal,
ConfirmationModal,
ShareObjectModal
},
props: {
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
createLocationUrl: {
type: String
},
createLocationInstanceUrl: {
type: String
},
directUploadUrl: {
type: String
}
},
data() {
return {
reloadingTable: false,
openEditModal: false,
editModalMode: null,
editStorageLocation: null,
objectToMove: null,
moveToUrl: null,
shareStorageLocation: null,
storageLocationDeleteTitle: '',
storageLocationDeleteDescription: ''
};
},
computed: {
columnDefs() {
const columns = [{
field: 'name',
headerName: this.i18n.t('storage_locations.index.table.name'),
sortable: true,
notSelectable: true,
cellRenderer: this.nameRenderer
},
{
field: 'code',
headerName: this.i18n.t('storage_locations.index.table.id'),
sortable: true
},
{
field: 'sub_location_count',
headerName: this.i18n.t('storage_locations.index.table.sub_locations'),
width: 250,
sortable: true
},
{
field: 'items',
headerName: this.i18n.t('storage_locations.index.table.items'),
sortable: true
},
{
field: 'shared',
headerName: this.i18n.t('storage_locations.index.table.shared'),
sortable: true
},
{
field: 'owned_by',
headerName: this.i18n.t('storage_locations.index.table.owned_by'),
sortable: true
},
{
field: 'created_on',
headerName: this.i18n.t('storage_locations.index.table.created_on'),
sortable: true
},
{
field: 'description',
headerName: this.i18n.t('storage_locations.index.table.description'),
sortable: true
}];
return columns;
},
toolbarActions() {
const left = [];
if (this.createLocationUrl) {
left.push({
name: 'create_location',
icon: 'sn-icon sn-icon-new-task',
label: this.i18n.t('storage_locations.index.new_location'),
type: 'emit',
path: this.createLocationUrl,
buttonStyle: 'btn btn-primary'
});
}
if (this.createLocationInstanceUrl) {
left.push({
name: 'create_container',
icon: 'sn-icon sn-icon-item',
label: this.i18n.t('storage_locations.index.new_container'),
type: 'emit',
path: this.createLocationInstanceUrl,
buttonStyle: 'btn btn-secondary'
});
}
return {
left,
right: []
};
},
filters() {
const filters = [
{
key: 'query',
type: 'Text'
},
{
key: 'search_tree',
type: 'Checkbox',
label: this.i18n.t('storage_locations.index.filters_modal.search_tree')
}
];
return filters;
},
createUrl() {
return this.editModalMode === 'location' ? this.createLocationUrl : this.createLocationInstanceUrl;
}
},
methods: {
openCreateLocationModal() {
this.openEditModal = true;
this.editModalMode = 'location';
this.editStorageLocation = null;
},
openCreateContainerModal() {
this.openEditModal = true;
this.editModalMode = 'container';
this.editStorageLocation = null;
},
edit(action, params) {
this.openEditModal = true;
this.editModalMode = params[0].container ? 'container' : 'location';
[this.editStorageLocation] = params;
},
duplicate(action) {
axios.post(action.path)
.then(() => {
this.reloadingTable = true;
HelperModule.flashAlertMsg(this.i18n.t('storage_locations.index.duplicate.success_message'), 'success');
})
.catch(() => {
HelperModule.flashAlertMsg(this.i18n.t('errors.general'), 'danger');
});
},
// Renderers
nameRenderer(params) {
const {
name,
urls,
shared,
ishared
} = params.data;
let containerIcon = '';
if (params.data.container) {
containerIcon = '<i class="sn-icon sn-icon-item"></i>';
}
let sharedIcon = '';
if (shared || ishared) {
sharedIcon = '<i class="fas fa-users"></i>';
}
return `<a class="hover:no-underline flex items-center gap-1"
title="${name}" href="${urls.show}">
${sharedIcon}${containerIcon}
<span class="truncate">${name}</span>
</a>`;
},
updateTable() {
this.reloadingTable = true;
this.objectToMove = null;
this.shareStorageLocation = null;
},
move(event, rows) {
[this.objectToMove] = rows;
this.moveToUrl = event.path;
},
async deleteStorageLocation(event, rows) {
const storageLocationType = rows[0].container ? this.i18n.t('storage_locations.container') : this.i18n.t('storage_locations.location');
const description = `
<p>${this.i18n.t('storage_locations.index.delete_modal.description_1_html',
{ name: rows[0].name, type: storageLocationType, num_of_items: event.number_of_items })}</p>
<p>${this.i18n.t('storage_locations.index.delete_modal.description_2_html')}</p>`;
this.storageLocationDeleteDescription = description;
this.storageLocationDeleteTitle = this.i18n.t('storage_locations.index.delete_modal.title', { type: storageLocationType });
const ok = await this.$refs.deleteStorageLocationModal.show();
if (ok) {
axios.delete(event.path).then((_) => {
this.reloadingTable = true;
HelperModule.flashAlertMsg(this.i18n.t('storage_locations.index.delete_modal.success_message',
{
type: storageLocationType[0].toUpperCase() + storageLocationType.slice(1),
name: rows[0].name
}), 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
share(_event, rows) {
const [storageLocation] = rows;
this.shareStorageLocation = storageLocation;
}
}
};
</script>

View file

@ -2,6 +2,8 @@
# Provides asynchronous generation of image previews for ActiveStorage::Blob records.
class ActiveStorage::PreviewJob < ActiveStorage::BaseJob
include ActiveStorageHelper
queue_as :assets
discard_on StandardError do |job, error|
@ -18,11 +20,11 @@ class ActiveStorage::PreviewJob < ActiveStorage::BaseJob
def perform(blob_id)
blob = ActiveStorage::Blob.find(blob_id)
preview = blob.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT).processed
preview = blob.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT, format: image_preview_format(blob)).processed
Rails.logger.info "Preview for the Blod with id: #{blob.id} - successfully generated.\n" \
"Transformations applied: #{preview.variation.transformations}"
preview = blob.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT).processed
preview = blob.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT, format: image_preview_format(blob)).processed
Rails.logger.info "Preview for the Blod with id: #{blob.id} - successfully generated.\n" \
"Transformations applied: #{preview.variation.transformations}"

View file

@ -4,7 +4,9 @@ class CleanupUserSettingsJob < ApplicationJob
queue_as :default
def perform(record_type, record_id)
raise ArgumentError, 'Invalid record_type' unless %w(task_step_states results_order).include?(record_type)
unless %w(task_step_states results_order result_states).include?(record_type)
raise ArgumentError, 'Invalid record_type'
end
sanitized_record_id = record_id.to_i.to_s
raise ArgumentError, 'Invalid record_id' unless sanitized_record_id == record_id.to_s

View file

@ -187,6 +187,9 @@ class Activity < ApplicationRecord
when Asset
breadcrumbs[:asset] = subject.blob.filename.to_s
generate_breadcrumb(subject.result || subject.step || subject.repository_cell.repository_row.repository)
when StorageLocation
breadcrumbs[:storage_location] = subject.name
generate_breadcrumb(subject.team)
end
end

View file

@ -7,6 +7,7 @@ class Asset < ApplicationRecord
include WopiUtil
include ActiveStorageFileUtil
include ActiveStorageConcerns
include ActiveStorageHelper
require 'tempfile'
# Lock duration set to 30 minutes
@ -105,11 +106,11 @@ class Asset < ApplicationRecord
end
def medium_preview
preview_attachment.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT)
preview_attachment.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT, format: image_preview_format(blob))
end
def large_preview
preview_attachment.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT)
preview_attachment.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT, format: image_preview_format(blob))
end
def file_name

View file

@ -7,12 +7,18 @@ module Cloneable
raise NotImplementedError, "Cloneable model must implement the '.parent' method!" unless respond_to?(:parent)
clone_label = I18n.t('general.clone_label')
last_clone_number =
parent.public_send(self.class.table_name)
.select("substring(#{self.class.table_name}.name, '(?:^#{clone_label} )(\\d+)')::int AS clone_number")
.where('name ~ ?', "^#{clone_label} \\d+ - #{Regexp.escape(name)}$")
.order(clone_number: :asc)
.last&.clone_number
records = if parent
parent.public_send(self.class.table_name)
else
self.class.where(parent_id: nil, team: team)
end
last_clone_number = records
.select("substring(#{self.class.table_name}.name, '(?:^#{clone_label} )(\\d+)')::int AS clone_number")
.where('name ~ ?', "^#{clone_label} \\d+ - #{Regexp.escape(name)}$")
.order(clone_number: :asc)
.last&.clone_number
"#{clone_label} #{(last_clone_number || 0) + 1} - #{name}".truncate(Constants::NAME_MAX_LENGTH)
end

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
module Shareable
extend ActiveSupport::Concern
included do
has_many :team_shared_objects, as: :shared_object, dependent: :destroy
has_many :teams_shared_with, through: :team_shared_objects, source: :team, dependent: :destroy
if column_names.include? 'permission_level'
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS
define_method :globally_shareable? do
true
end
else
# If model does not include the permission_level column for global sharing,
# all related methods should just return false
Extends::SHARED_OBJECTS_PERMISSION_LEVELS.each do |level|
define_method "#{level[0]}?" do
level[0] == :not_shared
end
define_method :globally_shareable? do
false
end
end
end
scope :viewable_by_user, lambda { |user, teams = user.current_team|
readable = readable_by_user(user).left_outer_joins(:team_shared_objects)
readable
.where(team: teams)
.or(readable.where(team_shared_objects: { team: teams }))
.or(readable
.where(
if column_names.include?('permission_level')
{
permission_level: [
Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_read],
Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_write]
]
}
else
{}
end
).where.not(team: teams))
.distinct
}
rescue ActiveRecord::NoDatabaseError,
ActiveRecord::ConnectionNotEstablished,
ActiveRecord::StatementInvalid,
PG::ConnectionBad
Rails.logger.info('Not connected to database, skipping sharable model initialization.')
end
def shareable_write?
true
end
def private_shared_with?(team)
team_shared_objects.where(team: team).any?
end
def private_shared_with_write?(team)
team_shared_objects.where(team: team, permission_level: :shared_write).any?
end
def i_shared?(team)
shared_with_anybody? && self.team == team
end
def globally_shared?
shared_read? || shared_write?
end
def shared_with_anybody?
(!not_shared? || team_shared_objects.any?)
end
def shared_with?(team)
return false if self.team == team
!not_shared? || private_shared_with?(team)
end
def shared_with_write?(team)
return false if self.team == team
shared_write? || private_shared_with_write?(team)
end
def shared_with_read?(team)
return false if self.team == team
shared_read? || team_shared_objects.where(team: team, permission_level: :shared_read).any?
end
end

View file

@ -7,12 +7,11 @@ class Repository < RepositoryBase
include PermissionCheckableModel
include RepositoryImportParser
include ArchivableModel
include Shareable
ID_PREFIX = 'IN'
include PrefixedIdModel
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS
belongs_to :archived_by,
foreign_key: :archived_by_id,
class_name: 'User',
@ -23,8 +22,6 @@ class Repository < RepositoryBase
class_name: 'User',
inverse_of: :restored_repositories,
optional: true
has_many :team_shared_objects, as: :shared_object, dependent: :destroy
has_many :teams_shared_with, through: :team_shared_objects, source: :team, dependent: :destroy
has_many :repository_snapshots,
class_name: 'RepositorySnapshot',
foreign_key: :parent_id,
@ -48,17 +45,6 @@ class Repository < RepositoryBase
scope :archived, -> { where(archived: true) }
scope :globally_shared, -> { where(permission_level: %i(shared_read shared_write)) }
scope :viewable_by_user, lambda { |user, teams = user.current_team|
readable_repositories = readable_by_user(user).left_outer_joins(:team_shared_objects)
readable_repositories
.where(team: teams)
.or(readable_repositories.where(team_shared_objects: { team: teams }))
.or(readable_repositories
.where(permission_level: [Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_read], Extends::SHARED_OBJECTS_PERMISSION_LEVELS[:shared_write]])
.where.not(team: teams))
.distinct
}
scope :assigned_to_project, lambda { |project|
joins(repository_rows: { my_module_repository_rows: { my_module: { experiment: :project } } })
.where(repository_rows: { my_module_repository_rows: { my_module: { experiments: { project: project } } } })
@ -80,10 +66,6 @@ class Repository < RepositoryBase
teams.blank? ? self : where(team: teams)
end
def shareable_write?
true
end
def permission_parent
team
end
@ -111,44 +93,6 @@ class Repository < RepositoryBase
['repository_rows.name', RepositoryRow::PREFIXED_ID_SQL, 'users.full_name']
end
def i_shared?(team)
shared_with_anybody? && self.team == team
end
def globally_shared?
shared_read? || shared_write?
end
def shared_with_anybody?
(!not_shared? || team_shared_objects.any?)
end
def shared_with?(team)
return false if self.team == team
!not_shared? || private_shared_with?(team)
end
def shared_with_write?(team)
return false if self.team == team
shared_write? || private_shared_with_write?(team)
end
def shared_with_read?(team)
return false if self.team == team
shared_read? || team_shared_objects.where(team: team, permission_level: :shared_read).any?
end
def private_shared_with?(team)
team_shared_objects.where(team: team).any?
end
def private_shared_with_write?(team)
team_shared_objects.where(team: team, permission_level: :shared_write).any?
end
def self.name_like(query)
where('repositories.name ILIKE ?', "%#{query}%")
end

View file

@ -98,6 +98,13 @@ class RepositoryRow < ApplicationRecord
class_name: 'RepositoryRow',
source: :parent,
dependent: :destroy
has_many :discarded_storage_location_repository_rows,
-> { discarded },
class_name: 'StorageLocationRepositoryRow',
inverse_of: :repository_row,
dependent: :destroy
has_many :storage_location_repository_rows, inverse_of: :repository_row, dependent: :destroy
has_many :storage_locations, through: :storage_location_repository_rows
auto_strip_attributes :name, nullify: false
validates :name,
@ -172,6 +179,14 @@ class RepositoryRow < ApplicationRecord
self[:archived]
end
def has_reminders?(user)
stock_reminders = RepositoryCell.stock_reminder_repository_cells_scope(
repository_cells.joins(:repository_column), user)
date_reminders = RepositoryCell.date_time_reminder_repository_cells_scope(
repository_cells.joins(:repository_column), user)
stock_reminders.any? || date_reminders.any?
end
def archived
row_archived? || repository&.archived?
end

View file

@ -37,6 +37,9 @@ class Result < ApplicationRecord
accepts_nested_attributes_for :tables
before_save :ensure_default_name
after_discard do
CleanupUserSettingsJob.perform_later('result_states', id)
end
def self.search(user,
include_archived,

View file

@ -0,0 +1,159 @@
# frozen_string_literal: true
class StorageLocation < ApplicationRecord
include Cloneable
include Discard::Model
ID_PREFIX = 'SL'
include PrefixedIdModel
include Shareable
default_scope -> { kept }
has_one_attached :image
belongs_to :team
belongs_to :parent, class_name: 'StorageLocation', optional: true
belongs_to :created_by, class_name: 'User'
has_many :storage_location_repository_rows, inverse_of: :storage_location, dependent: :destroy
has_many :storage_locations, foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
has_many :repository_rows, through: :storage_location_repository_rows
validates :name, length: { maximum: Constants::NAME_MAX_LENGTH }
validate :parent_validation, if: -> { parent.present? }
scope :readable_by_user, (lambda do |user, team = user.current_team|
next StorageLocation.none unless team.permission_granted?(user, TeamPermissions::STORAGE_LOCATIONS_READ)
where(team: team)
end)
after_discard do
StorageLocation.where(parent_id: id).find_each(&:discard)
storage_location_repository_rows.each(&:discard)
end
def shared_with?(team)
return false if self.team == team
(root? ? self : root_storage_location).private_shared_with?(team)
end
def root?
parent_id.nil?
end
def root_storage_location
return self if root?
storage_location = self
storage_location = storage_location.parent while storage_location.parent_id
storage_location
end
def empty?
storage_location_repository_rows.count.zero?
end
def duplicate!
ActiveRecord::Base.transaction do
new_storage_location = dup
new_storage_location.name = next_clone_name
new_storage_location.save!
copy_image(self, new_storage_location)
recursive_duplicate(id, new_storage_location.id)
new_storage_location
rescue ActiveRecord::RecordInvalid
false
end
end
def with_grid?
metadata['display_type'] == 'grid'
end
def grid_size
metadata['dimensions'] if with_grid?
end
def available_positions
return unless with_grid?
occupied_positions = storage_location_repository_rows.pluck(:metadata).map { |metadata| metadata['position'] }
rows = {}
grid_size[0].times do |row|
rows_cells = []
grid_size[1].times.filter_map do |col|
rows_cells.push(col + 1) if occupied_positions.exclude?([row + 1, col + 1])
end
rows[row + 1] = rows_cells unless rows_cells.empty?
end
rows
end
def self.storage_locations_enabled?
ApplicationSettings.instance.values['storage_locations_enabled']
end
private
def recursive_duplicate(old_parent_id = nil, new_parent_id = nil)
StorageLocation.where(parent_id: old_parent_id).find_each do |child|
new_child = child.dup
new_child.parent_id = new_parent_id
new_child.save!
copy_image(child, new_child)
recursive_duplicate(child.id, new_child.id)
end
end
def copy_image(old_storage_location, new_storage_location)
return unless old_storage_location.image.attached?
old_blob = old_storage_location.image.blob
old_blob.open do |tmp_file|
to_blob = ActiveStorage::Blob.create_and_upload!(
io: tmp_file,
filename: old_blob.filename,
metadata: old_blob.metadata
)
new_storage_location.image.attach(to_blob)
end
end
def self.inner_storage_locations(team, storage_location = nil)
entry_point_condition = storage_location ? 'parent_id = ?' : 'parent_id IS NULL'
inner_storage_locations_sql =
"WITH RECURSIVE inner_storage_locations(id, selected_storage_locations_ids) AS (
SELECT id, ARRAY[id]
FROM storage_locations
WHERE team_id = ? AND #{entry_point_condition}
UNION ALL
SELECT storage_locations.id, selected_storage_locations_ids || storage_locations.id
FROM inner_storage_locations
JOIN storage_locations ON storage_locations.parent_id = inner_storage_locations.id
WHERE NOT storage_locations.id = ANY(selected_storage_locations_ids)
)
SELECT id FROM inner_storage_locations ORDER BY selected_storage_locations_ids".gsub(/\n|\t/, ' ').squeeze(' ')
if storage_location.present?
where("storage_locations.id IN (#{inner_storage_locations_sql})", team.id, storage_location.id)
else
where("storage_locations.id IN (#{inner_storage_locations_sql})", team.id)
end
end
def parent_validation
if parent.id == id
errors.add(:parent, I18n.t('activerecord.errors.models.storage_location.attributes.parent_storage_location'))
elsif StorageLocation.inner_storage_locations(team, self).exists?(id: parent_id)
errors.add(:parent, I18n.t('activerecord.errors.models.project_folder.attributes.parent_storage_location_child'))
end
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class StorageLocationRepositoryRow < ApplicationRecord
include Discard::Model
default_scope -> { kept }
belongs_to :storage_location, inverse_of: :storage_location_repository_rows
belongs_to :repository_row, inverse_of: :storage_location_repository_rows
belongs_to :created_by, class_name: 'User'
with_options if: -> { storage_location.container && storage_location.metadata['type'] == 'grid' } do
validate :position_must_be_present
validate :ensure_uniq_position
end
def human_readable_position
return unless metadata['position']
column_letter = ('A'..'Z').to_a[metadata['position'][0] - 1]
row_number = metadata['position'][1]
"#{column_letter}#{row_number}"
end
def position_must_be_present
errors.add(:base, I18n.t('activerecord.errors.models.storage_location.missing_position')) if metadata['position'].blank?
end
def ensure_uniq_position
if StorageLocationRepositoryRow.where(storage_location: storage_location)
.where('metadata @> ?', { position: metadata['position'] }.to_json)
.where.not(id: id).exists?
errors.add(:base, I18n.t('activerecord.errors.models.storage_location.not_uniq_position'))
end
end
end

View file

@ -72,6 +72,7 @@ class Team < ApplicationRecord
source_type: 'RepositoryBase',
dependent: :destroy
has_many :shareable_links, inverse_of: :team, dependent: :destroy
has_many :storage_locations, dependent: :destroy
attr_accessor :without_templates

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
Canaid::Permissions.register_for(StorageLocation) do
can :read_storage_location do |user, storage_location|
root_storage_location = storage_location.root_storage_location
next true if root_storage_location.shared_with?(user.current_team)
user.current_team == root_storage_location.team && root_storage_location.team.permission_granted?(
user,
if root_storage_location.container?
TeamPermissions::STORAGE_LOCATION_CONTAINERS_READ
else
TeamPermissions::STORAGE_LOCATIONS_READ
end
)
end
can :manage_storage_location do |user, storage_location|
root_storage_location = storage_location.root_storage_location
next true if root_storage_location.shared_with_write?(user.current_team)
user.current_team == root_storage_location.team && root_storage_location.team.permission_granted?(
user,
if root_storage_location.container?
TeamPermissions::STORAGE_LOCATION_CONTAINERS_MANAGE
else
TeamPermissions::STORAGE_LOCATIONS_MANAGE
end
)
end
can :share_storage_location do |user, storage_location|
user.current_team == storage_location.team &&
storage_location.root? &&
can_manage_storage_location?(user, storage_location)
end
end

View file

@ -43,6 +43,14 @@ Canaid::Permissions.register_for(Team) do
within_limits && team.permission_granted?(user, TeamPermissions::INVENTORIES_CREATE)
end
can :create_storage_locations do |user, team|
team.permission_granted?(user, TeamPermissions::STORAGE_LOCATIONS_CREATE)
end
can :create_storage_location_containers do |user, team|
team.permission_granted?(user, TeamPermissions::STORAGE_LOCATION_CONTAINERS_CREATE)
end
can :create_reports do |user, team|
team.permission_granted?(user, TeamPermissions::REPORTS_CREATE)
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
module ShareableSerializer
extend ActiveSupport::Concern
included do
attributes :shared, :shared_label, :ishared, :shared_read, :shared_write, :shareable_write
end
def shared
object.shared_with?(current_user.current_team)
end
def shared_label
case object[:shared]
when 1
I18n.t('libraries.index.shared')
when 2
I18n.t('libraries.index.shared_for_editing')
when 3
I18n.t('libraries.index.shared_for_viewing')
when 4
I18n.t('libraries.index.not_shared')
end
end
def ishared
object.i_shared?(current_user.current_team)
end
def shared_read
object.shared_read?
end
def shared_write
object.shared_write?
end
def shareable_write
object.shareable_write?
end
end

View file

@ -4,36 +4,14 @@ module Lists
class RepositorySerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
include ShareableSerializer
attributes :name, :code, :nr_of_rows, :shared, :shared_label, :ishared,
:team, :created_at, :created_by, :archived_on, :archived_by,
:urls, :shared_read, :shared_write, :shareable_write
attributes :name, :code, :nr_of_rows, :team, :created_at, :created_by, :archived_on, :archived_by, :urls
def nr_of_rows
object[:row_count]
end
def shared
object.shared_with?(current_user.current_team)
end
def shared_label
case object[:shared]
when 1
I18n.t('libraries.index.shared')
when 2
I18n.t('libraries.index.shared_for_editing')
when 3
I18n.t('libraries.index.shared_for_viewing')
when 4
I18n.t('libraries.index.not_shared')
end
end
def ishared
object.i_shared?(current_user.current_team)
end
def team
object[:team_name]
end
@ -54,25 +32,15 @@ module Lists
object[:archived_by_user]
end
def shared_read
object.shared_read?
end
def shared_write
object.shared_write?
end
def shareable_write
object.shareable_write?
end
def urls
{
show: repository_path(object),
update: team_repository_path(current_user.current_team, id: object, format: :json),
shareable_teams: shareable_teams_team_repository_path(current_user.current_team, object),
duplicate: team_repository_copy_path(current_user.current_team, repository_id: object, format: :json),
share: team_repository_team_repositories_path(current_user.current_team, object)
shareable_teams: shareable_teams_team_shared_objects_path(
current_user.current_team, object_id: object.id, object_type: 'Repository'
),
share: team_shared_objects_path(current_user.current_team, object_id: object.id, object_type: 'Repository')
}
end
end

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
module Lists
class StorageLocationRepositoryRowSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :created_by, :created_on, :position, :row_id, :row_name, :hidden, :position_formatted, :stock,
:have_reminders, :reminders_url
def row_id
object.repository_row.code
end
def row_name
object.repository_row.name unless hidden
end
def created_by
object.created_by.full_name unless hidden
end
def created_on
I18n.l(object.created_at, format: :full) unless hidden
end
def position
object.metadata['position']
end
def position_formatted
"#{('A'..'Z').to_a[position[0] - 1]}#{position[1]}" if position
end
def stock
if object.repository_row.repository.has_stock_management? && !hidden
object.repository_row.repository_cells.find_by(value_type: 'RepositoryStockValue')&.value&.formatted
end
end
def hidden
!can_read_repository?(object.repository_row.repository)
end
def have_reminders
object.repository_row.has_reminders?(scope)
end
def reminders_url
row = object.repository_row
active_reminder_repository_cells_repository_repository_row_url(row.repository, row)
end
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
module Lists
class StorageLocationSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
include ShareableSerializer
attributes :id, :code, :name, :container, :description, :owned_by, :created_by,
:created_on, :urls, :metadata, :file_name, :sub_location_count, :is_empty
def owned_by
object.team.name
end
def is_empty
object.empty?
end
def metadata
{
display_type: object.metadata['display_type'],
dimensions: object.metadata['dimensions'] || []
}
end
def file_name
object.image.filename if object.image.attached?
end
def created_by
object.created_by.full_name
end
def created_on
I18n.l(object.created_at, format: :full)
end
def sub_location_count
if object.respond_to?(:sub_location_count)
object.sub_location_count
else
StorageLocation.where(parent_id: object.id).count
end
end
def urls
show_url = if @object.container
storage_location_path(@object)
else
storage_locations_path(parent_id: object.id)
end
{
show: show_url,
update: storage_location_path(@object),
shareable_teams: shareable_teams_team_shared_objects_path(
current_user.current_team, object_id: object.id, object_type: object.class.name
),
share: team_shared_objects_path(current_user.current_team, object_id: object.id, object_type: object.class.name)
}
end
end
end

View file

@ -13,7 +13,8 @@ class ResultSerializer < ActiveModel::Serializer
:open_vector_editor_context, :comments_count, :assets_view_mode, :storage_limit, :collapsed
def collapsed
false
result_states = current_user.settings.fetch('result_states', {})
result_states[object.id.to_s] == true
end
def marvinjs_enabled

View file

@ -6,16 +6,16 @@ class ShareableTeamSerializer < ActiveModel::Serializer
attributes :id, :name, :private_shared_with, :private_shared_with_write
def private_shared_with
repository.private_shared_with?(object)
model.private_shared_with?(object)
end
def private_shared_with_write
repository.private_shared_with_write?(object)
model.private_shared_with_write?(object)
end
private
def repository
scope[:repository] || @instance_options[:repository]
def model
scope[:model] || @instance_options[:model]
end
end

View file

@ -61,7 +61,11 @@ module Activities
end
if id
obj = const.find id
obj = if const.respond_to?(:with_discarded)
const.with_discarded.find id
else
const.find id
end
@activity.message_items[k] = { type: const.to_s, value: obj.public_send(getter_method).to_s, id: id }
@activity.message_items[k][:value_for] = getter_method
@activity.message_items[k][:value_type] = value_type unless value_type.nil?

View file

@ -29,7 +29,7 @@ module Lists
end
def paginate_records
@records = @records.page(@params[:page]).per(@params[:per_page])
@records = @records.page(@params[:page]).per(@params[:per_page]) if @params[:page].present?
end
def sort_direction(order_params)

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Lists
class StorageLocationRepositoryRowsService < BaseService
def initialize(team, params)
@team = team
@storage_location_id = params[:storage_location_id]
@params = params
end
def fetch_records
@records = StorageLocationRepositoryRow.includes(:repository_row).where(storage_location_id: @storage_location_id)
end
def filter_records; end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Lists
class StorageLocationsService < BaseService
def initialize(user, team, params)
@user = user
@team = team
@parent_id = params[:parent_id]
@filters = params[:filters] || {}
@params = params
end
def fetch_records
@records =
StorageLocation.joins('LEFT JOIN storage_locations AS sub_locations ' \
'ON storage_locations.id = sub_locations.parent_id')
.viewable_by_user(@user, @team)
.select('storage_locations.*, COUNT(sub_locations.id) AS sub_location_count')
.group(:id)
end
def filter_records
if @filters[:search_tree].present?
if @parent_id.present?
storage_location = @records.find_by(id: @parent_id)
@records = @records.where(id: StorageLocation.inner_storage_locations(@team, storage_location))
end
else
@records = @records.where(parent_id: @parent_id)
end
@records = @records.where('LOWER(name) ILIKE ?', "%#{@filters[:query].downcase}%") if @filters[:query].present?
@records = @records.where('LOWER(name) ILIKE ?', "%#{@params[:search].downcase}%") if @params[:search].present?
end
end
end

View file

@ -93,7 +93,7 @@ module ModelExporters
element_json = element.as_json
case element.orderable_type
when 'ResultText'
element_json['step_text'] = element.orderable.as_json
element_json['result_text'] = element.orderable.as_json
when 'ResultTable'
element_json['table'] = table(element.orderable.table)
end

View file

@ -47,10 +47,6 @@ module ModelExporters
user_assignments: team.user_assignments.map do |ua|
user_assignment(ua)
end,
notifications: Notification
.includes(:user_notifications)
.where('user_notifications.user_id': team.users)
.map { |n| notification(n) },
repositories: team.repositories.map { |r| repository(r) },
tiny_mce_assets: team.tiny_mce_assets.map { |tma| tiny_mce_asset_data(tma) },
protocols: team.protocols.where(my_module: nil).map do |pr|
@ -68,15 +64,6 @@ module ModelExporters
}
end
def notification(notification)
notification_json = notification.as_json
notification_json['type_of'] = Extends::NOTIFICATIONS_TYPES
.key(notification
.read_attribute('type_of'))
.to_s
notification_json
end
def label_templates(templates)
templates.where.not(type: 'FluicsLabelTemplate').map do |template|
template_json = template.as_json
@ -97,7 +84,6 @@ module ModelExporters
copy_files([user], :avatar, File.join(@dir_to_export, 'avatars'))
{
user: user_json,
user_notifications: user.user_notifications,
user_identities: user.user_identities,
repository_table_states:
user.repository_table_states.where(repository: @team.repositories)

View file

@ -11,9 +11,9 @@ module Reports
def self.image_prepare(asset)
if asset.class == Asset
if asset.inline?
asset.preview_attachment.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT, format: :png)
asset.medium_preview
else
asset.preview_attachment.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT, format: :png)
asset.large_preview
end
elsif asset.class == TinyMceAsset
asset.image.representation(format: :png)

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'caxlsx'
module StorageLocations
class ExportService
include Canaid::Helpers::PermissionsHelper
def initialize(storage_location, user)
@storage_location = storage_location
@user = user
end
def to_xlsx
package = Axlsx::Package.new
workbook = package.workbook
workbook.add_worksheet(name: 'Box Export') do |sheet|
sheet.add_row ['Box position', 'Item ID', 'Item name']
@storage_location.storage_location_repository_rows.each do |storage_location_item|
row = storage_location_item.repository_row
row_name = row.name if can_read_repository?(@user, row.repository)
sheet.add_row [format_position(storage_location_item), storage_location_item.repository_row_id, row_name]
end
end
package.to_stream.read
end
private
def format_position(item)
position = item.metadata['position']
return unless position
column_letter = ('A'..'Z').to_a[position[0] - 1]
row_number = position[1]
"#{column_letter}#{row_number}"
end
end
end

View file

@ -0,0 +1,102 @@
# frozen_string_literal: true
require 'caxlsx'
module StorageLocations
class ImportService
def initialize(storage_location, file, user)
@storage_location = storage_location
@file = file
@user = user
end
def import_items
sheet = SpreadsheetParser.open_spreadsheet(@file)
incoming_items = SpreadsheetParser.spreadsheet_enumerator(sheet).reject { |r| r.all?(&:blank?) }
# Check if the file has proper headers
header = SpreadsheetParser.parse_row(incoming_items[0], sheet)
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_structure') } unless header[0] == 'Box position' && header[1] == 'Item ID'
# Remove first row
incoming_items.shift
incoming_items.map! { |r| SpreadsheetParser.parse_row(r, sheet) }
# Check duplicate positions in the file
if @storage_location.with_grid? && incoming_items.pluck(0).uniq.length != incoming_items.length
return { status: :error, message: I18n.t('storage_locations.show.import_modal.errors.invalid_position') }
end
existing_items = @storage_location.storage_location_repository_rows.map do |item|
[convert_position_number_to_letter(item), item.repository_row_id, item.id]
end
items_to_unassign = []
existing_items.each do |existing_item|
if incoming_items.any? { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
incoming_items.reject! { |r| r[0] == existing_item[0] && r[1].to_i == existing_item[1] }
else
items_to_unassign << existing_item[2]
end
end
error_message = ''
ActiveRecord::Base.transaction do
@storage_location.storage_location_repository_rows.where(id: items_to_unassign).discard_all
incoming_items.each do |row|
if @storage_location.with_grid?
position = convert_position_letter_to_number(row[0])
unless position[0].to_i <= @storage_location.grid_size[0].to_i && position[1].to_i <= @storage_location.grid_size[1].to_i
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_position')
raise ActiveRecord::RecordInvalid
end
end
repository_row = RepositoryRow.find_by(id: row[1])
unless repository_row
error_message = I18n.t('storage_locations.show.import_modal.errors.invalid_item', row_id: row[1].to_i)
raise ActiveRecord::RecordNotFound
end
@storage_location.storage_location_repository_rows.create!(
repository_row: repository_row,
metadata: { position: position },
created_by: @user
)
end
rescue ActiveRecord::RecordNotFound
return { status: :error, message: error_message }
end
{ status: :ok }
end
private
def convert_position_letter_to_number(position)
return unless position
column_letter = position[0]
row_number = position[1]
[column_letter.ord - 64, row_number]
end
def convert_position_number_to_letter(item)
position = item.metadata['position']
return unless position
column_letter = ('A'..'Z').to_a[position[0] - 1]
row_number = position[1]
"#{column_letter}#{row_number}"
end
end
end

View file

@ -71,7 +71,6 @@ class TeamImporter
# Find new id of the first admin in the team
@admin_id = @user_mappings[team_json['default_admin_id']]
create_notifications(team_json['notifications'])
create_protocol_keywords(team_json['protocol_keywords'], team)
create_protocols(team_json['protocols'], nil, team)
create_project_folders(team_json['project_folders'], team)
@ -83,17 +82,6 @@ class TeamImporter
# Second run, we needed it because of some models should be created
team_json['users'].each do |user_json|
user_json['user_notifications'].each do |user_notification_json|
user_notification = UserNotification.new(user_notification_json)
user_notification.id = nil
user_notification.user_id = find_user(user_notification.user_id)
user_notification.notification_id =
@notification_mappings[user_notification.notification_id]
next if user_notification.notification_id.blank?
user_notification.save!
end
user_json['repository_table_states'].each do |rep_tbl_state_json|
rep_tbl_state = RepositoryTableState.new(rep_tbl_state_json)
rep_tbl_state.id = nil
@ -423,21 +411,6 @@ class TeamImporter
end
end
def create_notifications(notifications_json)
puts 'Creating notifications...'
notifications_json.each do |notification_json|
notification = Notification.new(notification_json)
next if notification.type_of.blank?
orig_notification_id = notification.id
notification.id = nil
notification.generator_user_id = find_user(notification.generator_user_id)
notification.save!
@notification_mappings[orig_notification_id] = notification.id
@notification_counter += 1
end
end
def create_repositories(repositories_json, team, snapshots = false)
puts 'Creating repositories...'
repositories_json.each do |repository_json|
@ -756,7 +729,7 @@ class TeamImporter
def create_protocols(protocols_json, my_module = nil, team = nil,
user_id = nil)
sorted_protocols = protocols_json.sort_by { |p| p['id'] }
sorted_protocols = protocols_json.sort_by { |p| p['protocol']['id'] }
puts 'Creating protocols...'
sorted_protocols.each do |protocol_json|

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Toolbars
class StorageLocationRepositoryRowsService
attr_reader :current_user
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def initialize(current_user, items_ids: [])
@current_user = current_user
@assigned_rows = StorageLocationRepositoryRow.where(id: items_ids)
@storage_location = @assigned_rows.first&.storage_location
@single = @assigned_rows.length == 1
end
def actions
return [] if @assigned_rows.none?
[
unassign_action,
move_action
].compact
end
private
def unassign_action
return unless can_manage_storage_location?(@storage_location)
{
name: 'unassign',
label: I18n.t('storage_locations.show.toolbar.unassign'),
icon: 'sn-icon sn-icon-close',
path: unassign_rows_storage_location_path(@storage_location, ids: @assigned_rows.pluck(:id)),
type: :emit
}
end
def move_action
return unless @single && can_manage_storage_location?(@storage_location)
{
name: 'move',
label: I18n.t('storage_locations.show.toolbar.move'),
icon: 'sn-icon sn-icon-move',
path: move_storage_location_storage_location_repository_row_path(
@storage_location, @assigned_rows.first
),
type: :emit
}
end
end
end

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
module Toolbars
class StorageLocationsService
attr_reader :current_user
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def initialize(current_user, storage_location_ids: [])
@current_user = current_user
@storage_locations = StorageLocation.where(id: storage_location_ids)
@single = @storage_locations.length == 1
end
def actions
return [] if @storage_locations.none?
[
edit_action,
move_action,
duplicate_action,
delete_action,
share_action
].compact
end
private
def edit_action
return unless @single && can_manage_storage_location?(@storage_locations.first)
{
name: 'edit',
label: I18n.t('storage_locations.index.toolbar.edit'),
icon: 'sn-icon sn-icon-edit',
path: storage_location_path(@storage_locations.first),
type: :emit
}
end
def move_action
return unless @single && can_manage_storage_location?(@storage_locations.first)
{
name: 'move',
label: I18n.t('storage_locations.index.toolbar.move'),
icon: 'sn-icon sn-icon-move',
path: move_storage_location_path(@storage_locations.first),
type: :emit
}
end
def duplicate_action
return unless @single && can_manage_storage_location?(@storage_locations.first)
{
name: 'duplicate',
label: I18n.t('storage_locations.index.toolbar.duplicate'),
icon: 'sn-icon sn-icon-duplicate',
path: duplicate_storage_location_path(@storage_locations.first),
type: :emit
}
end
def delete_action
return unless @single && can_manage_storage_location?(@storage_locations.first)
storage_location = @storage_locations.first
number_of_items = storage_location.storage_location_repository_rows.count +
StorageLocation.inner_storage_locations(current_user.current_team, storage_location)
.where(container: true)
.joins(:storage_location_repository_rows)
.count
{
name: 'delete',
label: I18n.t('storage_locations.index.toolbar.delete'),
icon: 'sn-icon sn-icon-delete',
number_of_items: number_of_items,
path: storage_location_path(storage_location),
type: :emit
}
end
def share_action
return unless @single && can_share_storage_location?(@storage_locations.first)
{
name: :share,
label: I18n.t('storage_locations.index.share'),
icon: 'sn-icon sn-icon-shared',
type: :emit
}
end
end
end

View file

@ -0,0 +1,19 @@
<h1>Radio</h1>
<div class="flex flex-items gap-8 p-6">
<div class="sci-radio-container">
<input type="radio" name="test_1" class="sci-radio">
<span class="sci-radio-label">
</div>
<div class="sci-radio-container">
<input type="radio" name="test_1" class="sci-radio" checked>
<span class="sci-radio-label">
</div>
<div class="sci-radio-container">
<input type="radio" name="test_2" class="sci-radio" disabled>
<span class="sci-radio-label">
</div>
<div class="sci-radio-container">
<input type="radio" name="test_2" class="sci-radio" checked disabled>
<span class="sci-radio-label">
</div>
</div>

View file

@ -10,6 +10,8 @@
end
%>
<%= render partial: 'radio' %>
<%= render partial: 'select' %>
<%= render partial: 'modals' %>

View file

@ -0,0 +1,15 @@
<%= render partial: "global_activities/references/team",
locals: { team: team, subject: team, breadcrumbs: breadcrumbs, values: values, type_of: type_of } %>
<div class="ga-breadcrumb">
<span class="sn-icon sn-icon-storage"></span>
<% if subject %>
<%= route_to_other_team(storage_location_path(subject.id, team: subject.team.id),
team,
subject.name&.truncate(Constants::NAME_TRUNCATION_LENGTH),
title: subject.name) %>
<% else %>
<span title="<%= breadcrumbs['storage_location'] %>">
<%= breadcrumbs['storage_location']&.truncate(Constants::NAME_TRUNCATION_LENGTH) %>
</span>
<% end %>
</div>

View file

@ -2,7 +2,7 @@
.print-footer {
line-height: 50px;
font-size: 13px;
padding-right: 30px;
padding-right: 30px;
text-align: right;
width: 100%;
}

View file

@ -37,6 +37,23 @@ json.actions do
end
end
json.storage_locations do
json.locations(
@repository_row.storage_locations.distinct.map do |storage_location|
readable = can_read_storage_location?(storage_location)
{
id: storage_location.id,
readable: readable,
name: readable ? storage_location.name : storage_location.code,
metadata: storage_location.metadata,
positions: readable ? storage_location.storage_location_repository_rows.where(repository_row: @repository_row) : []
}
end
)
json.enabled StorageLocation.storage_locations_enabled?
end
json.default_columns do
json.name @repository_row.name
json.code @repository_row.code

View file

@ -0,0 +1,25 @@
<% provide(:head_title, t("storage_locations.index.head_title")) %>
<% provide(:container_class, "no-second-nav-container") %>
<% if current_team %>
<div class="content-pane with-grey-background flexible">
<div class="content-header">
<div class="title-row">
<h1><%= t('storage_locations.index.head_title') %></h1>
</div>
</div>
<div class="content-body " data-e2e="e2e-CO-storageLocations">
<div id="storageLocationsTable" class="fixed-content-body">
<storage-locations
actions-url="<%= actions_toolbar_storage_locations_path(current_team) %>"
data-source="<%= storage_locations_path(format: :json, parent_id: params[:parent_id]) %>"
direct-upload-url="<%= rails_direct_uploads_url %>"
create-location-url="<%= storage_locations_path(parent_id: params[:parent_id]) if can_create_storage_locations?(current_team) %>"
create-location-instance-url="<%= storage_locations_path(parent_id: params[:parent_id]) if can_create_storage_location_containers?(current_team) %>"
/>
</div>
</div>
</div>
<%= javascript_include_tag 'vue_storage_locations_table' %>
<% end %>

View file

@ -0,0 +1,26 @@
<% provide(:head_title, @storage_location.name) %>
<% provide(:container_class, "no-second-nav-container") %>
<% if current_team %>
<div class="content-pane flexible with-grey-background">
<div class="content-header">
<div class="title-row">
<h1><%= @storage_location.name %></h1>
</div>
</div>
<div class="content-body" data-e2e="e2e-CO-storageLocations-box">
<div id="StorageLocationsContainer" class="fixed-content-body">
<storage-locations-container
actions-url="<%= actions_toolbar_storage_location_storage_location_repository_rows_path(@storage_location) %>"
data-source="<%= storage_location_storage_location_repository_rows_path(@storage_location) %>"
:can-manage="<%= can_manage_storage_location?(@storage_location) %>"
:with-grid="<%= @storage_location.with_grid? %>"
:grid-size="<%= @storage_location.grid_size.to_json %>"
:container-id="<%= @storage_location.id %>"
/>
</div>
</div>
</div>
<%= javascript_include_tag 'vue_storage_locations_container' %>
<% end %>

View file

@ -1,2 +0,0 @@
#!/bin/bash
env -i /usr/bin/chromium $@

View file

@ -105,4 +105,8 @@ Rails.application.configure do
config.x.new_team_on_signup = false
end
config.hosts << "dev.scinote.test"
# Automatically update js-routes file
# when routes.rb is changed
config.middleware.use(JsRoutes::Middleware)
end

View file

@ -188,7 +188,7 @@ class Extends
ACTIVITY_SUBJECT_TYPES = %w(
Team RepositoryBase Project Experiment MyModule Result Protocol Report RepositoryRow
ProjectFolder Asset Step LabelTemplate
ProjectFolder Asset Step LabelTemplate StorageLocation StorageLocationRepositoryRow
).freeze
SEARCHABLE_ACTIVITY_SUBJECT_TYPES = %w(
@ -205,7 +205,8 @@ class Extends
my_module: %i(results protocols),
result: [:assets],
protocol: [:steps],
step: [:assets]
step: [:assets],
storage_location: [:storage_location_repository_rows]
}
ACTIVITY_MESSAGE_ITEMS_TYPES =
@ -495,7 +496,24 @@ class Extends
task_step_asset_renamed: 305,
result_asset_renamed: 306,
protocol_step_asset_renamed: 307,
inventory_items_added_or_updated_with_import: 308
inventory_items_added_or_updated_with_import: 308,
storage_location_created: 309,
storage_location_deleted: 310,
storage_location_edited: 311,
storage_location_moved: 312,
storage_location_shared: 313,
storage_location_unshared: 314,
storage_location_sharing_updated: 315,
container_storage_location_created: 316,
container_storage_location_deleted: 317,
container_storage_location_edited: 318,
container_storage_location_moved: 319,
container_storage_location_shared: 320,
container_storage_location_unshared: 321,
container_storage_location_sharing_updated: 322,
storage_location_repository_row_created: 323,
storage_location_repository_row_deleted: 324,
storage_location_repository_row_moved: 325
}
ACTIVITY_GROUPS = {
@ -515,7 +533,10 @@ class Extends
190, 191, *204..215, 220, 223, 227, 228, 229, *230..235,
*237..240, *253..256, *279..283, 300, 304, 307],
team: [92, 94, 93, 97, 104, 244, 245],
label_templates: [*216..219]
label_templates: [*216..219],
storage_locations: [*309..315],
container_storage_location: [*316..322],
storage_location_repository_rows: [*323..325]
}
TOP_LEVEL_ASSIGNABLES = %w(Project Team Protocol Repository).freeze
@ -636,6 +657,8 @@ class Extends
preferences/index
addons/index
search/index
storage_locations/index
storage_locations/show
)
DEFAULT_USER_NOTIFICATION_SETTINGS = {
@ -673,6 +696,7 @@ class Extends
repository_export_file_type
navigator_collapsed
navigator_width
result_states
).freeze
end

View file

@ -13,7 +13,13 @@ module PermissionExtends
REPORTS_CREATE
LABEL_TEMPLATES_READ
LABEL_TEMPLATES_MANAGE
).each { |permission| const_set(permission, "team_#{permission.underscore}") }
STORAGE_LOCATIONS_CREATE
STORAGE_LOCATIONS_MANAGE
STORAGE_LOCATIONS_READ
STORAGE_LOCATION_CONTAINERS_CREATE
STORAGE_LOCATION_CONTAINERS_MANAGE
STORAGE_LOCATION_CONTAINERS_READ
).each { |permission| const_set(permission, "team_#{permission.parameterize}") }
end
module ProtocolPermissions
@ -24,7 +30,7 @@ module PermissionExtends
MANAGE
USERS_MANAGE
MANAGE_DRAFT
).each { |permission| const_set(permission, "protocol_#{permission.underscore}") }
).each { |permission| const_set(permission, "protocol_#{permission.parameterize}") }
end
module ReportPermissions
@ -33,7 +39,7 @@ module PermissionExtends
READ
MANAGE
USERS_MANAGE
).each { |permission| const_set(permission, "report_#{permission.underscore}") }
).each { |permission| const_set(permission, "report_#{permission.parameterize}") }
end
module ProjectPermissions
@ -51,7 +57,7 @@ module PermissionExtends
COMMENTS_MANAGE_OWN
TAGS_MANAGE
EXPERIMENTS_CREATE
).each { |permission| const_set(permission, "project_#{permission.underscore}") }
).each { |permission| const_set(permission, "project_#{permission.parameterize}") }
end
module ExperimentPermissions
@ -65,7 +71,7 @@ module PermissionExtends
USERS_MANAGE
READ_CANVAS
ACTIVITIES_READ
).each { |permission| const_set(permission, "experiment_#{permission.underscore}") }
).each { |permission| const_set(permission, "experiment_#{permission.parameterize}") }
end
module MyModulePermissions
@ -107,7 +113,7 @@ module PermissionExtends
USERS_MANAGE
DESIGNATED_USERS_MANAGE
STOCK_CONSUMPTION_UPDATE
).each { |permission| const_set(permission, "task_#{permission.underscore}") }
).each { |permission| const_set(permission, "task_#{permission.parameterize}") }
end
module RepositoryPermissions
@ -127,7 +133,7 @@ module PermissionExtends
COLUMNS_DELETE
USERS_MANAGE
FILTERS_MANAGE
).each { |permission| const_set(permission, "inventory_#{permission.underscore}") }
).each { |permission| const_set(permission, "inventory_#{permission.parameterize}") }
end
module PredefinedRoles
@ -147,6 +153,12 @@ module PermissionExtends
TeamPermissions::REPORTS_CREATE,
TeamPermissions::LABEL_TEMPLATES_READ,
TeamPermissions::LABEL_TEMPLATES_MANAGE,
TeamPermissions::STORAGE_LOCATIONS_READ,
TeamPermissions::STORAGE_LOCATIONS_CREATE,
TeamPermissions::STORAGE_LOCATIONS_MANAGE,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_READ,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_CREATE,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_MANAGE,
ProtocolPermissions::READ,
ProtocolPermissions::READ_ARCHIVED,
ProtocolPermissions::MANAGE_DRAFT,
@ -241,6 +253,8 @@ module PermissionExtends
VIEWER_PERMISSIONS = [
TeamPermissions::LABEL_TEMPLATES_READ,
TeamPermissions::STORAGE_LOCATIONS_READ,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_READ,
ProtocolPermissions::READ,
ProtocolPermissions::READ_ARCHIVED,
ReportPermissions::READ,

View file

@ -3,8 +3,8 @@
Grover.configure do |config|
config.options = {
cache: false,
executable_path: './bin/chromium',
launch_args: %w(--disable-gpu --no-sandbox),
executable_path: ENV['CHROMIUM_PATH'] || '/usr/bin/chromium',
launch_args: %w(--disable-dev-shm-usage --disable-gpu --no-sandbox),
timeout: Constants::GROVER_TIMEOUT_MS
}
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
JsRoutes.setup do |c|
# Setup your JS module system:
# ESM, CJS, AMD, UMD or nil
# c.module_type = "ESM"
end

View file

@ -261,6 +261,12 @@ en:
attributes:
text: Text is too long
position: "Position has already been taken by another item in the checklist"
storage_location:
missing_position: 'Missing position metadata'
not_uniq_position: 'Position already taken'
attributes:
parent_storage_location: "Storage location cannot be parent to itself"
parent_storage_location_child: "Storage location cannot be moved to it's child"
storage:
limit_reached: "Storage limit has been reached."
helpers:
@ -342,6 +348,8 @@ en:
templates: "Templates"
protocol: "Protocol"
label: "Label"
items: "Items"
locations: "Locations"
reports: "Reports"
settings: "Settings"
activities: "Activities"
@ -2004,14 +2012,6 @@ en:
name_placeholder: "My inventory"
submit: "Create"
success_flash_html: "Inventory <strong>%{name}</strong> successfully created."
modal_share:
title: "Share Inventory"
submit: "Save sharing options"
share_with_team: "Share with Team"
can_edit: "Can Edit"
all_teams: "All teams (current & new)"
all_teams_tooltip: "This will disable individual team settings"
success_message: "Selected sharing options for the Inventory %{inventory_name} have been saved."
export:
notification:
error:
@ -2341,7 +2341,7 @@ en:
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
dragAndDropUpload:
notSingleFileError: 'Single file import only. Please import one file at a time.'
wrongFileTypeError: 'The file has invalid extension (.csv, .xlsx, .txt or .tsv.)'
wrongFileTypeError: 'The file has invalid extension (%{extensions}).'
emptyFileError: 'You have uploaded empty file. There is not much to import.'
fileTooLargeError: 'File too large. Max file size limit is'
importText:
@ -2608,7 +2608,12 @@ en:
custom_columns_label: 'Custom columns'
relationships_label: 'Relationships'
assigned_label: 'Assigned'
locations_label: 'Locations'
QR_label: 'QR'
locations:
title: 'Locations (%{count})'
container: 'Box'
assign: 'Assign new location'
repository_stock_values:
manage_modal:
title: "Stock %{item}"
@ -2663,6 +2668,113 @@ en:
repository_ledger_records:
errors:
my_module_references_missing: 'Task references are not set'
storage_locations:
container: 'box'
location: 'location'
show:
hidden: "Private item"
table:
position: "Position"
reminders: "Reminders"
row_id: "Item ID"
row_name: "Name"
stock: "Stock"
toolbar:
assign: 'Assign item'
unassign: 'Unassign'
move: 'Move'
unassign_modal:
title: 'Unassign location'
description: 'Are you sure you want to remove %{items} item(s) from their current storage location?'
button: 'Unassign'
assign_modal:
assign_title: 'Assign position'
move_title: 'Move item'
assign_description: 'Select an item to assign it to a location.'
move_description: 'Select a new location for your item.'
assign_action: 'Assign'
move_action: 'Move'
row: 'Row'
column: 'Column'
inventory: 'Inventory'
item: 'Item'
import_modal:
import_button: 'Import items'
title: "Import items to a box"
description: "Import items to a box allows for assigning items to a box and updating positions within a grid box. First, export the current box data to download a file listing the items already in the box. Then, edit the exported file to add or update the items you want to place in the box. When importing the file, ensure it includes the Position and Item ID columns for a successful import."
export: "Export"
export_button: "Export current box"
import: "Import"
drag_n_drop: ".xlsx file"
errors:
invalid_structure: "The imported file content doesn't meet criteria."
invalid_position: "Positions in the file must match with the box."
invalid_item: "Item ID %{row_id} doesn't exist."
index:
head_title: "Locations"
new_location: "New location"
new_container: "New box"
duplicate:
success_message: "Location was successfully duplicated."
toolbar:
edit: 'Edit'
move: 'Move'
duplicate: 'Duplicate'
delete: 'Delete'
table:
name: "Location name"
id: "ID"
sub_locations: "Sub-locations"
items: "Items"
free_spaces: "Free spaces"
shared: "Shared"
owned_by: "Owned by"
created_on: "Created on"
description: "Description"
filters_modal:
search_tree: "Look inside locations"
edit_modal:
title_create_location: "Create new location"
title_create_container: "Create new box"
title_edit_location: "Edit location"
title_edit_container: "Edit box"
description_create_location: "Fill in the fields and create a new location."
description_create_container: "Fill in the fields to create a new box. Defining the box dimensions allows you to control the number of available spaces for placing inventory items."
name_label_location: "Location name"
image_label_location: "Image of location"
name_label_container: "Box name"
image_label_container: "Image of box"
drag_and_drop_supporting_text: ".png or .jpg file"
warning_box_not_empty: "Box dimensions can be updated only when the box is empty."
description_label: "Description"
name_placeholder: "Big freezer"
description_placeholder: "Keep everyone on the same page. You can also use smart annotations."
dimensions_label: "Dimensions (rows x columns)"
no_grid: "No grid"
grid: "Grid"
no_grid_tooltip: "You can assign unlimited items to the “No-grid” box but they do not have assigned position."
success_message:
create_location: "Location %{name} was successfully created."
create_container: "Box %{name} was successfully created."
edit_location: "Location %{name} was successfully updated."
edit_container: "Box %{name} was successfully updated."
errors:
max_length: "is too long (maximum is %{max_length} characters)"
move_modal:
title: 'Move %{name}'
description: 'Select where you want to move %{name}.'
search_header: 'Locations'
success_flash: "You have successfully moved the selected location/box to another location."
error_flash: "An error occurred. The selected location/box has not been moved."
placeholder:
find_storage_locations: 'Find location'
delete_modal:
title: 'Delete a %{type}'
description_1_html: "You're about to delete <b>%{name}</b>. This action will delete the %{type}. <b>%{num_of_items}</b> items inside will lose their assigned positions."
description_2_html: '<b>Are you sure you want to delete it?</b>'
success_message: "%{type} %{name} successfully deleted."
share: "Share"
libraries:
manange_modal_column_index:
title: "Manage columns"
@ -4307,6 +4419,7 @@ en:
labels: "Label"
teams: "All Teams"
addons: "Add-ons"
locations: "Locations"
label_printer: "Label printer"
fluics_printer: "Fluics printer"
@ -4418,6 +4531,15 @@ en:
active_state: "Active state"
archived_state: "Archived state"
modal_share:
title: "Share %{object_name}"
submit: "Save sharing options"
share_with_team: "Share with Team"
can_edit: "Can Edit"
all_teams: "All teams (current & new)"
all_teams_tooltip: "This will disable individual team settings"
success_message: "Selected sharing options for the %{object_name} have been saved."
errors:
general: "Something went wrong."
general_text_too_long: 'Text is too long'

View file

@ -322,6 +322,23 @@ en:
protocol_step_asset_renamed_html: "%{user} renamed file %{old_name} to %{new_name} on protocols step <strong>%{step}</strong> in Protocol repository."
result_asset_renamed_html: "%{user} renamed file %{old_name} to %{new_name} on result <strong>%{result}</strong> on task <strong>%{my_module}</strong>."
item_added_with_import_html: "%{user} edited %{num_of_items} inventory item(s) in %{repository}."
storage_location_created_html: "%{user} created location %{storage_location}."
storage_location_deleted_html: "%{user} deleted location %{storage_location}."
storage_location_edited_html: "%{user} edited location %{storage_location}."
storage_location_moved_html: "%{user} moved location %{storage_location} from %{storage_location_original} to %{storage_location_destination}."
storage_location_shared_html: "%{user} shared location %{storage_location} with team %{team} with %{permission_level} permission."
storage_location_unshared_html: "%{user} unshared location %{storage_location} with team %{team}."
storage_location_sharing_updated_html: "%{user} changed permission of shared location %{storage_location} with team %{team} to %{permission_level}."
container_storage_location_created_html: "%{user} created box %{storage_location}."
container_storage_location_deleted_html: "%{user} deleted box %{storage_location}."
container_storage_location_edited_html: "%{user} edited box %{storage_location}."
container_storage_location_moved_html: "%{user} moved box %{storage_location} from %{storage_location_original} to %{storage_location_destination}."
container_storage_location_shared_html: "%{user} shared box %{storage_location} with team %{team} with %{permission_level} permission."
container_storage_location_unshared_html: "%{user} unshared box %{storage_location} with team %{team}."
container_storage_location_sharing_updated_html: "%{user} changed permission of shared box %{storage_location} with team %{team} to %{permission_level}."
storage_location_repository_row_created_html: "%{user} assigned %{repository_row} to box %{storage_location} %{position}."
storage_location_repository_row_deleted_html: "%{user} unassigned %{repository_row} from box %{storage_location} %{position}."
storage_location_repository_row_moved_html: "%{user} moved item %{repository_row} from box %{storage_location_original} %{positions} to box %{storage_location_destination} %{positions}."
activity_name:
create_project: "Project created"
rename_project: "Project renamed"
@ -601,6 +618,23 @@ en:
task_step_file_duplicated: "File attachment on Task step duplicated"
result_file_duplicated: "File attachment on Task result duplicated"
protocol_step_file_duplicated: "File attachment on Protocol step duplicated"
storage_location_created: "Location created"
storage_location_deleted: "Location deleted"
storage_location_edited: "Location edited"
storage_location_moved: "Location moved"
storage_location_shared: "Location shared"
storage_location_unshared: "Location unshared"
storage_location_sharing_updated: "Location sharing permission updated"
container_storage_location_created: "Box created"
container_storage_location_deleted: "Box deleted"
container_storage_location_edited: "Box edited"
container_storage_location_moved: "Box moved"
container_storage_location_shared: "Box shared"
container_storage_location_unshared: "Box unshared"
container_storage_location_sharing_updated: "Box sharing permission updated"
storage_location_repository_row_created: "Inventory item location assigned"
storage_location_repository_row_deleted: "Inventory item location unassigned"
storage_location_repository_row_moved: "Inventory item location moved"
activity_group:
projects: "Projects"
task_results: "Task results"
@ -614,6 +648,8 @@ en:
team: "Team"
exports: "Exports"
label_templates: "Label templates"
storage_locations: "Locations"
container_storage_locations: "Boxes"
subject_name:
repository: "Inventory"
project: "Project"
@ -623,3 +659,4 @@ en:
protocol: "Protocol"
step: "Step"
report: "Report"
storage_location: "Location"

View file

@ -166,6 +166,7 @@ Rails.application.routes.draw do
resources :user_notifications, only: :index do
collection do
get :filter_groups
get :unseen_counter
end
end
@ -194,6 +195,8 @@ Rails.application.routes.draw do
get 'create_modal', to: 'repositories#create_modal',
defaults: { format: 'json' }
get 'actions_toolbar'
get :list
get :rows_list
end
member do
get :export_empty_repository
@ -253,6 +256,13 @@ Rails.application.routes.draw do
via: [:get, :post, :put, :patch]
end
resources :team_shared_objects, only: [] do
collection do
post 'update'
get 'shareable_teams'
end
end
resources :reports, only: [:index, :new, :create, :update] do
member do
get :document_preview
@ -807,6 +817,30 @@ Rails.application.routes.draw do
resources :connected_devices, controller: 'users/connected_devices', only: %i(destroy)
resources :storage_locations, only: %i(index create destroy update show) do
collection do
get :actions_toolbar
get :tree
end
member do
post :move
post :duplicate
post :unassign_rows
get :available_positions
get :shareable_teams
get :export_container
post :import_container
end
resources :storage_location_repository_rows, only: %i(index create destroy update) do
collection do
get :actions_toolbar
end
member do
post :move
end
end
end
get 'search' => 'search#index'
get 'search/new' => 'search#new', as: :new_search
resource :search, only: [], controller: :search do

View file

@ -65,7 +65,9 @@ const entryList = {
vue_legacy_tags_modal: './app/javascript/packs/vue/legacy/tags_modal.js',
vue_legacy_access_modal: './app/javascript/packs/vue/legacy/access_modal.js',
vue_legacy_repository_menu_dropdown: './app/javascript/packs/vue/legacy/repository_menu_dropdown.js',
vue_dashboard_new_task: './app/javascript/packs/vue/dashboard_new_task.js'
vue_dashboard_new_task: './app/javascript/packs/vue/dashboard_new_task.js',
vue_storage_locations_table: './app/javascript/packs/vue/storage_locations_table.js',
vue_storage_locations_container: './app/javascript/packs/vue/storage_locations_container.js'
};
// Engine pack loading based on https://github.com/rails/webpacker/issues/348#issuecomment-635480949

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
class AddStorageLocations < ActiveRecord::Migration[7.0]
include DatabaseHelper
def up
create_table :storage_locations do |t|
t.string :name
t.string :description
t.references :parent, index: true, foreign_key: { to_table: :storage_locations }
t.references :team, index: true, foreign_key: { to_table: :teams }
t.references :created_by, foreign_key: { to_table: :users }
t.boolean :container, default: false, null: false, index: true
t.jsonb :metadata, null: false, default: {}
t.datetime :discarded_at, index: true
t.timestamps
end
create_table :storage_location_repository_rows do |t|
t.references :repository_row, index: true, foreign_key: { to_table: :repository_rows }
t.references :storage_location, index: true, foreign_key: { to_table: :storage_locations }
t.references :created_by, foreign_key: { to_table: :users }
t.jsonb :metadata, null: false, default: {}
t.datetime :discarded_at, index: true
t.timestamps
end
add_gin_index_without_tags :storage_locations, :name
add_gin_index_without_tags :storage_locations, :description
end
def down
drop_table :storage_location_repository_rows
drop_table :storage_locations
end
end

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class AddStorageLocationPermissions < ActiveRecord::Migration[7.0]
STORAGE_LOCATIONS_MANAGE_PERMISSION = [
TeamPermissions::STORAGE_LOCATIONS_CREATE,
TeamPermissions::STORAGE_LOCATIONS_MANAGE,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_CREATE,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_MANAGE
].freeze
STORAGE_LOCATIONS_READ_PERMISSION = [
TeamPermissions::STORAGE_LOCATIONS_READ,
TeamPermissions::STORAGE_LOCATION_CONTAINERS_READ
].freeze
def up
@owner_role = UserRole.find_predefined_owner_role
@normal_user_role = UserRole.find_predefined_normal_user_role
@viewer_user_role = UserRole.find_predefined_viewer_role
@owner_role.permissions = @owner_role.permissions | STORAGE_LOCATIONS_MANAGE_PERMISSION |
STORAGE_LOCATIONS_READ_PERMISSION
@normal_user_role.permissions = @normal_user_role.permissions | STORAGE_LOCATIONS_MANAGE_PERMISSION |
STORAGE_LOCATIONS_READ_PERMISSION
@viewer_user_role.permissions = @viewer_user_role.permissions | STORAGE_LOCATIONS_READ_PERMISSION
@owner_role.save(validate: false)
@normal_user_role.save(validate: false)
@viewer_user_role.save(validate: false)
end
def down
@owner_role = UserRole.find_predefined_owner_role
@normal_user_role = UserRole.find_predefined_normal_user_role
@viewer_user_role = UserRole.find_predefined_viewer_role
@owner_role.permissions = @owner_role.permissions - STORAGE_LOCATIONS_MANAGE_PERMISSION -
STORAGE_LOCATIONS_READ_PERMISSION
@normal_user_role.permissions = @normal_user_role.permissions - STORAGE_LOCATIONS_MANAGE_PERMISSION -
STORAGE_LOCATIONS_READ_PERMISSION
@viewer_user_role.permissions = @viewer_user_role.permissions - STORAGE_LOCATIONS_READ_PERMISSION
@owner_role.save(validate: false)
@normal_user_role.save(validate: false)
@viewer_user_role.save(validate: false)
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class RemoveForeignKeyConstraintFromTeamSharedObjects < ActiveRecord::Migration[7.0]
def change
remove_foreign_key :team_shared_objects, :repositories, column: :shared_object_id
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_06_26_113515) do
ActiveRecord::Schema[7.0].define(version: 2024_07_05_122903) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gist"
enable_extension "pg_trgm"
@ -721,8 +721,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_06_26_113515) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "type"
t.datetime "start_time_dup"
t.datetime "end_time_dup"
t.datetime "start_time_dup", precision: nil
t.datetime "end_time_dup", precision: nil
t.index "((end_time)::date)", name: "index_repository_date_time_range_values_on_end_time_as_date", where: "((type)::text = 'RepositoryDateRangeValue'::text)"
t.index "((end_time)::time without time zone)", name: "index_repository_date_time_range_values_on_end_time_as_time", where: "((type)::text = 'RepositoryTimeRangeValue'::text)"
t.index "((start_time)::date)", name: "index_repository_date_time_range_values_on_start_time_as_date", where: "((type)::text = 'RepositoryDateRangeValue'::text)"
@ -1083,6 +1083,40 @@ ActiveRecord::Schema[7.0].define(version: 2024_06_26_113515) do
t.index ["user_id"], name: "index_steps_on_user_id"
end
create_table "storage_location_repository_rows", force: :cascade do |t|
t.bigint "repository_row_id"
t.bigint "storage_location_id"
t.bigint "created_by_id"
t.jsonb "metadata", default: {}, null: false
t.datetime "discarded_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_id"], name: "index_storage_location_repository_rows_on_created_by_id"
t.index ["discarded_at"], name: "index_storage_location_repository_rows_on_discarded_at"
t.index ["repository_row_id"], name: "index_storage_location_repository_rows_on_repository_row_id"
t.index ["storage_location_id"], name: "index_storage_location_repository_rows_on_storage_location_id"
end
create_table "storage_locations", force: :cascade do |t|
t.string "name"
t.string "description"
t.bigint "parent_id"
t.bigint "team_id"
t.bigint "created_by_id"
t.boolean "container", default: false, null: false
t.jsonb "metadata", default: {}, null: false
t.datetime "discarded_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index "trim_html_tags((description)::text) gin_trgm_ops", name: "index_storage_locations_on_description", using: :gin
t.index "trim_html_tags((name)::text) gin_trgm_ops", name: "index_storage_locations_on_name", using: :gin
t.index ["container"], name: "index_storage_locations_on_container"
t.index ["created_by_id"], name: "index_storage_locations_on_created_by_id"
t.index ["discarded_at"], name: "index_storage_locations_on_discarded_at"
t.index ["parent_id"], name: "index_storage_locations_on_parent_id"
t.index ["team_id"], name: "index_storage_locations_on_team_id"
end
create_table "tables", force: :cascade do |t|
t.binary "contents", null: false
t.datetime "created_at", precision: nil, null: false
@ -1278,6 +1312,9 @@ ActiveRecord::Schema[7.0].define(version: 2024_06_26_113515) do
t.integer "failed_attempts", default: 0, null: false
t.datetime "locked_at", precision: nil
t.string "unlock_token"
t.string "api_key"
t.datetime "api_key_expires_at", precision: nil
t.datetime "api_key_created_at", precision: nil
t.index "trim_html_tags((full_name)::text) gin_trgm_ops", name: "index_users_on_full_name", using: :gin
t.index ["authentication_token"], name: "index_users_on_authentication_token", unique: true
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
@ -1502,6 +1539,12 @@ ActiveRecord::Schema[7.0].define(version: 2024_06_26_113515) do
add_foreign_key "steps", "protocols"
add_foreign_key "steps", "users"
add_foreign_key "steps", "users", column: "last_modified_by_id"
add_foreign_key "storage_location_repository_rows", "repository_rows"
add_foreign_key "storage_location_repository_rows", "storage_locations"
add_foreign_key "storage_location_repository_rows", "users", column: "created_by_id"
add_foreign_key "storage_locations", "storage_locations", column: "parent_id"
add_foreign_key "storage_locations", "teams"
add_foreign_key "storage_locations", "users", column: "created_by_id"
add_foreign_key "tables", "users", column: "created_by_id"
add_foreign_key "tables", "users", column: "last_modified_by_id"
add_foreign_key "tags", "projects"

View file

@ -19,6 +19,7 @@ services:
container_name: scinote_web_development
stdin_open: true
tty: true
user: scinote
depends_on:
- db
ports:
@ -43,6 +44,7 @@ services:
container_name: scinote_webpack_development
stdin_open: true
tty: true
user: scinote
command: >
bash -c "yarn install && yarn build --watch"
environment:
@ -60,6 +62,7 @@ services:
container_name: scinote_css_bundling_development
stdin_open: true
tty: true
user: scinote
command: >
bash -c "yarn build:css --watch"
environment:
@ -77,6 +80,7 @@ services:
container_name: scinote_tailwind_development
stdin_open: true
tty: true
user: scinote
command: >
bash -c "rails tailwindcss:watch"
environment:

View file

@ -35,8 +35,8 @@
"@vuepic/vue-datepicker": "^7.2.0",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"ag-grid-community": "^30.1.0",
"ag-grid-vue3": "^30.2.1",
"ag-grid-community": "^31.3.4",
"ag-grid-vue3": "^31.3.4",
"ajv": "6.12.6",
"autoprefixer": "10.4.14",
"axios": "^1.7.4",
@ -85,7 +85,7 @@
"tui-color-picker": "^2.2.0",
"tui-image-editor": "github:scinote-eln/tui.image-editor#3_15_2_updated",
"twemoji": "^12.1.4",
"vue": "^3.3.4",
"vue": "^3.5.4",
"vue-loader": "^16.0.0",
"vue3-draggable-resizable": "^1.6.5",
"vue3-perfect-scrollbar": "^1.6.1",

Some files were not shown because too many files have changed in this diff Show more