motor-admin 0.1.50 → 0.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e5a06d8a42714fbc30464ef704366dc9f3b851df80bd8c9a206a2ed75a6fd23
4
- data.tar.gz: 13d46e2d091be7a9b6e9832ab1e036c08734bbe70b0b92b64f1b41dd0f355a17
3
+ metadata.gz: 0e67ce24b85ae545c7d4f4b2bbda48d660bdcba56849accee1d213ff4c854b3a
4
+ data.tar.gz: 7f53d4751a1f77a5b75cf27f803390972426a10ab4bd66b0a4903bc2daa1bdab
5
5
  SHA512:
6
- metadata.gz: 544edcef7fd6e97e1285fff69f1984a0991dc72a1768ebace132faad1a1b6609e68dd7c0ae4e6d573036c171a1b364a65fc52a9f63e653f51e4f33b8b86281ec
7
- data.tar.gz: 2cb5fb5c3c47710cfea97550e0f0ac67c2463eb3d71a5a62a8ce6fbff62763e04274d92b6c4c6bc51e2f14e2a4fd84a6d9f290b7586fca407b85a1aa254fa47d
6
+ metadata.gz: 1a03dff86d33cebf9b32820e5e9fe3e036be360fe749540fed06b5482205bf33a192e982e026d979d1a2f929383fc46ae0ecfd26fcfc072413fe336915c29141
7
+ data.tar.gz: 6bdf9b79b7fb2c99f2965af0e6651f226673bc3b72dba7b2316855bb064f05aa50ebf45e82d60b1a7da5d4ea228c2f223a365d160837a7ddea0c58f4bfcc6eb6
@@ -12,10 +12,12 @@ module Motor
12
12
  end
13
13
  end
14
14
 
15
- rescue_from StandardError do |e|
16
- Rails.logger.error(e)
15
+ unless Rails.env.test?
16
+ rescue_from StandardError do |e|
17
+ Rails.logger.error(e)
17
18
 
18
- render json: { errors: [e.message] }, status: :internal_server_error
19
+ render json: { errors: [e.message] }, status: :internal_server_error
20
+ end
19
21
  end
20
22
 
21
23
  def current_ability
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Motor
4
4
  class AssetsController < ActionController::Metal
5
- CACHE_STORE = ActiveSupport::Cache::MemoryStore.new(coder: ActiveSupport::Cache::NullCoder)
5
+ CACHE_STORE = ActiveSupport::Cache::MemoryStore.new
6
6
 
