diff --git a/Gemfile b/Gemfile index e8639a5bd..937aef4af 100644 --- a/Gemfile +++ b/Gemfile @@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5' # facebook client gem 'koala' # slack client -gem 'slack-ruby-client', '~> 2.5.1' +gem 'slack-ruby-client', '~> 2.5.2' # for dialogflow integrations gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'grpc' diff --git a/Gemfile.lock b/Gemfile.lock index abb007178..e9a573816 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -747,7 +747,7 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slack-ruby-client (2.5.1) + slack-ruby-client (2.5.2) faraday (>= 2.0) faraday-mashify faraday-multipart @@ -954,7 +954,7 @@ DEPENDENCIES sidekiq (>= 7.3.1) sidekiq-cron (>= 1.12.0) simplecov (= 0.17.1) - slack-ruby-client (~> 2.5.1) + slack-ruby-client (~> 2.5.2) spring spring-watcher-listen squasher diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index f63e8f6bf..0f915ab8a 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -36,7 +36,7 @@ class DashboardController < ActionController::Base 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', 'DEPLOYMENT_ENV', - 'CSML_EDITOR_HOST' + 'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index 515c82000..c24e65e68 100644 --- a/app/helpers/super_admin/account_features_helper.rb +++ b/app/helpers/super_admin/account_features_helper.rb @@ -7,13 +7,46 @@ module SuperAdmin::AccountFeaturesHelper account_features.filter { |feature| feature['premium'] }.pluck('name') end - # Accepts account.features as argument - def self.filtered_features(features) - deployment_env = GlobalConfig.get_value('DEPLOYMENT_ENV') - return features if deployment_env == 'cloud' + # Returns a hash mapping feature names to their display names + def self.feature_display_names + account_features.each_with_object({}) do |feature, hash| + hash[feature['name']] = feature['display_name'] + end + end + + def self.filter_internal_features(features) + return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' - # Filter out internal features for non-cloud environments internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name') features.except(*internal_features) end + + def self.filter_deprecated_features(features) + deprecated_features = account_features.select { |f| f['deprecated'] }.pluck('name') + features.except(*deprecated_features) + end + + def self.sort_and_transform_features(features, display_names) + features.sort_by { |key, _| display_names[key] || key } + .to_h + .transform_keys { |key| [key, display_names[key]] } + end + + def self.partition_features(features) + filtered = filter_internal_features(features) + filtered = filter_deprecated_features(filtered) + display_names = feature_display_names + + regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) } + + [ + sort_and_transform_features(regular, display_names), + sort_and_transform_features(premium, display_names) + ] + end + + def self.filtered_features(features) + regular, premium = partition_features(features) + regular.merge(premium) + end end diff --git a/app/javascript/dashboard/assets/scss/super_admin/index.scss b/app/javascript/dashboard/assets/scss/super_admin/index.scss index 91f7835d0..ddac5f7c8 100644 --- a/app/javascript/dashboard/assets/scss/super_admin/index.scss +++ b/app/javascript/dashboard/assets/scss/super_admin/index.scss @@ -6,3 +6,209 @@ body { font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } + +@layer base { + // FIXME: Use a common color file for all packs + // scss-lint:disable PropertySortOrder + :root { + --slate-1: 252 252 253; + --slate-2: 249 249 251; + --slate-3: 240 240 243; + --slate-4: 232 232 236; + --slate-5: 224 225 230; + --slate-6: 217 217 224; + --slate-7: 205 206 214; + --slate-8: 185 187 198; + --slate-9: 139 141 152; + --slate-10: 128 131 141; + --slate-11: 96 100 108; + --slate-12: 28 32 36; + + --iris-1: 253 253 255; + --iris-2: 248 248 255; + --iris-3: 240 241 254; + --iris-4: 230 231 255; + --iris-5: 218 220 255; + --iris-6: 203 205 255; + --iris-7: 184 186 248; + --iris-8: 155 158 240; + --iris-9: 91 91 214; + --iris-10: 81 81 205; + --iris-11: 87 83 198; + --iris-12: 39 41 98; + + --ruby-1: 255 252 253; + --ruby-2: 255 247 248; + --ruby-3: 254 234 237; + --ruby-4: 255 220 225; + --ruby-5: 255 206 214; + --ruby-6: 248 191 200; + --ruby-7: 239 172 184; + --ruby-8: 229 146 163; + --ruby-9: 229 70 102; + --ruby-10: 220 59 93; + --ruby-11: 202 36 77; + --ruby-12: 100 23 43; + + --amber-1: 254 253 251; + --amber-2: 254 251 233; + --amber-3: 255 247 194; + --amber-4: 255 238 156; + --amber-5: 251 229 119; + --amber-6: 243 214 115; + --amber-7: 233 193 98; + --amber-8: 226 163 54; + --amber-9: 255 197 61; + --amber-10: 255 186 24; + --amber-11: 171 100 0; + --amber-12: 79 52 34; + + --teal-1: 250 254 253; + --teal-2: 243 251 249; + --teal-3: 224 248 243; + --teal-4: 204 243 234; + --teal-5: 184 234 224; + --teal-6: 161 222 210; + --teal-7: 131 205 193; + --teal-8: 83 185 171; + --teal-9: 18 165 148; + --teal-10: 13 155 138; + --teal-11: 0 133 115; + --teal-12: 13 61 56; + + --gray-1: 252 252 252; + --gray-2: 249 249 249; + --gray-3: 240 240 240; + --gray-4: 232 232 232; + --gray-5: 224 224 224; + --gray-6: 217 217 217; + --gray-7: 206 206 206; + --gray-8: 187 187 187; + --gray-9: 141 141 141; + --gray-10: 131 131 131; + --gray-11: 100 100 100; + --gray-12: 32 32 32; + + --background-color: 253 253 253; + --text-blue: 8 109 224; + --border-container: 236 236 236; + --border-strong: 235 235 235; + --border-weak: 234 234 234; + --solid-1: 255 255 255; + --solid-2: 255 255 255; + --solid-3: 255 255 255; + --solid-active: 255 255 255; + --solid-amber: 252 232 193; + --solid-blue: 218 236 255; + --solid-iris: 230 231 255; + + --alpha-1: 67, 67, 67, 0.06; + --alpha-2: 201, 202, 207, 0.15; + --alpha-3: 255, 255, 255, 0.96; + --black-alpha-1: 0, 0, 0, 0.12; + --black-alpha-2: 0, 0, 0, 0.04; + --border-blue: 39, 129, 246, 0.5; + --white-alpha: 255, 255, 255, 0.8; + } + + .dark { + --slate-1: 17 17 19; + --slate-2: 24 25 27; + --slate-3: 33 34 37; + --slate-4: 39 42 45; + --slate-5: 46 49 53; + --slate-6: 54 58 63; + --slate-7: 67 72 78; + --slate-8: 90 97 105; + --slate-9: 105 110 119; + --slate-10: 119 123 132; + --slate-11: 176 180 186; + --slate-12: 237 238 240; + + --iris-1: 19 19 30; + --iris-2: 23 22 37; + --iris-3: 32 34 72; + --iris-4: 38 42 101; + --iris-5: 48 51 116; + --iris-6: 61 62 130; + --iris-7: 74 74 149; + --iris-8: 89 88 177; + --iris-9: 91 91 214; + --iris-10: 84 114 228; + --iris-11: 158 177 255; + --iris-12: 224 223 254; + + --ruby-1: 25 17 19; + --ruby-2: 30 21 23; + --ruby-3: 58 20 30; + --ruby-4: 78 19 37; + --ruby-5: 94 26 46; + --ruby-6: 111 37 57; + --ruby-7: 136 52 71; + --ruby-8: 179 68 90; + --ruby-9: 229 70 102; + --ruby-10: 236 90 114; + --ruby-11: 255 148 157; + --ruby-12: 254 210 225; + + --amber-1: 22 18 12; + --amber-2: 29 24 15; + --amber-3: 48 32 8; + --amber-4: 63 39 0; + --amber-5: 77 48 0; + --amber-6: 92 61 5; + --amber-7: 113 79 25; + --amber-8: 143 100 36; + --amber-9: 255 197 61; + --amber-10: 255 214 10; + --amber-11: 255 202 22; + --amber-12: 255 231 179; + + --teal-1: 13 21 20; + --teal-2: 17 28 27; + --teal-3: 13 45 42; + --teal-4: 2 59 55; + --teal-5: 8 72 67; + --teal-6: 20 87 80; + --teal-7: 28 105 97; + --teal-8: 32 126 115; + --teal-9: 18 165 148; + --teal-10: 14 179 158; + --teal-11: 11 216 182; + --teal-12: 173 240 221; + + --gray-1: 17 17 17; + --gray-2: 25 25 25; + --gray-3: 34 34 34; + --gray-4: 42 42 42; + --gray-5: 49 49 49; + --gray-6: 58 58 58; + --gray-7: 72 72 72; + --gray-8: 96 96 96; + --gray-9: 110 110 110; + --gray-10: 123 123 123; + --gray-11: 180 180 180; + --gray-12: 238 238 238; + + --background-color: 18 18 19; + --border-strong: 52 52 52; + --border-weak: 38 38 42; + --solid-1: 23 23 26; + --solid-2: 29 30 36; + --solid-3: 44 45 54; + --solid-active: 53 57 66; + --solid-amber: 42 37 30; + --solid-blue: 16 49 91; + --solid-iris: 38 42 101; + --text-blue: 126 182 255; + + --alpha-1: 36, 36, 36, 0.8; + --alpha-2: 139, 147, 182, 0.15; + --alpha-3: 36, 38, 45, 0.9; + --black-alpha-1: 0, 0, 0, 0.3; + --black-alpha-2: 0, 0, 0, 0.2; + --border-blue: 39, 129, 246, 0.5; + --border-container: 236, 236, 236, 0; + --white-alpha: 255, 255, 255, 0.1; + } +} diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue index 8adcce6b2..2cee66d12 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue @@ -50,6 +50,7 @@ const { t } = useI18n();
- +
value.every(option => 'value' in option && 'label' in option), }, - placeholder: { - type: String, - default: '', - }, - modelValue: { - type: [String, Number], - default: '', - }, - disabled: { - type: Boolean, - default: false, - }, - searchPlaceholder: { - type: String, - default: '', - }, - emptyState: { - type: String, - default: '', - }, - message: { - type: String, - default: '', - }, - hasError: { - type: Boolean, - default: false, - }, + placeholder: { type: String, default: '' }, + modelValue: { type: [String, Number], default: '' }, + disabled: { type: Boolean, default: false }, + searchPlaceholder: { type: String, default: '' }, + emptyState: { type: String, default: '' }, + message: { type: String, default: '' }, + hasError: { type: Boolean, default: false }, + useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API }); const emit = defineEmits(['update:modelValue', 'search']); @@ -54,6 +34,12 @@ const dropdownRef = ref(null); const comboboxRef = ref(null); const filteredOptions = computed(() => { + // For API search, don't filter options locally + if (props.useApiResults && search.value) { + return props.options; + } + + // For local search, filter options based on search term const searchTerm = search.value.toLowerCase(); return props.options.filter(option => option.label.toLowerCase().includes(searchTerm) diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index ed00e4623..9dd73e7e4 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -240,24 +240,20 @@ const menuItems = computed(() => { name: 'Captain', icon: 'i-woot-captain', label: t('SIDEBAR.CAPTAIN'), - showOnlyOnCloud: true, children: [ { name: 'Assistants', label: t('SIDEBAR.CAPTAIN_ASSISTANTS'), - showOnlyOnCloud: true, to: accountScopedRoute('captain_assistants_index'), }, { name: 'Documents', label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), - showOnlyOnCloud: true, to: accountScopedRoute('captain_documents_index'), }, { name: 'Responses', label: t('SIDEBAR.CAPTAIN_RESPONSES'), - showOnlyOnCloud: true, to: accountScopedRoute('captain_responses_index'), }, ], @@ -509,7 +505,6 @@ const menuItems = computed(() => { name: 'Settings Billing', label: t('SIDEBAR.BILLING'), icon: 'i-lucide-credit-card', - showOnlyOnCloud: true, to: accountScopedRoute('billing_settings_index'), }, ], diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue index 64345c7de..6e211b132 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroup.vue @@ -24,7 +24,6 @@ const { resolvePath, resolvePermissions, resolveFeatureFlag, - isOnChatwootCloud, isAllowed, } = useSidebarContext(); @@ -43,7 +42,6 @@ const hasChildren = computed( const accessibleItems = computed(() => { if (!hasChildren.value) return []; return props.children.filter(child => { - if (child.showOnlyOnCloud && !isOnChatwootCloud.value) return false; // If a item has no link, it means it's just a subgroup header // So we don't need to check for permissions here, because there's nothing to // access here anyway @@ -166,7 +164,7 @@ onMounted(async () => { :active-child="activeChild" /> { - if (props.showOnlyOnCloud && !isOnChatwootCloud.value) return false; - - return true; -}); +const { resolvePermissions, resolveFeatureFlag } = useSidebarContext(); const shouldRenderComponent = computed(() => { return typeof props.component === 'function' || isVNode(props.component); @@ -30,7 +22,6 @@ const shouldRenderComponent = computed(() => { diff --git a/app/javascript/dashboard/components/policy.vue b/app/javascript/dashboard/components/policy.vue index bafe4caa7..5e0aa9d54 100644 --- a/app/javascript/dashboard/components/policy.vue +++ b/app/javascript/dashboard/components/policy.vue @@ -15,17 +15,22 @@ const props = defineProps({ type: String, default: null, }, + installationTypes: { + type: Array, + default: null, + }, }); -const { checkFeatureAllowed, checkPermissions } = usePolicy(); +const { shouldShow } = usePolicy(); -const isFeatureAllowed = computed(() => checkFeatureAllowed(props.featureFlag)); -const hasPermission = computed(() => checkPermissions(props.permissions)); +const show = computed(() => + shouldShow(props.featureFlag, props.permissions, props.installationTypes) +); diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.vue b/app/javascript/dashboard/components/widgets/Thumbnail.vue index 7b222aa7e..5998dd338 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.vue +++ b/app/javascript/dashboard/components/widgets/Thumbnail.vue @@ -156,13 +156,14 @@ export default { Badge
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 5d516e111..9c04232e2 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -127,7 +127,7 @@ export default { const uploadRef = ref(false); const keyboardEvents = { - 'Alt+KeyA': { + '$mod+Alt+KeyA': { action: () => { // TODO: This is really hacky, we need to replace the file picker component with // a custom one, where the logic and the component markup is isolated. diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 7e8ad507c..690259a5e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -75,6 +75,7 @@ export default { 'assignLabel', 'assignTeam', 'markAsUnread', + 'markAsRead', 'assignPriority', 'updateConversationStatus', ], @@ -95,9 +96,6 @@ export default { activeInbox: 'getSelectedInbox', accountId: 'getCurrentAccountId', }), - bulkActionCheck() { - return !this.hideThumbnail && !this.hovered && !this.selected; - }, chatMetadata() { return this.chat.meta || {}; }, @@ -182,10 +180,10 @@ export default { router.push({ path }); }, - onCardHover() { + onThumbnailHover() { this.hovered = !this.hideThumbnail; }, - onCardLeave() { + onThumbnailLeave() { this.hovered = false; }, onSelectConversation(checked) { @@ -231,6 +229,10 @@ export default { this.$emit('markAsUnread', this.chat.id); this.closeContextMenu(); }, + async markAsRead() { + this.$emit('markAsRead', this.chat.id); + this.closeContextMenu(); + }, async assignPriority(priority) { this.$emit('assignPriority', priority, this.chat.id); this.closeContextMenu(); @@ -249,28 +251,36 @@ export default { 'has-inbox-name': showInboxName, 'conversation-selected': selected, }" - @mouseenter="onCardHover" - @mouseleave="onCardLeave" @click="onCardClick" @contextmenu="openContextMenu($event)" > - - +
@@ -351,6 +361,7 @@ export default { @assign-label="onAssignLabel" @assign-team="onAssignTeam" @mark-as-unread="markAsUnread" + @mark-as-read="markAsRead" @assign-priority="assignPriority" /> @@ -400,7 +411,7 @@ export default { } .checkbox-wrapper { - @apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4 hover:bg-woot-100 dark:hover:bg-woot-800; + @apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4; input[type='checkbox'] { @apply m-0 cursor-pointer; diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue index 8778fc461..fccff3457 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue @@ -41,6 +41,7 @@ export default { 'updateConversation', 'assignPriority', 'markAsUnread', + 'markAsRead', 'assignAgent', 'assignTeam', 'assignLabel', @@ -48,6 +49,10 @@ export default { data() { return { STATUS_TYPE: wootConstants.STATUS_TYPE, + readOption: { + label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'), + icon: 'mail', + }, unreadOption: { label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'), icon: 'mail', @@ -58,16 +63,16 @@ export default { label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'), icon: 'checkmark', }, - { - key: wootConstants.STATUS_TYPE.PENDING, - label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'), - icon: 'book-clock', - }, { key: wootConstants.STATUS_TYPE.OPEN, label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'), icon: 'arrow-redo', }, + { + key: wootConstants.STATUS_TYPE.PENDING, + label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'), + icon: 'book-clock', + }, ], snoozeOption: { key: wootConstants.STATUS_TYPE.SNOOZED, @@ -203,6 +208,13 @@ export default { variant="icon" @click.stop="$emit('markAsUnread')" /> + +