Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dynamic counts to search filters #5471

Open
wants to merge 72 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
cc610d6
Remove color function
jorg-vr Apr 4, 2024
810fa47
Remove paramval function
jorg-vr Apr 4, 2024
84872dc
Pass through filter collection objects
jorg-vr Apr 4, 2024
0447296
Revert "Pass through filter collection objects"
jorg-vr Apr 4, 2024
b7a03dc
create flecible count by filters
jorg-vr Apr 5, 2024
bf8764c
Return named filter info
jorg-vr Apr 5, 2024
6f71249
Replace autoinclude by class method
jorg-vr Apr 5, 2024
ab642b6
Make filter options available in controller
jorg-vr Apr 5, 2024
fb772c4
Display counts
jorg-vr Apr 18, 2024
a51f6c2
Add support for multi filters
jorg-vr Apr 19, 2024
8beadd3
Fix bug
jorg-vr Apr 19, 2024
b7dfa43
fix non standerdize name options
jorg-vr Apr 19, 2024
95138fe
Create custom count functions
jorg-vr Apr 19, 2024
befc03a
Simplify js
jorg-vr Apr 19, 2024
f37d232
Autoupdate
jorg-vr Apr 19, 2024
4e7e410
Fix translations
jorg-vr Apr 19, 2024
8dde4f7
Add disabeled state to filters
jorg-vr Apr 19, 2024
b11b565
Convert activity read states
jorg-vr Apr 19, 2024
5293075
Rename by id filters
jorg-vr Apr 22, 2024
3459c41
Merge branch 'main' into chore/simplify-search
jorg-vr Apr 22, 2024
46acf6f
Fix filters for questions
jorg-vr Apr 23, 2024
f39264f
Fix course member search
jorg-vr Apr 23, 2024
60c073c
Fix course
jorg-vr Apr 23, 2024
2e3ebcc
Fix course
jorg-vr Apr 23, 2024
8792545
Fix course label filters
jorg-vr Apr 29, 2024
62a16f8
Fix course labels filters for evaluations
jorg-vr Apr 30, 2024
57110bb
Fix event types
jorg-vr Apr 30, 2024
457f928
COnvert feedbacks searchbar
jorg-vr Apr 30, 2024
8fa9e88
Fix repository searchbars
jorg-vr Apr 30, 2024
7030d61
Fix saved annotations
jorg-vr Apr 30, 2024
bebda0f
Fix series
jorg-vr Apr 30, 2024
3d2e327
Fix submissions
jorg-vr Apr 30, 2024
8d92875
fix users
jorg-vr Apr 30, 2024
f07a7d3
Simplify enums
jorg-vr Apr 30, 2024
2c80ae8
Simplify model naming
jorg-vr Apr 30, 2024
f9be4c9
remove unused code
jorg-vr May 2, 2024
98bdf22
Always include hasfilter
jorg-vr May 2, 2024
2d22ea1
Always include filterable
jorg-vr May 2, 2024
559b800
Fix javascript tests
jorg-vr May 2, 2024
0d95942
Merge branch 'chore/simplify-search' of github.com:dodona-edu/dodona …
jorg-vr May 2, 2024
d413a4a
Revert "Always include hasfilter"
jorg-vr May 2, 2024
16763ba
Fix rails tests
jorg-vr May 2, 2024
609dcec
Fix linting
jorg-vr May 2, 2024
f2215c4
Move color responsibility to frontend
jorg-vr May 2, 2024
7e581df
Simply xourse controller scoresheet
jorg-vr May 2, 2024
deb9809
Fix linting
jorg-vr May 2, 2024
a0cde25
Add count to searchfield suggestions
jorg-vr May 2, 2024
4a5c754
Remove unused scope
jorg-vr May 2, 2024
fae53bd
Remove unused scope
jorg-vr May 2, 2024
c40bbdc
Merge branch 'main' into chore/simplify-search
jorg-vr May 13, 2024
f7b5ec8
Avoid string repeat in type
jorg-vr May 13, 2024
e9e0887
Add documentation
jorg-vr May 13, 2024
e89c14a
Make numbers less prominent
jorg-vr May 13, 2024
9f054c6
Remove inline style
jorg-vr May 13, 2024
ff66570
Remove inline sql
jorg-vr May 14, 2024
698b7b9
Only do reselect on group by clauses
jorg-vr May 14, 2024
632fdde
Remove counts for submissions table
jorg-vr May 14, 2024
edd9fca
Fix course filter on all courses page
jorg-vr May 14, 2024
b934156
Add clear all active filters link
jorg-vr May 16, 2024
da4d108
Calculate question filter options after everything filter
jorg-vr May 16, 2024
726a1d6
Update app/controllers/feedbacks_controller.rb
jorg-vr May 22, 2024
174fc2a
Move filterable by course labels to its own concern
jorg-vr May 22, 2024
adf67d8
Only include filterable when needed
jorg-vr May 22, 2024
1a69157
Fix imports
jorg-vr May 22, 2024
77d6f3f
Merge branch 'main' into chore/simplify-search
jorg-vr May 22, 2024
f78d162
Hide 'define_singleton_method' behind method
jorg-vr May 22, 2024
0e89451
Remove delegation
jorg-vr May 22, 2024
8e2ce0b
Don't modify parent
jorg-vr May 22, 2024
b8171b2
Fix rubocop
jorg-vr May 22, 2024
14528ea
Merge branch 'main' into chore/simplify-search
jorg-vr May 28, 2024
9d872d8
No need for extra keys
jorg-vr May 28, 2024
25930a7
Merge branch 'main' into chore/simplify-search
jorg-vr Jun 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions app/assets/javascripts/components/search/dropdown_filter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FilterCollection, Label, FilterCollectionElement } from "components/search/filter_collection_element";
import {
FilterCollection,
Label,
FilterCollectionElement,
AccentColor
} from "components/search/filter_collection_element";
import { i18n } from "i18n/i18n";
import { DodonaElement } from "components/meta/dodona_element";