7
7
  GZIP_TYPES = [
8
8
  'application/javascript',
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Motor
4
4
  class IconsController < ApiBaseController
5
- CACHE_STORE = ActiveSupport::Cache::MemoryStore.new(coder: ActiveSupport::Cache::NullCoder)
5
+ CACHE_STORE = ActiveSupport::Cache::MemoryStore.new
6
6
 
7
7
  def index
8
8
  data = CACHE_STORE.fetch('icons') do
@@ -9,22 +9,24 @@ module Motor
9
9
  before_action :build_query, only: :create
10
10
  authorize_resource :query, only: :create
11
11
 
12
- rescue_from 'ActiveRecord::StatementInvalid' do |e|
13
- render json: { errors: [{ detail: e.message }] }, status: :unprocessable_entity
14
- end
15
-
16
12
  def show
17
- render json: query_result_hash(query_result)
13
+ render_result
18
14
  end
19
15
 
20
16
  def create
21
- render json: query_result_hash(query_result)
17
+ render_result
22
18
  end
23
19
 
24
20
  private
25
21
 
26
- def query_result
27
- Queries::RunQuery.call(@query, variables_hash: params[:variables])
22
+ def render_result
23
+ query_result = Queries::RunQuery.call(@query, variables_hash: params[:variables])
24
+
25
+ if query_result.error
26
+ render json: { errors: [{ detail: query_result.error }] }, status: :unprocessable_entity
27
+ else
28
+ render json: query_result_hash(query_result)
29
+ end
28
30
  end
29
31
 
30
32
  def query_result_hash(query_result)
@@ -16,7 +16,7 @@ module Motor
16
16
  scope :active, -> { where(deleted_at: nil) }
17
17
 
18
18
  def result(variables_hash = {})
19
- result = Motor::Queries::RunQuery.call(self, variables_hash: variables_hash)
19
+ result = Motor::Queries::RunQuery.call!(self, variables_hash: variables_hash)
20
20
  column_names = result.columns.pluck(:name)
21
21
 
22
22
  result.data.map { |row| column_names.zip(row).to_h }
@@ -1,12 +1,12 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def self.up
3
3
  create_table :motor_queries do |t|
4
- t.column :name, :text, null: false
4
+ t.column :name, :string, null: false
5
5
  t.column :description, :text
6
6
  t.column :sql_body, :text, null: false
7
7
  t.column :preferences, :text, null: false
8
8
  t.column :author_id, :bigint
9
- t.column :author_type, :text
9
+ t.column :author_type, :string
10
10
  t.column :deleted_at, :datetime
11
11
 
12
12
  t.timestamps
@@ -15,16 +15,15 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
15
15
  t.index 'name',
16
16
  name: 'motor_queries_name_unique_index',
17
17
  unique: true,
18
- where: 'deleted_at IS NULL',
19
- length: { name: 255 }
18
+ where: 'deleted_at IS NULL'
20
19
  end
21
20
 
22
21
  create_table :motor_dashboards do |t|
23
- t.column :title, :text, null: false
22
+ t.column :title, :string, null: false
24
23
  t.column :description, :text
25
24
  t.column :preferences, :text, null: false
26
25
  t.column :author_id, :bigint
27
- t.column :author_type, :text
26
+ t.column :author_type, :string
28
27
  t.column :deleted_at, :datetime
29
28
 
30
29
  t.timestamps
@@ -33,18 +32,17 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
33
32
  t.index 'title',
34
33
  name: 'motor_dashboards_title_unique_index',
35
34
  unique: true,
36
- where: 'deleted_at IS NULL',
37
- length: { title: 255 }
35
+ where: 'deleted_at IS NULL'
38
36
  end
39
37
 
40
38
  create_table :motor_forms do |t|
41
- t.column :name, :text, null: false
39
+ t.column :name, :string, null: false
42
40
  t.column :description, :text
43
41
  t.column :api_path, :text, null: false
44
- t.column :http_method, :text, null: false
42
+ t.column :http_method, :string, null: false
45
43
  t.column :preferences, :text, null: false
46
44
  t.column :author_id, :bigint
47
- t.column :author_type, :text
45
+ t.column :author_type, :string
48
46
  t.column :deleted_at, :datetime
49
47
 
50
48
  t.timestamps
@@ -53,12 +51,11 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
53
51
  t.index 'name',
54
52
  name: 'motor_forms_name_unique_index',
55
53
  unique: true,
56
- where: 'deleted_at IS NULL',
57
- length: { name: 255 }
54
+ where: 'deleted_at IS NULL'
58
55
  end
59
56
 
60
57
  create_table :motor_resources do |t|
61
- t.column :name, :text, null: false, index: { unique: true, length: 255 }
58
+ t.column :name, :string, null: false, index: { unique: true }
62
59
  t.column :preferences, :text, null: false
63
60
 
64
61
  t.timestamps
@@ -67,7 +64,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
67
64
  end
68
65
 
69
66
  create_table :motor_configs do |t|
70
- t.column :key, :text, null: false, index: { unique: true, length: 255 }
67
+ t.column :key, :string, null: false, index: { unique: true }
71
68
  t.column :value, :text, null: false
72
69
 
73
70
  t.timestamps
@@ -77,13 +74,13 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
77
74
 
78
75
  create_table :motor_alerts do |t|
79
76
  t.references :query, null: false, foreign_key: { to_table: :motor_queries }, index: true
80
- t.column :name, :text, null: false
77
+ t.column :name, :string, null: false
81
78
  t.column :description, :text
82
79
  t.column :to_emails, :text, null: false
83
80
  t.column :is_enabled, :boolean, null: false, default: true
84
81
  t.column :preferences, :text, null: false
85
82
  t.column :author_id, :bigint
86
- t.column :author_type, :text
83
+ t.column :author_type, :string
87
84
  t.column :deleted_at, :datetime
88
85
 
89
86
  t.timestamps
@@ -92,65 +89,59 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
92
89
  t.index 'name',
93
90
  name: 'motor_alerts_name_unique_index',
94
91
  unique: true,
95
- where: 'deleted_at IS NULL',
96
- length: { name: 255 }
92
+ where: 'deleted_at IS NULL'
97
93
  end
98
94
 
99
95
  create_table :motor_alert_locks do |t|
100
96
  t.references :alert, null: false, foreign_key: { to_table: :motor_alerts }
101
- t.column :lock_timestamp, :text, null: false
97
+ t.column :lock_timestamp, :string, null: false
102
98
 
103
99
  t.timestamps
104
100
 
105
- t.index %i[alert_id lock_timestamp], unique: true, length: { lock_timestamp: 255 }
101
+ t.index %i[alert_id lock_timestamp], unique: true
106
102
  end
107
103
 
108
104
  create_table :motor_tags do |t|
109
- t.column :name, :text, null: false
105
+ t.column :name, :string, null: false
110
106
 
111
107
  t.timestamps
112
108
 
113
109
  t.index 'name',
114
110
  name: 'motor_tags_name_unique_index',
115
- unique: true,
116
- length: { name: 255 }
111
+ unique: true
117
112
  end
118
113
 
119
114
  create_table :motor_taggable_tags do |t|
120
115
  t.references :tag, null: false, foreign_key: { to_table: :motor_tags }, index: true
121
116
  t.column :taggable_id, :bigint, null: false
122
- t.column :taggable_type, :text, null: false
117
+ t.column :taggable_type, :string, null: false
123
118
 
124
119
  t.index %i[taggable_id taggable_type tag_id],
125
120
  name: 'motor_polymorphic_association_tag_index',
126
- unique: true,
127
- length: { taggable_type: 255 }
121
+ unique: true
128
122
  end
129
123
 
130
124
  create_table :motor_audits do |t|
131
125
  t.column :auditable_id, :bigint
132
- t.column :auditable_type, :text
126
+ t.column :auditable_type, :string
133
127
  t.column :associated_id, :bigint
134
- t.column :associated_type, :text
128
+ t.column :associated_type, :string
135
129
  t.column :user_id, :bigint
136
- t.column :user_type, :text
137
- t.column :username, :text
138
- t.column :action, :text
130
+ t.column :user_type, :string
131
+ t.column :username, :string
132
+ t.column :action, :string
139
133
  t.column :audited_changes, :text
140
134
  t.column :version, :bigint, default: 0
141
135
  t.column :comment, :text
142
- t.column :remote_address, :text
143
- t.column :request_uuid, :text
136
+ t.column :remote_address, :string
137
+ t.column :request_uuid, :string
144
138
  t.column :created_at, :datetime
145
139
  end
146
140
 
147
- add_index :motor_audits, %i[auditable_type auditable_id version], name: 'motor_auditable_index',
148
- length: { auditable_type: 255 }
149
- add_index :motor_audits, %i[associated_type associated_id], name: 'motor_auditable_associated_index',
150
- length: { associated_type: 255 }
151
- add_index :motor_audits, %i[user_id user_type], name: 'motor_auditable_user_index',
152
- length: { user_type: 255 }
153
- add_index :motor_audits, :request_uuid, length: { request_uuid: 255 }
141
+ add_index :motor_audits, %i[auditable_type auditable_id version], name: 'motor_auditable_index'
142
+ add_index :motor_audits, %i[associated_type associated_id], name: 'motor_auditable_associated_index'
143
+ add_index :motor_audits, %i[user_id user_type], name: 'motor_auditable_user_index'
144
+ add_index :motor_audits, :request_uuid
154
145
  add_index :motor_audits, :created_at
155
146
  end
156
147
 
@@ -20,3 +20,4 @@ require_relative './active_record_utils/fetch_methods'
20
20
  require_relative './active_record_utils/defined_scopes_extension'
21
21
  require_relative './active_record_utils/active_storage_links_extension'
22
22
  require_relative './active_record_utils/active_storage_blob_patch'
23
+ require_relative './active_record_utils/active_record_filter'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Filter.module_eval do
4
+ def filters
5
+ @filters ||= HashWithIndifferentAccess.new
6
+ end
7
+ end
@@ -13,6 +13,10 @@ module Motor
13
13
  super(**hash.with_indifferent_access.slice(*KEYWORD_ARGS).symbolize_keys)
14
14
  end
15
15
 
16
+ def create_after_upload!(hash)
17
+ super(**hash.with_indifferent_access.slice(*KEYWORD_ARGS).symbolize_keys)
18
+ end
19
+
16
20
  def create_after_unfurling!(hash)
17
21
  super(**hash.with_indifferent_access.slice(*KEYWORD_ARGS).symbolize_keys)
18
22
  end
data/lib/motor/admin.rb CHANGED
@@ -43,7 +43,7 @@ module Motor
43
43
  end
44
44
 
45
45
  initializer 'motor.alerts.scheduler' do
46
- config.after_initialize do |_app|
46
+ config.after_initialize do
47
47
  next unless Motor.server?
48
48
 
49
49
  Motor::Alerts::Scheduler::SCHEDULER_TASK.execute
@@ -66,11 +66,10 @@ module Motor
66
66
  end
67
67
 
68
68
  initializer 'motor.active_storage.extensions' do
69
- ActiveSupport.on_load(:active_storage_attachment) do
70
- ActiveStorage::Attachment.include(Motor::ActiveRecordUtils::ActiveStorageLinksExtension)
71
- end
69
+ config.after_initialize do
70
+ next unless defined?(ActiveStorage::Engine)
72
71
 
73
- ActiveSupport.on_load(:active_storage_blob) do
72
+ ActiveStorage::Attachment.include(Motor::ActiveRecordUtils::ActiveStorageLinksExtension)
74
73
  ActiveStorage::Blob.singleton_class.prepend(Motor::ActiveRecordUtils::ActiveStorageBlobPatch)
75
74
  end
76
75
  end
@@ -3,8 +3,7 @@
3
3
  module Motor
4
4
  module Alerts
5
5
  module ScheduledAlertsCache
6
- CACHE_STORE = ActiveSupport::Cache::MemoryStore.new(size: 5.megabytes,
7
- coder: ActiveSupport::Cache::NullCoder)
6
+ CACHE_STORE = ActiveSupport::Cache::MemoryStore.new(size: 5.megabytes)
8
7
 
9
8
  module_function
10
9
 
@@ -13,7 +13,9 @@ module Motor
13
13
  arel_order = build_arel_order(rel.klass, param)
14
14
  join_params = build_join_params(rel.klass, param)
15
15
 
16
- rel.reorder(arel_order).left_joins(join_params)
16
+ rel = rel.left_joins(join_params) if join_params.present?
17
+
18
+ rel.reorder(arel_order)
17
19
  end
18
20
 
19
21
  def build_join_params(_model, param)
data/lib/motor/assets.rb CHANGED
@@ -35,6 +35,8 @@ module Motor
35
35
  def load_from_disk(filename, gzip:)
36
36
  filename += '.gz' if gzip
37
37
 
38
+ raise InvalidPathError if filename.include?('..')
39
+
38
40
  path = ASSETS_PATH.join(filename)
39
41
 
40
42
  raise InvalidPathError unless path.to_s.starts_with?(ASSETS_PATH.to_s)
@@ -7,8 +7,7 @@ module Motor
7
7
  if Motor.development?
8
8
  ActiveSupport::Cache::NullStore.new
9
9
  else
10
- ActiveSupport::Cache::MemoryStore.new(size: 5.megabytes,
11
- coder: ActiveSupport::Cache::NullCoder)
10
+ ActiveSupport::Cache::MemoryStore.new(size: 5.megabytes)
12
11
  end
13
12
 
14
13
  module_function
@@ -3,8 +3,7 @@
3
3
  module Motor
4
4
  module Configs
5
5
  module LoadFromCache
6
- CACHE_STORE = ActiveSupport::Cache::MemoryStore.new(size: 10.megabytes,
7
- coder: ActiveSupport::Cache::NullCoder)
6
+ CACHE_HASH = HashWithIndifferentAccess.new
8
7
 
9
8
  module_function
10
9
 
@@ -57,10 +56,18 @@ module Motor
57
56
  end
58
57
  end
59
58
 
60
- def maybe_fetch_from_cache(type, cache_key, &block)
59
+ def maybe_fetch_from_cache(type, cache_key)
61
60
  return yield unless cache_key
62
61
 
63
- CACHE_STORE.fetch(type + cache_key.to_s, &block)
62
+ if CACHE_HASH[type] && CACHE_HASH[type][:key] == cache_key
63
+ CACHE_HASH[type][:value]
64
+ else
65
+ result = yield
66
+
67
+ CACHE_HASH[type] = { key: cache_key, value: result }
68
+
69
+ result
70
+ end
64
71
  end
65
72
 
66
73
  def load_cache_keys
@@ -5,19 +5,24 @@ module Motor
5
5
  module RunQuery
6
6
  DEFAULT_LIMIT = 100_000
7
7
 
8
- QueryResult = Struct.new(:data, :columns, keyword_init: true)
8
+ QueryResult = Struct.new(:data, :columns, :error, keyword_init: true)
9
+
10
+ WITH_STATEMENT_START = 'WITH __query__ AS ('
9
11
 
10
12
  WITH_STATEMENT_TEMPLATE = <<~SQL
11
- WITH __query__ AS (%<sql_body>s) SELECT * FROM __query__ LIMIT %<limit>s;
13
+ #{WITH_STATEMENT_START}%<sql_body>s
14
+ ) SELECT * FROM __query__ LIMIT %<limit>s;
12
15
  SQL
