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/*
!/app/assets/builds/.keep !/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 - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 > docker-compose
- chmod +x docker-compose - chmod +x docker-compose
- sudo mv docker-compose /usr/local/bin - sudo mv docker-compose /usr/local/bin
- sudo chown --recursive 1000 .
- make docker-ci - make docker-ci
script: script:
- make tests-ci - 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> MAINTAINER SciNote <info@scinote.net>
# additional dependecies # additional dependecies
@ -20,7 +20,8 @@ RUN apt-get update -qq && \
fonts-wqy-microhei \ fonts-wqy-microhei \
fonts-wqy-zenhei \ fonts-wqy-zenhei \
libfile-mimeinfo-perl \ libfile-mimeinfo-perl \
chromium-driver \ chromium \
chromium-sandbox \
yarnpkg && \ yarnpkg && \
ln -s /usr/lib/x86_64-linux-gnu/libvips.so.42 /usr/lib/x86_64-linux-gnu/libvips.so && \ 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/* rm -rf /var/lib/apt/lists/*
@ -35,6 +36,10 @@ ENV BUNDLE_PATH /usr/local/bundle/
ENV APP_HOME /usr/src/app ENV APP_HOME /usr/src/app
ENV PATH $APP_HOME/bin:$PATH ENV PATH $APP_HOME/bin:$PATH
RUN mkdir $APP_HOME 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 WORKDIR $APP_HOME
CMD rails s -b 0.0.0.0 CMD rails s -b 0.0.0.0

View file

@ -1,5 +1,5 @@
# Building stage # 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 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 \ RUN \
@ -23,7 +23,7 @@ COPY . $APP_HOME
RUN rm -f $APP_HOME/config/application.yml $APP_HOME/production.env RUN rm -f $APP_HOME/config/application.yml $APP_HOME/production.env
WORKDIR $APP_HOME WORKDIR $APP_HOME
RUN \ 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 without 'development test' && \
bundle config set path '/usr/src/app/tmp/bundle' && \ bundle config set path '/usr/src/app/tmp/bundle' && \
bundle install --jobs `nproc` && \ bundle install --jobs `nproc` && \
@ -34,14 +34,14 @@ RUN \
RUN \ RUN \
--mount=type=cache,target=/usr/local/share/.cache/yarn/v6,sharing=locked \ --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 \ DATABASE_URL=postgresql://postgres@db/scinote_production \
SECRET_KEY_BASE=dummy \ SECRET_KEY_BASE=dummy \
DEFACE_ENABLED=true \ 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 # Final stage
FROM ruby:3.2.2-bookworm AS runner FROM ruby:3.2.5-bookworm AS runner
MAINTAINER SciNote <info@scinote.net> 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 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 \ libvips42 \
graphviz \ graphviz \
chromium \ chromium \
chromium-sandbox \
libfile-mimeinfo-perl \ libfile-mimeinfo-perl \
yarnpkg && \ yarnpkg && \
/usr/share/nodejs/yarn/bin/yarn add puppeteer@npm:puppeteer-core@^22.15.0 && \ /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 PATH=$GEM_HOME/bin:$PATH
ENV BUNDLE_APP_CONFIG=.bundle 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 WORKDIR $APP_HOME
CMD rails s -b 0.0.0.0 CMD rails s -b 0.0.0.0

View file

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

View file

@ -388,6 +388,8 @@ GEM
rails-dom-testing (>= 1, < 3) rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0) railties (>= 4.2.0)
thor (>= 0.14, < 2.0) thor (>= 0.14, < 2.0)
js-routes (2.2.8)
railties (>= 4)
jsbundling-rails (1.1.1) jsbundling-rails (1.1.1)
railties (>= 6.0.0) railties (>= 6.0.0)
json (2.6.3) json (2.6.3)
@ -828,6 +830,7 @@ DEPENDENCIES
image_processing image_processing
img2zpl! img2zpl!
jbuilder jbuilder
js-routes
jsbundling-rails jsbundling-rails
json-jwt json-jwt
json_matchers json_matchers
@ -898,4 +901,4 @@ RUBY VERSION
ruby 3.2.2p53 ruby 3.2.2p53
BUNDLED WITH 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" @echo "Set environment variables, DATABASE_URL, RAILS_SERVE_STATIC_FILES, RAKE_ENV, RAILS_ENV, SECRET_KEY_BASE"
docker: docker:
@docker-compose build @docker-compose --progress plain build
docker-ci: docker-ci:
@docker-compose --progress plain build web @docker-compose --progress plain build web
docker-production: 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: config-production:
ifeq (production.env,$(wildcard production.env)) ifeq (production.env,$(wildcard production.env))

View file

@ -5,3 +5,5 @@ require File.expand_path('../config/application', __FILE__)
Rails.application.load_tasks Rails.application.load_tasks
Doorkeeper::Rake.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/buttons";
@import "tailwind/modals"; @import "tailwind/modals";
@import "tailwind/flyouts"; @import "tailwind/flyouts";
@import "tailwind/radio";
@import "tailwind/loader.css"; @import "tailwind/loader.css";
@tailwind base; @tailwind base;
@ -69,6 +70,6 @@ html {
@keyframes shine-lines { @keyframes shine-lines {
0% { background-position: -150px } 0% { background-position: -150px }
40%, 100% { background-position: 320px } 40%, 100% { background-position: 320px }
} }

View file

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

View file

@ -1,5 +1,5 @@
// scss-lint:disable SelectorDepth QualifyingElement // scss-lint:disable SelectorDepth QualifyingElement
/*
:root { :root {
--sci-radio-size: 16px; --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 include MyModulesHelper
before_action :load_repository, except: %i(index create create_modal sidebar archive restore actions_toolbar before_action :load_repository, except: %i(index create create_modal sidebar archive restore actions_toolbar
export_modal export_repositories) export_modal export_repositories list)
before_action :load_repositories, only: :index before_action :load_repositories, only: %i(index list)
before_action :load_repositories_for_archiving, only: :archive before_action :load_repositories_for_archiving, only: :archive
before_action :load_repositories_for_restoring, only: :restore 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 before_action :check_view_permissions, except: %i(index create_modal create update destroy parse_sheet
import_records sidebar archive restore actions_toolbar 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_manage_permissions, only: %i(rename_modal update)
before_action :check_delete_permissions, only: %i(destroy destroy_modal) before_action :check_delete_permissions, only: %i(destroy destroy_modal)
before_action :check_archive_permissions, only: %i(archive restore) 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_create_permissions, only: %i(create_modal create)
before_action :check_copy_permissions, only: %i(copy_modal copy) before_action :check_copy_permissions, only: %i(copy_modal copy)
before_action :set_inline_name_editing, only: %i(show) before_action :set_inline_name_editing, only: %i(show)
@ -44,6 +43,16 @@ class RepositoriesController < ApplicationController
end end
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 def sidebar
render json: { render json: {
html: render_to_string(partial: 'repositories/sidebar', locals: { html: render_to_string(partial: 'repositories/sidebar', locals: {
@ -101,15 +110,6 @@ class RepositoriesController < ApplicationController
} }
end 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 def hide_reminders
# synchronously hide currently visible reminders # synchronously hide currently visible reminders
if params[:visible_reminder_repository_row_ids].present? if params[:visible_reminder_repository_row_ids].present?
@ -522,10 +522,6 @@ class RepositoriesController < ApplicationController
render_403 unless can_delete_repository?(@repository) render_403 unless can_delete_repository?(@repository)
end end
def check_share_permissions
render_403 unless can_share_repository?(@repository)
end
def repository_params def repository_params
params.require(:repository).permit(:name) params.require(:repository).permit(:name)
end end

View file

@ -328,7 +328,7 @@ class RepositoryRowsController < ApplicationController
def active_reminder_repository_cells def active_reminder_repository_cells
reminder_cells = @repository_row.repository_cells.with_active_reminder(current_user).distinct reminder_cells = @repository_row.repository_cells.with_active_reminder(current_user).distinct
render json: { 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 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) next unless Extends::WHITELISTED_USER_SETTINGS.include?(key.to_s)
case key.to_s case key.to_s
when 'task_step_states' when 'task_step_states', 'result_states'
update_task_step_states(data) update_object_states(data, key.to_s)
else else
current_user.settings[key] = data current_user.settings[key] = data
end end
@ -34,18 +34,18 @@ module Users
private private
def update_task_step_states(task_step_states_data) def update_object_states(object_states_data, object_state_key)
current_states = current_user.settings.fetch('task_step_states', {}) 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 if collapsed
current_states[step_id] = true current_states[object_id] = true
else else
current_states.delete(step_id) current_states.delete(object_id)
end end
end end
current_user.settings['task_step_states'] = current_states current_user.settings[object_state_key] = current_states
end end
end 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 else
project_folder_path(obj, team: obj.team.id) project_folder_path(obj, team: obj.team.id)
end end
when StorageLocation
path = storage_location_path(obj)
else else
return current_value return current_value
end end

View file

@ -19,8 +19,16 @@ module LeftMenuBarHelper
url: repositories_path, url: repositories_path,
name: t('left_menu_bar.repositories'), name: t('left_menu_bar.repositories'),
icon: 'sn-icon-inventory', icon: 'sn-icon-inventory',
active: repositories_are_selected?, active: repositories_are_selected? || storage_locations_are_selected?,
submenu: [] 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: "#", url: "#",
name: t('left_menu_bar.templates'), name: t('left_menu_bar.templates'),
@ -63,6 +71,10 @@ module LeftMenuBarHelper
controller_name == 'repositories' controller_name == 'repositories'
end end
def storage_locations_are_selected?
controller_name == 'storage_locations'
end
def protocols_are_selected? def protocols_are_selected?
controller_name == 'protocols' controller_name == 'protocols'
end 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 === '') { if (this.query === '') {
return this.foldersTree; return this.foldersTree;
} }
return this.foldersTree.map((folder) => ( return this.filteredFoldersTreeHelper(this.foldersTree);
{
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
));
}, },
}, },
methods: { 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) { selectFolder(folderId) {
this.selectedFolderId = folderId; this.selectedFolderId = folderId;
}, },

View file

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

View file

@ -312,6 +312,11 @@
</div> </div>
</section> </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> <div v-if="!repository?.is_snapshot" id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<!-- QR --> <!-- QR -->
@ -367,6 +372,7 @@ import ScrollSpy from './repository_values/ScrollSpy.vue';
import CustomColumns from './customColumns.vue'; import CustomColumns from './customColumns.vue';
import RepositoryItemSidebarTitle from './Title.vue'; import RepositoryItemSidebarTitle from './Title.vue';
import UnlinkModal from './unlink_modal.vue'; import UnlinkModal from './unlink_modal.vue';
import Locations from './locations.vue';
import axios from '../../packs/custom_axios.js'; import axios from '../../packs/custom_axios.js';
const items = [ const items = [
@ -405,6 +411,14 @@ const items = [
{ {
id: 'highlight-item-5', id: 'highlight-item-5',
textId: 'text-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', labelAlias: 'QR_label',
label: 'QR-label', label: 'QR-label',
sectionId: 'qr-section', sectionId: 'qr-section',
@ -416,6 +430,7 @@ export default {
name: 'RepositoryItemSidebar', name: 'RepositoryItemSidebar',
components: { components: {
CustomColumns, CustomColumns,
Locations,
'repository-item-sidebar-title': RepositoryItemSidebarTitle, 'repository-item-sidebar-title': RepositoryItemSidebarTitle,
'inline-edit': InlineEdit, 'inline-edit': InlineEdit,
'scroll-spy': ScrollSpy, 'scroll-spy': ScrollSpy,
@ -433,6 +448,7 @@ export default {
repository: null, repository: null,
defaultColumns: null, defaultColumns: null,
customColumns: null, customColumns: null,
repositoryRow: null,
parentsCount: 0, parentsCount: 0,
childrenCount: 0, childrenCount: 0,
parents: null, parents: null,
@ -591,6 +607,7 @@ export default {
{ params: { my_module_id: this.myModuleId } } { params: { my_module_id: this.myModuleId } }
).then((response) => { ).then((response) => {
const result = response.data; const result = response.data;
this.repositoryRow = result;
this.repositoryRowId = result.id; this.repositoryRowId = result.id;
this.repository = result.repository; this.repository = result.repository;
this.optionsPath = result.options_path; 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 }, resultToReload: { type: Number, required: false },
activeDragResult: { activeDragResult: {
required: false required: false
},
userSettingsUrl: {
required: false
} }
}, },
data() { data() {
@ -227,6 +230,17 @@ export default {
deep: true 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: { computed: {
reorderableElements() { reorderableElements() {
return this.orderedElements.map((e) => ({ id: e.id, attributes: e.attributes.orderable })); return this.orderedElements.map((e) => ({ id: e.id, attributes: e.attributes.orderable }));
@ -337,6 +351,13 @@ export default {
toggleCollapsed() { toggleCollapsed() {
this.isCollapsed = !this.isCollapsed; this.isCollapsed = !this.isCollapsed;
this.result.attributes.collapsed = 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) { dragEnter(e) {
if (!this.urls.upload_attachment_url) return; if (!this.urls.upload_attachment_url) return;

View file

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

View file

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

View file

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

View file

@ -7,29 +7,29 @@
<i class="sn-icon sn-icon-close"></i> <i class="sn-icon sn-icon-close"></i>
</button> </button>
<h4 class="modal-title truncate !block"> <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> </h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div class="col-span-2"> <div class="col-span-2">
{{ i18n.t("repositories.index.modal_share.share_with_team") }} {{ i18n.t("modal_share.share_with_team") }}
</div> </div>
<div class="text-center"> <div class="text-center">
{{ i18n.t("repositories.index.modal_share.can_edit") }} {{ i18n.t("modal_share.can_edit") }}
</div> </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"> <span class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="sharedWithAllRead" /> <input type="checkbox" class="sci-checkbox" v-model="sharedWithAllRead" />
<span class="sci-checkbox-label"></span> <span class="sci-checkbox-label"></span>
</span> </span>
{{ i18n.t("repositories.index.modal_share.all_teams") }} {{ i18n.t("modal_share.all_teams") }}
</div> </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"> <span v-if="sharedWithAllRead" class="sci-toggle-checkbox-container">
<input type="checkbox" <input type="checkbox"
class="sci-toggle-checkbox" class="sci-toggle-checkbox"
:disabled="!repository.shareable_write" :disabled="!object.shareable_write"
v-model="sharedWithAllWrite" /> v-model="sharedWithAllWrite" />
<span class="sci-toggle-checkbox-label"></span> <span class="sci-toggle-checkbox-label"></span>
</span> </span>
@ -48,8 +48,7 @@
class="sci-toggle-checkbox-container"> class="sci-toggle-checkbox-container">
<input type="checkbox" <input type="checkbox"
class="sci-toggle-checkbox" class="sci-toggle-checkbox"
@change="permission_changes[team.id] = true" :disabled="!object.shareable_write"
:disabled="!repository.shareable_write"
v-model="team.attributes.private_shared_with_write" /> v-model="team.attributes.private_shared_with_write" />
<span class="sci-toggle-checkbox-label"></span> <span class="sci-toggle-checkbox-label"></span>
</span> </span>
@ -60,7 +59,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit"> <button class="btn btn-primary" @click="submit" type="submit">
{{ i18n.t('repositories.index.modal_share.submit') }} {{ i18n.t('modal_share.submit') }}
</button> </button>
</div> </div>
</div> </div>
@ -71,19 +70,20 @@
<script> <script>
/* global HelperModule */ /* global HelperModule */
import axios from '../../../packs/custom_axios.js'; import axios from '../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin'; import modalMixin from './modal_mixin';
export default { export default {
name: 'ShareRepositoryModal', name: 'ShareObjectModal',
props: { props: {
repository: Object object: Object,
globalShareEnabled: { type: Boolean, default: false }
}, },
mixins: [modalMixin], mixins: [modalMixin],
data() { data() {
return { return {
sharedWithAllRead: this.repository.shared_read || this.repository.shared_write, sharedWithAllRead: this.object.shared_read || this.object.shared_write,
sharedWithAllWrite: this.repository.shared_write, sharedWithAllWrite: this.object.shared_write,
shareableTeams: [], shareableTeams: [],
permission_changes: {} permission_changes: {}
}; };
@ -93,7 +93,7 @@ export default {
}, },
methods: { methods: {
getTeams() { getTeams() {
axios.get(this.repository.urls.shareable_teams).then((response) => { axios.get(this.object.urls.shareable_teams).then((response) => {
this.shareableTeams = response.data.data; this.shareableTeams = response.data.data;
}); });
}, },
@ -101,14 +101,12 @@ export default {
const data = { const data = {
select_all_teams: this.sharedWithAllRead, select_all_teams: this.sharedWithAllRead,
select_all_write_permission: this.sharedWithAllWrite, select_all_write_permission: this.sharedWithAllWrite,
share_team_ids: this.shareableTeams.filter((team) => team.attributes.private_shared_with).map((team) => team.id), team_share_params: this.shareableTeams.map((team) => { return { id: team.id, ...team.attributes } })
write_permissions: this.shareableTeams.filter((team) => team.attributes.private_shared_with_write).map((team) => team.id),
permission_changes: this.permission_changes
}; };
axios.post(this.repository.urls.share, data).then(() => { axios.post(this.object.urls.share, data).then(() => {
HelperModule.flashAlertMsg(this.i18n.t( HelperModule.flashAlertMsg(this.i18n.t(
'repositories.index.modal_share.success_message', 'modal_share.success_message',
{ inventory_name: this.repository.name } { object_name: this.object.name }
), 'success'); ), 'success');
this.$emit('share'); 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. # Provides asynchronous generation of image previews for ActiveStorage::Blob records.
class ActiveStorage::PreviewJob < ActiveStorage::BaseJob class ActiveStorage::PreviewJob < ActiveStorage::BaseJob
include ActiveStorageHelper
queue_as :assets queue_as :assets
discard_on StandardError do |job, error| discard_on StandardError do |job, error|
@ -18,11 +20,11 @@ class ActiveStorage::PreviewJob < ActiveStorage::BaseJob
def perform(blob_id) def perform(blob_id)
blob = ActiveStorage::Blob.find(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" \ Rails.logger.info "Preview for the Blod with id: #{blob.id} - successfully generated.\n" \
"Transformations applied: #{preview.variation.transformations}" "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" \ Rails.logger.info "Preview for the Blod with id: #{blob.id} - successfully generated.\n" \
"Transformations applied: #{preview.variation.transformations}" "Transformations applied: #{preview.variation.transformations}"

View file

@ -4,7 +4,9 @@ class CleanupUserSettingsJob < ApplicationJob
queue_as :default queue_as :default
def perform(record_type, record_id) 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 sanitized_record_id = record_id.to_i.to_s
raise ArgumentError, 'Invalid record_id' unless sanitized_record_id == record_id.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 when Asset
breadcrumbs[:asset] = subject.blob.filename.to_s breadcrumbs[:asset] = subject.blob.filename.to_s
generate_breadcrumb(subject.result || subject.step || subject.repository_cell.repository_row.repository) 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
end end

View file

@ -7,6 +7,7 @@ class Asset < ApplicationRecord
include WopiUtil include WopiUtil
include ActiveStorageFileUtil include ActiveStorageFileUtil
include ActiveStorageConcerns include ActiveStorageConcerns
include ActiveStorageHelper
require 'tempfile' require 'tempfile'
# Lock duration set to 30 minutes # Lock duration set to 30 minutes
@ -105,11 +106,11 @@ class Asset < ApplicationRecord
end end
def medium_preview 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 end
def large_preview 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 end
def file_name def file_name

View file

@ -7,12 +7,18 @@ module Cloneable
raise NotImplementedError, "Cloneable model must implement the '.parent' method!" unless respond_to?(:parent) raise NotImplementedError, "Cloneable model must implement the '.parent' method!" unless respond_to?(:parent)
clone_label = I18n.t('general.clone_label') clone_label = I18n.t('general.clone_label')
last_clone_number =
parent.public_send(self.class.table_name) records = if parent
.select("substring(#{self.class.table_name}.name, '(?:^#{clone_label} )(\\d+)')::int AS clone_number") parent.public_send(self.class.table_name)
.where('name ~ ?', "^#{clone_label} \\d+ - #{Regexp.escape(name)}$") else
.order(clone_number: :asc) self.class.where(parent_id: nil, team: team)
.last&.clone_number 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) "#{clone_label} #{(last_clone_number || 0) + 1} - #{name}".truncate(Constants::NAME_MAX_LENGTH)
end 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 PermissionCheckableModel
include RepositoryImportParser include RepositoryImportParser
include ArchivableModel include ArchivableModel
include Shareable
ID_PREFIX = 'IN' ID_PREFIX = 'IN'
include PrefixedIdModel include PrefixedIdModel
enum permission_level: Extends::SHARED_OBJECTS_PERMISSION_LEVELS
belongs_to :archived_by, belongs_to :archived_by,
foreign_key: :archived_by_id, foreign_key: :archived_by_id,
class_name: 'User', class_name: 'User',
@ -23,8 +22,6 @@ class Repository < RepositoryBase
class_name: 'User', class_name: 'User',
inverse_of: :restored_repositories, inverse_of: :restored_repositories,
optional: true 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, has_many :repository_snapshots,
class_name: 'RepositorySnapshot', class_name: 'RepositorySnapshot',
foreign_key: :parent_id, foreign_key: :parent_id,
@ -48,17 +45,6 @@ class Repository < RepositoryBase
scope :archived, -> { where(archived: true) } scope :archived, -> { where(archived: true) }
scope :globally_shared, -> { where(permission_level: %i(shared_read shared_write)) } 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| scope :assigned_to_project, lambda { |project|
joins(repository_rows: { my_module_repository_rows: { my_module: { experiment: :project } } }) joins(repository_rows: { my_module_repository_rows: { my_module: { experiment: :project } } })
.where(repository_rows: { my_module_repository_rows: { my_module: { experiments: { project: 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) teams.blank? ? self : where(team: teams)
end end
def shareable_write?
true
end
def permission_parent def permission_parent
team team
end end
@ -111,44 +93,6 @@ class Repository < RepositoryBase
['repository_rows.name', RepositoryRow::PREFIXED_ID_SQL, 'users.full_name'] ['repository_rows.name', RepositoryRow::PREFIXED_ID_SQL, 'users.full_name']
end 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) def self.name_like(query)
where('repositories.name ILIKE ?', "%#{query}%") where('repositories.name ILIKE ?', "%#{query}%")
end end

View file

@ -98,6 +98,13 @@ class RepositoryRow < ApplicationRecord
class_name: 'RepositoryRow', class_name: 'RepositoryRow',
source: :parent, source: :parent,
dependent: :destroy 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 auto_strip_attributes :name, nullify: false
validates :name, validates :name,
@ -172,6 +179,14 @@ class RepositoryRow < ApplicationRecord
self[:archived] self[:archived]
end 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 def archived
row_archived? || repository&.archived? row_archived? || repository&.archived?
end end

View file

@ -37,6 +37,9 @@ class Result < ApplicationRecord
accepts_nested_attributes_for :tables accepts_nested_attributes_for :tables
before_save :ensure_default_name before_save :ensure_default_name
after_discard do
CleanupUserSettingsJob.perform_later('result_states', id)
end
def self.search(user, def self.search(user,
include_archived, 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', source_type: 'RepositoryBase',
dependent: :destroy dependent: :destroy
has_many :shareable_links, inverse_of: :team, dependent: :destroy has_many :shareable_links, inverse_of: :team, dependent: :destroy
has_many :storage_locations, dependent: :destroy
attr_accessor :without_templates 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) within_limits && team.permission_granted?(user, TeamPermissions::INVENTORIES_CREATE)
end 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| can :create_reports do |user, team|
team.permission_granted?(user, TeamPermissions::REPORTS_CREATE) team.permission_granted?(user, TeamPermissions::REPORTS_CREATE)
end 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 class RepositorySerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers include Rails.application.routes.url_helpers
include ShareableSerializer
attributes :name, :code, :nr_of_rows, :shared, :shared_label, :ishared, attributes :name, :code, :nr_of_rows, :team, :created_at, :created_by, :archived_on, :archived_by, :urls
:team, :created_at, :created_by, :archived_on, :archived_by,
:urls, :shared_read, :shared_write, :shareable_write
def nr_of_rows def nr_of_rows
object[:row_count] object[:row_count]
end 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 def team
object[:team_name] object[:team_name]
end end
@ -54,25 +32,15 @@ module Lists
object[:archived_by_user] object[:archived_by_user]
end end
def shared_read
object.shared_read?
end
def shared_write
object.shared_write?
end
def shareable_write
object.shareable_write?
end
def urls def urls
{ {
show: repository_path(object), show: repository_path(object),
update: team_repository_path(current_user.current_team, id: object, format: :json), 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), 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
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 :open_vector_editor_context, :comments_count, :assets_view_mode, :storage_limit, :collapsed
def collapsed def collapsed
false result_states = current_user.settings.fetch('result_states', {})
result_states[object.id.to_s] == true
end end
def marvinjs_enabled def marvinjs_enabled

View file

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

View file

@ -61,7 +61,11 @@ module Activities
end end
if id 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] = { 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_for] = getter_method
@activity.message_items[k][:value_type] = value_type unless value_type.nil? @activity.message_items[k][:value_type] = value_type unless value_type.nil?

View file

@ -29,7 +29,7 @@ module Lists
end end
def paginate_records 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 end
def sort_direction(order_params) 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 element_json = element.as_json
case element.orderable_type case element.orderable_type
when 'ResultText' when 'ResultText'
element_json['step_text'] = element.orderable.as_json element_json['result_text'] = element.orderable.as_json
when 'ResultTable' when 'ResultTable'
element_json['table'] = table(element.orderable.table) element_json['table'] = table(element.orderable.table)
end end

View file

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

View file

@ -11,9 +11,9 @@ module Reports
def self.image_prepare(asset) def self.image_prepare(asset)
if asset.class == Asset if asset.class == Asset
if asset.inline? if asset.inline?
asset.preview_attachment.representation(resize_to_limit: Constants::MEDIUM_PIC_FORMAT, format: :png) asset.medium_preview
else else
asset.preview_attachment.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT, format: :png) asset.large_preview
end end
elsif asset.class == TinyMceAsset elsif asset.class == TinyMceAsset
asset.image.representation(format: :png) 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 # Find new id of the first admin in the team
@admin_id = @user_mappings[team_json['default_admin_id']] @admin_id = @user_mappings[team_json['default_admin_id']]
create_notifications(team_json['notifications'])
create_protocol_keywords(team_json['protocol_keywords'], team) create_protocol_keywords(team_json['protocol_keywords'], team)
create_protocols(team_json['protocols'], nil, team) create_protocols(team_json['protocols'], nil, team)
create_project_folders(team_json['project_folders'], 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 # Second run, we needed it because of some models should be created
team_json['users'].each do |user_json| 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| user_json['repository_table_states'].each do |rep_tbl_state_json|
rep_tbl_state = RepositoryTableState.new(rep_tbl_state_json) rep_tbl_state = RepositoryTableState.new(rep_tbl_state_json)
rep_tbl_state.id = nil rep_tbl_state.id = nil
@ -423,21 +411,6 @@ class TeamImporter
end end
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) def create_repositories(repositories_json, team, snapshots = false)
puts 'Creating repositories...' puts 'Creating repositories...'
repositories_json.each do |repository_json| repositories_json.each do |repository_json|
@ -756,7 +729,7 @@ class TeamImporter
def create_protocols(protocols_json, my_module = nil, team = nil, def create_protocols(protocols_json, my_module = nil, team = nil,
user_id = 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...' puts 'Creating protocols...'
sorted_protocols.each do |protocol_json| 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 end
%> %>
<%= render partial: 'radio' %>
<%= render partial: 'select' %> <%= render partial: 'select' %>
<%= render partial: 'modals' %> <%= 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 { .print-footer {
line-height: 50px; line-height: 50px;
font-size: 13px; font-size: 13px;
padding-right: 30px; padding-right: 30px;
text-align: right; text-align: right;
width: 100%; width: 100%;
} }

View file

@ -37,6 +37,23 @@ json.actions do
end end
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.default_columns do
json.name @repository_row.name json.name @repository_row.name
json.code @repository_row.code 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 config.x.new_team_on_signup = false
end end
config.hosts << "dev.scinote.test" config.hosts << "dev.scinote.test"
# Automatically update js-routes file
# when routes.rb is changed
config.middleware.use(JsRoutes::Middleware)
end end

View file

@ -188,7 +188,7 @@ class Extends
ACTIVITY_SUBJECT_TYPES = %w( ACTIVITY_SUBJECT_TYPES = %w(
Team RepositoryBase Project Experiment MyModule Result Protocol Report RepositoryRow Team RepositoryBase Project Experiment MyModule Result Protocol Report RepositoryRow
ProjectFolder Asset Step LabelTemplate ProjectFolder Asset Step LabelTemplate StorageLocation StorageLocationRepositoryRow
).freeze ).freeze
SEARCHABLE_ACTIVITY_SUBJECT_TYPES = %w( SEARCHABLE_ACTIVITY_SUBJECT_TYPES = %w(
@ -205,7 +205,8 @@ class Extends
my_module: %i(results protocols), my_module: %i(results protocols),
result: [:assets], result: [:assets],
protocol: [:steps], protocol: [:steps],
step: [:assets] step: [:assets],
storage_location: [:storage_location_repository_rows]
} }
ACTIVITY_MESSAGE_ITEMS_TYPES = ACTIVITY_MESSAGE_ITEMS_TYPES =
@ -495,7 +496,24 @@ class Extends
task_step_asset_renamed: 305, task_step_asset_renamed: 305,
result_asset_renamed: 306, result_asset_renamed: 306,
protocol_step_asset_renamed: 307, 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 = { ACTIVITY_GROUPS = {
@ -515,7 +533,10 @@ class Extends
190, 191, *204..215, 220, 223, 227, 228, 229, *230..235, 190, 191, *204..215, 220, 223, 227, 228, 229, *230..235,
*237..240, *253..256, *279..283, 300, 304, 307], *237..240, *253..256, *279..283, 300, 304, 307],
team: [92, 94, 93, 97, 104, 244, 245], 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 TOP_LEVEL_ASSIGNABLES = %w(Project Team Protocol Repository).freeze
@ -636,6 +657,8 @@ class Extends
preferences/index preferences/index
addons/index addons/index
search/index search/index
storage_locations/index
storage_locations/show
) )
DEFAULT_USER_NOTIFICATION_SETTINGS = { DEFAULT_USER_NOTIFICATION_SETTINGS = {
@ -673,6 +696,7 @@ class Extends
repository_export_file_type repository_export_file_type
navigator_collapsed navigator_collapsed
navigator_width navigator_width
result_states
).freeze ).freeze
end end

View file

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

View file

@ -3,8 +3,8 @@
Grover.configure do |config| Grover.configure do |config|
config.options = { config.options = {
cache: false, cache: false,
executable_path: './bin/chromium', executable_path: ENV['CHROMIUM_PATH'] || '/usr/bin/chromium',
launch_args: %w(--disable-gpu --no-sandbox), launch_args: %w(--disable-dev-shm-usage --disable-gpu --no-sandbox),
timeout: Constants::GROVER_TIMEOUT_MS timeout: Constants::GROVER_TIMEOUT_MS
} }
end 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: attributes:
text: Text is too long text: Text is too long
position: "Position has already been taken by another item in the checklist" 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: storage:
limit_reached: "Storage limit has been reached." limit_reached: "Storage limit has been reached."
helpers: helpers:
@ -342,6 +348,8 @@ en:
templates: "Templates" templates: "Templates"
protocol: "Protocol" protocol: "Protocol"
label: "Label" label: "Label"
items: "Items"
locations: "Locations"
reports: "Reports" reports: "Reports"
settings: "Settings" settings: "Settings"
activities: "Activities" activities: "Activities"
@ -2004,14 +2012,6 @@ en:
name_placeholder: "My inventory" name_placeholder: "My inventory"
submit: "Create" submit: "Create"
success_flash_html: "Inventory <strong>%{name}</strong> successfully created." 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: export:
notification: notification:
error: error:
@ -2341,7 +2341,7 @@ en:
linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory' linkTo: 'https://knowledgebase.scinote.net/en/knowledge/how-to-add-items-to-an-inventory'
dragAndDropUpload: dragAndDropUpload:
notSingleFileError: 'Single file import only. Please import one file at a time.' 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.' emptyFileError: 'You have uploaded empty file. There is not much to import.'
fileTooLargeError: 'File too large. Max file size limit is' fileTooLargeError: 'File too large. Max file size limit is'
importText: importText:
@ -2608,7 +2608,12 @@ en:
custom_columns_label: 'Custom columns' custom_columns_label: 'Custom columns'
relationships_label: 'Relationships' relationships_label: 'Relationships'
assigned_label: 'Assigned' assigned_label: 'Assigned'
locations_label: 'Locations'
QR_label: 'QR' QR_label: 'QR'
locations:
title: 'Locations (%{count})'
container: 'Box'
assign: 'Assign new location'
repository_stock_values: repository_stock_values:
manage_modal: manage_modal:
title: "Stock %{item}" title: "Stock %{item}"
@ -2663,6 +2668,113 @@ en:
repository_ledger_records: repository_ledger_records:
errors: errors:
my_module_references_missing: 'Task references are not set' 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: libraries:
manange_modal_column_index: manange_modal_column_index:
title: "Manage columns" title: "Manage columns"
@ -4307,6 +4419,7 @@ en:
labels: "Label" labels: "Label"
teams: "All Teams" teams: "All Teams"
addons: "Add-ons" addons: "Add-ons"
locations: "Locations"
label_printer: "Label printer" label_printer: "Label printer"
fluics_printer: "Fluics printer" fluics_printer: "Fluics printer"
@ -4418,6 +4531,15 @@ en:
active_state: "Active state" active_state: "Active state"
archived_state: "Archived 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: errors:
general: "Something went wrong." general: "Something went wrong."
general_text_too_long: 'Text is too long' 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." 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>." 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}." 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: activity_name:
create_project: "Project created" create_project: "Project created"
rename_project: "Project renamed" rename_project: "Project renamed"
@ -601,6 +618,23 @@ en:
task_step_file_duplicated: "File attachment on Task step duplicated" task_step_file_duplicated: "File attachment on Task step duplicated"
result_file_duplicated: "File attachment on Task result duplicated" result_file_duplicated: "File attachment on Task result duplicated"
protocol_step_file_duplicated: "File attachment on Protocol step 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: activity_group:
projects: "Projects" projects: "Projects"
task_results: "Task results" task_results: "Task results"
@ -614,6 +648,8 @@ en:
team: "Team" team: "Team"
exports: "Exports" exports: "Exports"
label_templates: "Label templates" label_templates: "Label templates"
storage_locations: "Locations"
container_storage_locations: "Boxes"
subject_name: subject_name:
repository: "Inventory" repository: "Inventory"
project: "Project" project: "Project"
@ -623,3 +659,4 @@ en:
protocol: "Protocol" protocol: "Protocol"
step: "Step" step: "Step"
report: "Report" report: "Report"
storage_location: "Location"

View file

@ -166,6 +166,7 @@ Rails.application.routes.draw do
resources :user_notifications, only: :index do resources :user_notifications, only: :index do
collection do collection do
get :filter_groups
get :unseen_counter get :unseen_counter
end end
end end
@ -194,6 +195,8 @@ Rails.application.routes.draw do
get 'create_modal', to: 'repositories#create_modal', get 'create_modal', to: 'repositories#create_modal',
defaults: { format: 'json' } defaults: { format: 'json' }
get 'actions_toolbar' get 'actions_toolbar'
get :list
get :rows_list
end end
member do member do
get :export_empty_repository get :export_empty_repository
@ -253,6 +256,13 @@ Rails.application.routes.draw do
via: [:get, :post, :put, :patch] via: [:get, :post, :put, :patch]
end end
resources :team_shared_objects, only: [] do
collection do
post 'update'
get 'shareable_teams'
end
end
resources :reports, only: [:index, :new, :create, :update] do resources :reports, only: [:index, :new, :create, :update] do
member do member do
get :document_preview get :document_preview
@ -807,6 +817,30 @@ Rails.application.routes.draw do
resources :connected_devices, controller: 'users/connected_devices', only: %i(destroy) 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' => 'search#index'
get 'search/new' => 'search#new', as: :new_search get 'search/new' => 'search#new', as: :new_search
resource :search, only: [], controller: :search do 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_tags_modal: './app/javascript/packs/vue/legacy/tags_modal.js',
vue_legacy_access_modal: './app/javascript/packs/vue/legacy/access_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_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 // 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "btree_gist" enable_extension "btree_gist"
enable_extension "pg_trgm" 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "type" t.string "type"
t.datetime "start_time_dup" t.datetime "start_time_dup", precision: nil
t.datetime "end_time_dup" 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)::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 "((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)" 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" t.index ["user_id"], name: "index_steps_on_user_id"
end 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| create_table "tables", force: :cascade do |t|
t.binary "contents", null: false t.binary "contents", null: false
t.datetime "created_at", precision: nil, 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.integer "failed_attempts", default: 0, null: false
t.datetime "locked_at", precision: nil t.datetime "locked_at", precision: nil
t.string "unlock_token" 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 "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 ["authentication_token"], name: "index_users_on_authentication_token", unique: true
t.index ["confirmation_token"], name: "index_users_on_confirmation_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", "protocols"
add_foreign_key "steps", "users" add_foreign_key "steps", "users"
add_foreign_key "steps", "users", column: "last_modified_by_id" 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: "created_by_id"
add_foreign_key "tables", "users", column: "last_modified_by_id" add_foreign_key "tables", "users", column: "last_modified_by_id"
add_foreign_key "tags", "projects" add_foreign_key "tags", "projects"

View file

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

View file

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

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