Expand All @@ -10,17 +15,16 @@
*
* @element d-dropdown-filter
*
* @prop {(s: Label) => string} color - a function that fetches the color associated with each label
* @prop {AccentColor} color - the color associated with the filter
* @prop {string} type - The type of the filter collection, used to determine the dropdown button text
* @prop {string} param - the searchQuery param to be used for this filter
* @prop {boolean} multi - whether one or more labels can be selected at the same time
* @prop {(l: Label) => string} paramVal - a function that extracts the value that should be used in a searchQuery for a selected label
* @prop {[Label]} labels - all labels that could potentially be selected
*/
@customElement("d-dropdown-filter")
export class DropdownFilter extends FilterCollectionElement {
@property()
color: (s: Label) => string;
@property({ type: String })
color: AccentColor;
@property()
type: string;

Expand All @@ -43,7 +47,7 @@
return html`
<div class="dropdown dropdown-filter">
<a class="token token-bordered" href="#" role="button" id="dropdownMenuLink" data-bs-toggle="dropdown" aria-expanded="false">
${this.getSelectedLabels().map( s => html`<i class="mdi mdi-circle mdi-12 mdi-colored-accent accent-${this.color(s)} left-icon"></i>`)}
${this.getSelectedLabels().map( () => html`<i class="mdi mdi-circle mdi-12 mdi-colored-accent accent-${this.color} left-icon"></i>`)}

Check warning on line 50 in app/assets/javascripts/components/search/dropdown_filter.ts

View check run for this annotation

Codecov / codecov/patch

app/assets/javascripts/components/search/dropdown_filter.ts#L50

Added line #L50 was not covered by tests
${i18n.t(`js.dropdown.${this.multi?"multi":"single"}.${this.type}`)}
<i class="mdi mdi-chevron-down mdi-18 right-icon"></i>
</a>
Expand Down Expand Up @@ -92,7 +96,6 @@
<d-dropdown-filter
.labels=${c.data}
.color=${c.color}
.paramVal=${c.paramVal}
.param=${c.param}
.multi=${c.multi}
.type=${type}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import { DodonaElement } from "components/meta/dodona_element";

export type Label = {id: string, name: string};
export type AccentColor = "red" | "pink" | "purple" | "deep-purple" | "indigo" | "teal" | "orange" | "brown" | "blue-gray";
export type FilterCollection = {
data: Label[],
multi: boolean,
color: (l: Label) => string,
paramVal: (l: Label) => string,
color: AccentColor,
param: string
};

Expand All @@ -26,8 +26,6 @@
param: string;
@property({ type: Boolean })
multi: boolean;
@property()
paramVal: (l: Label) => string;
@property({ type: Array })
labels: Array<Label> = [];

Expand All @@ -47,36 +45,32 @@
super.update(changedProperties);
}

private str(label: Label): string {
return this.paramVal(label).toString();
}

private get multiSelected(): string[] {
return searchQueryState.arrayQueryParams.get(this.param) || [];
}

private multiUnSelect(label: Label): void {
searchQueryState.arrayQueryParams.set(this.param, this.multiSelected.filter(s => s !== this.str(label)));
searchQueryState.arrayQueryParams.set(this.param, this.multiSelected.filter(s => s !== label.id));

Check warning on line 53 in app/assets/javascripts/components/search/filter_collection_element.ts

View check run for this annotation

Codecov / codecov/patch

app/assets/javascripts/components/search/filter_collection_element.ts#L53

Added line #L53 was not covered by tests
}

private multiIsSelected(label: Label): boolean {
return this.multiSelected.includes(this.str(label));
return this.multiSelected.includes(label.id);

Check warning on line 57 in app/assets/javascripts/components/search/filter_collection_element.ts

View check run for this annotation

Codecov / codecov/patch

app/assets/javascripts/components/search/filter_collection_element.ts#L57

Added line #L57 was not covered by tests
}

private multiSelect(label: Label): void {
searchQueryState.arrayQueryParams.set(this.param, [...this.multiSelected, this.str(label)]);
searchQueryState.arrayQueryParams.set(this.param, [...this.multiSelected, label.id]);

Check warning on line 61 in app/assets/javascripts/components/search/filter_collection_element.ts

View check run for this annotation

Codecov / codecov/patch

app/assets/javascripts/components/search/filter_collection_element.ts#L61

Added line #L61 was not covered by tests
}

private singleUnSelect(label: Label): void {
searchQueryState.queryParams.set(this.param, undefined);
}

private singleSelect(label: Label): void {
searchQueryState.queryParams.set(this.param, this.str(label));
searchQueryState.queryParams.set(this.param, label.id);
}

private singleIsSelected(label: Label): boolean {
return searchQueryState.queryParams.get(this.param) === this.str(label);
return searchQueryState.queryParams.get(this.param) === label.id;
}

isSelected = this.singleIsSelected;
Expand Down
2 changes: 0 additions & 2 deletions app/assets/javascripts/components/search/filter_tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ export class FilterTabs extends watchMixin(FilterCollectionElement) {
multi = false;
@property()
param = "tab";
@property()
paramVal = (label: Label): string => label.id.toString();
@property({ type: Array })
labels: TabInfo[];

Expand Down
5 changes: 2 additions & 3 deletions app/assets/javascripts/components/search/search_field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { html, TemplateResult } from "lit";
import { createDelayer } from "utilities";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { ref } from "lit/directives/ref.js";
import { FilterCollectionElement, Label } from "components/search/filter_collection_element";
import { FilterCollection, FilterCollectionElement, Label } from "components/search/filter_collection_element";
import { searchQueryState } from "state/SearchQuery";
import { search } from "search";
import { DodonaElement } from "components/meta/dodona_element";
Expand Down Expand Up @@ -83,7 +83,7 @@ export class SearchField extends DodonaElement {
@property({ type: Boolean })
eager: boolean;
@property( { type: Array })
filterCollections: Record<string, { data: Label[], multi: boolean, paramVal: (l: Label) => string, param: string }>;
filterCollections: Record<string, FilterCollection>;

@property({ state: true })
filter?: string = "";
Expand Down Expand Up @@ -171,7 +171,6 @@ export class SearchField extends DodonaElement {
.labels=${c.data}
.type=${type}
.filter=${this.filter}
.paramVal=${c.paramVal}
.param=${c.param}
.multi=${c.multi}
.index=${i}
Expand Down
16 changes: 10 additions & 6 deletions app/assets/javascripts/components/search/search_token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FilterCollection, FilterCollectionElement, Label } from "components/search/filter_collection_element";
import {
AccentColor,
FilterCollection,
FilterCollectionElement,
Label
} from "components/search/filter_collection_element";
import { DodonaElement } from "components/meta/dodona_element";

/**
Expand All @@ -9,16 +14,16 @@ import { DodonaElement } from "components/meta/dodona_element";
*
* @element d-search-token
*
* @prop {(s: Label) => string} color - a function that fetches the color associated with each label
* @prop {AccentColor} color - the color associated with the filter
* @prop {string} param - the searchQuery param to be used for this filter
* @prop {boolean} multi - whether one or more labels can be selected at the same time
* @prop {(l: Label) => string} paramVal - a function that extracts the value that should be used in a searchQuery for a selected label
* @prop {[Label]} labels - all labels that could potentially be selected
*/
@customElement("d-search-token")
export class SearchToken extends FilterCollectionElement {
@property()
color: (l: Label) => string;
@property({ type: String })
color: AccentColor;

processClick(e: Event, label: Label): void {
this.unSelect(label);
Expand All @@ -28,7 +33,7 @@ export class SearchToken extends FilterCollectionElement {
render(): TemplateResult {
return html`
${ this.getSelectedLabels().map( label => html`
<div class="token accent-${this.color(label)}">
<div class="token accent-${this.color}">
<span class="token-label">${label.name}</span>
<a href="#" class="close" tabindex="-1" @click=${e => this.processClick(e, label)}>
<i class="mdi mdi-close mdi-18"></i>
Expand Down Expand Up @@ -61,7 +66,6 @@ export class SearchTokens extends DodonaElement {
<d-search-token
.labels=${c.data}
.color=${c.color}
.paramVal=${c.paramVal}
.param=${c.param}
.multi=${c.multi}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@
export class StandaloneDropdownFilter extends watchMixin(FilterCollectionElement) {
@property()
multi = false;
@property()
paramVal = (label: Label): string => label.id.toString();
@property({ type: String })
default;

watch = {
default: () => {
if (this.getSelectedLabels().length === 0) {
this.select(this.labels.find(label => this.paramVal(label) === this.default));
this.select(this.labels.find(label => label.id === this.default));

Check warning on line 26 in app/assets/javascripts/components/search/standalone-dropdown-filter.ts

View check run for this annotation

Codecov / codecov/patch

app/assets/javascripts/components/search/standalone-dropdown-filter.ts#L26

Added line #L26 was not covered by tests
}
}
};
Expand Down
8 changes: 5 additions & 3 deletions app/controllers/activities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class ActivitiesController < ApplicationController
include SeriesHelper
include SetLtiMessage
include Sortable
include HasFilter

INPUT_SERVICE_WORKER = 'inputServiceWorker.js'.freeze

Expand All @@ -19,13 +20,13 @@ class ActivitiesController < ApplicationController

has_scope :by_filter, as: 'filter'
has_scope :by_labels, as: 'labels', type: :array, if: ->(this) { this.params[:labels].is_a?(Array) }
has_scope :by_programming_language, as: 'programming_language'
has_scope :by_type, as: 'type'
has_scope :in_repository, as: 'repository_id'
has_scope :by_description_languages, as: 'description_languages', type: :array
has_scope :by_judge, as: 'judge_id'
has_scope :by_popularities, as: 'popularity', type: :array
has_scope :is_draft, as: 'draft'
has_filter :programming_language, 'red'
has_filter :type, 'deep-purple'
has_filter :judge, 'red'

has_scope :repository_scope, as: 'tab' do |controller, scope, value|
course = Series.find(controller.params[:id]).course if controller.params[:id]
Expand Down Expand Up @@ -74,6 +75,7 @@ def index
end

unless @activities.empty?
@filters = filters(@activities)
@activities = apply_scopes(@activities)
@activities = @activities.paginate(page: parse_pagination_param(params[:page]))
end
Expand Down
27 changes: 27 additions & 0 deletions app/controllers/concerns/has_filter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module HasFilter
extend ActiveSupport::Concern

class_methods do
def has_filter(name, color, multi: false)
if multi
has_scope "by_#{name}", as: name, type: :array

Check warning on line 7 in app/controllers/concerns/has_filter.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/concerns/has_filter.rb#L7

Added line #L7 was not covered by tests
else
has_scope "by_#{name}", as: name
end

@@filters ||= []
@@filters << [name, multi, color]
end
end
def filters(target)
@@filters.map do |filter, multi, color|
params_without_current = params.except(:controller, :action, :page, filter)
{
param: filter,
data: apply_scopes(target, params_without_current).send("#{filter}_filter_options"),
multi: multi,
color: color
}
end
end
end
15 changes: 6 additions & 9 deletions app/models/activity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,13 @@ class Activity < ApplicationRecord
scope :in_repository, ->(repository) { where repository: repository }

scope :by_name, ->(name) { where('name_nl LIKE ? OR name_en LIKE ? OR path LIKE ?', "%#{name}%", "%#{name}%", "%#{name}%") }
scope :by_status, ->(status) { where(status: status.in?(statuses) ? status : -1) }
scope :by_access, ->(access) { where(access: access.in?(accesses) ? access : -1) }
search_by :name_nl, :name_en, :path
filterable_by :status, value_check: ->(value) { value.in? statuses }
filterable_by :access, value_check: ->(value) { value.in? accesses }
filterable_by :programming_language, column: 'programming_languages.name', associations: :programming_language
filterable_by :type, name_hash: ->(value) { { Exercise.name => Exercise.model_name.human, ContentPage.name => ContentPage.model_name.human } }
filterable_by :judge, column: 'judge_id', name_hash: ->(values) { Judge.where(id: values).to_h { |j| [j.id, j.name] } }
scope :by_labels, ->(labels) { includes(:labels).where(labels: { name: labels }).group(:id).having('COUNT(DISTINCT(activity_labels.label_id)) = ?', labels.uniq.length) }
scope :by_programming_language, ->(programming_language) { includes(:programming_language).where(programming_languages: { name: programming_language }) }
scope :by_type, ->(type) { where(type: type) }
scope :by_judge, ->(judge) { where(judge_id: judge) }
scope :is_draft, ->(value = true) { where(draft: value) }
scope :by_description_languages, lambda { |languages|
by_language = all # allow chaining of scopes
Expand Down Expand Up @@ -424,10 +425,6 @@ def safe_destroy
destroy
end

def set_search
self.search = "#{Activity.human_enum_name(:status, status, locale: :nl)} #{Activity.human_enum_name(:status, status, locale: :en)} #{Activity.human_enum_name(:access, access, locale: :en)} #{Activity.human_enum_name(:access, access, locale: :nl)} #{name_nl} #{name_en} #{path}"
end

def self.parse_type(type)
return Exercise.name unless type
return type if types.include?(type)
Expand Down
38 changes: 35 additions & 3 deletions app/models/concerns/filterable.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,40 @@
module Filterable
extend ActiveSupport::Concern

included do
before_save :set_search
scope :by_filter, ->(filter) { filter.split.map(&:strip).select(&:present?).inject(self) { |query, part| query.where("#{table_name}.search LIKE ?", "%#{part}%") } }
class_methods do
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
def search_by(*columns)
define_method(:set_search) do
self.search = columns.map { |column| send(column) || '' }.join(' ')
end

before_save :set_search
scope :by_filter, ->(filter) { filter.split.map(&:strip).select(&:present?).inject(self) { |query, part| query.where("#{table_name}.search LIKE ?", "%#{part}%") } }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does part come from? Can a user inject something here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the query string by the user (so user generated text)
But it is give as the second argument for a where query that uses a ?. When replacing a ? rails will handle the proper escaping to avoid sql injection

end

# Creates a scope for the column, with the name `by_#{column}`
# It also creates a method `#{column}_filter_options` that returns the possible values for the column, with the count of each value
# params:
# +name+:: The name of the scope
# +column+:: The column to create the scope for
# +associations+:: The associations to include in the scope
# +value_check+:: A lambda that must return true for a value, otherwise the scope will return an empty relation
# +name_hash+:: a lambda that takes a list af column values and returns a hash with the human readable name for each column value
def filterable_by(name, column: name, associations: [], value_check: ->(value) { true }, name_hash: ->(values) { values.to_h { |value| [value, value] } })
scope "by_#{name}", lambda { |value|
if value_check.call(value)
includes(associations).where(column => value)

Check warning on line 25 in app/models/concerns/filterable.rb

View check run for this annotation

Codecov / codecov/patch

app/models/concerns/filterable.rb#L24-L25

Added lines #L24 - L25 were not covered by tests
else
none

Check warning on line 27 in app/models/concerns/filterable.rb

View check run for this annotation

Codecov / codecov/patch

app/models/concerns/filterable.rb#L27

Added line #L27 was not covered by tests
end
}

define_singleton_method("#{name}_filter_options") do
count = joins(associations).group(column).count
names = name_hash.call(count.keys)

count.map { |key, value| { id: key.to_s, name: names[key], count: value } }
end

end
end
end
5 changes: 1 addition & 4 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class Course < ApplicationRecord
validate :should_have_institution_when_visible_for_institution
validate :should_have_institution_when_open_for_institution

search_by :name, :teacher, :year
scope :by_name, ->(name) { where('name LIKE ?', "%#{name}%") }
scope :by_teacher, ->(teacher) { where('teacher LIKE ?', "%#{teacher}%") }
scope :by_institution, ->(institution) { where(institution: institution) }
Expand Down Expand Up @@ -413,10 +414,6 @@ def self.format_year(year)
year.sub(/ ?- ?/, '–')
end

def set_search
self.search = "#{teacher || ''} #{name || ''} #{year || ''}"
end

def color
colors = %w[blue-gray orange cyan purple teal pink indigo brown deep-purple]
colors[year.to_i % colors.size]
Expand Down
6 changes: 2 additions & 4 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ class User < ApplicationRecord

accepts_nested_attributes_for :identities, limit: 1

search_by :username, :first_name, :last_name

scope :by_permission, ->(permission) { where(permission: permission) }
scope :by_institution, ->(institution) { where(institution: institution) }

Expand Down Expand Up @@ -384,10 +386,6 @@ def self.from_username_and_institution(username, institution_id)
find_by(username: username, institution_id: institution_id)
end

def set_search
self.search = "#{username || ''} #{first_name || ''} #{last_name || ''}"
end

# Be careful when using force institution. This expects the providers to be updated externally
def merge_into(other, force: false, force_institution: false)
errors.add(:merge, 'User belongs to different institution') if !force_institution && other.institution_id != institution_id && other.institution_id.present? && institution_id.present?
Expand Down
Loading
Loading