13
16
 
17
+ PG_ERROR_REGEXP = /\APG.+ERROR:/.freeze
18
+
14
19
  module_function
15
20
 
16
21
  # @param query [Motor::Query]
17
22
  # @param variables_hash [Hash]
18
23
  # @param limit [Integer]
19
24
  # @return [Motor::Queries::RunQuery::QueryResult]
20
- def call(query, variables_hash: nil, limit: DEFAULT_LIMIT)
25
+ def call!(query, variables_hash: nil, limit: DEFAULT_LIMIT)
21
26
  variables_hash ||= {}
22
27
 
23
28
  result = execute_query(query, limit, variables_hash)
@@ -25,6 +30,22 @@ module Motor
25
30
  QueryResult.new(data: result.rows, columns: build_columns_hash(result))
26
31
  end
27
32
 
33
+ # @param query [Motor::Query]
34
+ # @param variables_hash [Hash]
35
+ # @param limit [Integer]
36
+ # @return [Motor::Queries::RunQuery::QueryResult]
37
+ def call(query, variables_hash: nil, limit: DEFAULT_LIMIT)
38
+ call!(query, variables_hash: variables_hash, limit: limit)
39
+ rescue ActiveRecord::StatementInvalid => e
40
+ QueryResult.new(error: build_error_message(e))
41
+ end
42
+
43
+ # @param exception [ActiveRecord::StatementInvalid]
44
+ # @return [String]
45
+ def build_error_message(exception)
46
+ exception.message.sub(WITH_STATEMENT_START, '').sub(PG_ERROR_REGEXP, '').strip.upcase_first
47
+ end
48
+
28
49
  # @param query [Motor::Query]
29
50
  # @param limit [Integer]
30
51
  # @param variables_hash [Hash]
data/lib/motor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Motor
4
- VERSION = '0.1.50'
4
+ VERSION = '0.1.55'
5
5
  end
@@ -2068,11 +2068,11 @@
2068
2068
  "mail-opened.svg": "icons/mail-opened.svg",
2069
2069
  "mail.svg": "icons/mail.svg",
2070
2070
  "mailbox.svg": "icons/mailbox.svg",
2071
- "main-55914a490baf8f7eba59.css.gz": "main-55914a490baf8f7eba59.css.gz",
2072
- "main-55914a490baf8f7eba59.js.LICENSE.txt": "main-55914a490baf8f7eba59.js.LICENSE.txt",
2073
- "main-55914a490baf8f7eba59.js.gz": "main-55914a490baf8f7eba59.js.gz",
2074
- "main.css": "main-55914a490baf8f7eba59.css",
2075
- "main.js": "main-55914a490baf8f7eba59.js",
2071
+ "main-4d659b311d92611ad5f6.css.gz": "main-4d659b311d92611ad5f6.css.gz",
2072
+ "main-4d659b311d92611ad5f6.js.LICENSE.txt": "main-4d659b311d92611ad5f6.js.LICENSE.txt",
2073
+ "main-4d659b311d92611ad5f6.js.gz": "main-4d659b311d92611ad5f6.js.gz",
2074
+ "main.css": "main-4d659b311d92611ad5f6.css",
2075
+ "main.js": "main-4d659b311d92611ad5f6.js",
2076
2076
  "man.svg": "icons/man.svg",
2077
2077
  "manual-gearbox.svg": "icons/manual-gearbox.svg",
2078
2078
  "map-2.svg": "icons/map-2.svg",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: motor-admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.50
4
+ version: 0.1.55
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pete Matsyburka
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-05-28 00:00:00.000000000 Z
11
+ date: 2021-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord-filter
@@ -108,7 +108,10 @@ dependencies:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
110
  version: '5.2'
111
- description: Admin UI and Business Analytics.
111
+ description: |
112
+ Motor Admin allows to create a flexible admin panel with writing less code.
113
+ All customizations to the admin panel can be made directly in the UI without
114
+ the need of writing any configurations code.
112
115
  email:
113
116
  - pete.matsy@gmail.com
114
117
  executables: []
@@ -165,6 +168,7 @@ files:
165
168
  - lib/motor-admin.rb
166
169
  - lib/motor.rb
167
170
  - lib/motor/active_record_utils.rb
171
+ - lib/motor/active_record_utils/active_record_filter.rb
168
172
  - lib/motor/active_record_utils/active_storage_blob_patch.rb
169
173
  - lib/motor/active_record_utils/active_storage_links_extension.rb
170
174
  - lib/motor/active_record_utils/defined_scopes_extension.rb
@@ -1481,8 +1485,8 @@ files:
1481
1485
  - ui/dist/icons/zoom-money.svg.gz
1482
1486
  - ui/dist/icons/zoom-out.svg.gz
1483
1487
  - ui/dist/icons/zoom-question.svg.gz
1484
- - ui/dist/main-55914a490baf8f7eba59.css.gz
1485
- - ui/dist/main-55914a490baf8f7eba59.js.gz
1488
+ - ui/dist/main-4d659b311d92611ad5f6.css.gz
1489
+ - ui/dist/main-4d659b311d92611ad5f6.js.gz
1486
1490
  - ui/dist/manifest.json
1487
1491
  homepage:
1488
1492
  licenses:
@@ -1506,5 +1510,5 @@ requirements: []
1506
1510
  rubygems_version: 3.2.3
1507
1511
  signing_key:
1508
1512
  specification_version: 4
1509
- summary: Admin UI and Business Analytics
1513
+ summary: Low-code Admin panel and Business intelligence
1510
1514
  test_files: []