diff --git a/app/javascript/dashboard/api/companies.js b/app/javascript/dashboard/api/companies.js new file mode 100644 index 000000000..090b530c4 --- /dev/null +++ b/app/javascript/dashboard/api/companies.js @@ -0,0 +1,37 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildCompanyParams = (page, sort) => { + let params = `page=${page}`; + if (sort) { + params = `${params}&sort=${sort}`; + } + return params; +}; + +export const buildSearchParams = (query, page, sort) => { + let params = `q=${encodeURIComponent(query)}&page=${page}`; + if (sort) { + params = `${params}&sort=${sort}`; + } + return params; +}; + +class CompanyAPI extends ApiClient { + constructor() { + super('companies', { accountScoped: true }); + } + + get(params = {}) { + const { page = 1, sort = 'name' } = params; + const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`; + return axios.get(requestURL); + } + + search(query = '', page = 1, sort = 'name') { + const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`; + return axios.get(requestURL); + } +} + +export default new CompanyAPI(); diff --git a/app/javascript/dashboard/api/specs/companies.spec.js b/app/javascript/dashboard/api/specs/companies.spec.js new file mode 100644 index 000000000..82fdc1c97 --- /dev/null +++ b/app/javascript/dashboard/api/specs/companies.spec.js @@ -0,0 +1,142 @@ +import companyAPI, { + buildCompanyParams, + buildSearchParams, +} from '../companies'; +import ApiClient from '../ApiClient'; + +describe('#CompanyAPI', () => { + it('creates correct instance', () => { + expect(companyAPI).toBeInstanceOf(ApiClient); + expect(companyAPI).toHaveProperty('get'); + expect(companyAPI).toHaveProperty('show'); + expect(companyAPI).toHaveProperty('create'); + expect(companyAPI).toHaveProperty('update'); + expect(companyAPI).toHaveProperty('delete'); + expect(companyAPI).toHaveProperty('search'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + get: vi.fn(() => Promise.resolve()), + patch: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#get with default params', () => { + companyAPI.get({}); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=1&sort=name' + ); + }); + + it('#get with page and sort params', () => { + companyAPI.get({ page: 2, sort: 'domain' }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=2&sort=domain' + ); + }); + + it('#get with descending sort', () => { + companyAPI.get({ page: 1, sort: '-created_at' }); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies?page=1&sort=-created_at' + ); + }); + + it('#search with query', () => { + companyAPI.search('acme', 1, 'name'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=acme&page=1&sort=name' + ); + }); + + it('#search with special characters in query', () => { + companyAPI.search('acme & co', 2, 'domain'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain' + ); + }); + + it('#search with descending sort', () => { + companyAPI.search('test', 1, '-created_at'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=test&page=1&sort=-created_at' + ); + }); + + it('#search with empty query', () => { + companyAPI.search('', 1, 'name'); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/companies/search?q=&page=1&sort=name' + ); + }); + }); +}); + +describe('#buildCompanyParams', () => { + it('returns correct string with page only', () => { + expect(buildCompanyParams(1)).toBe('page=1'); + }); + + it('returns correct string with page and sort', () => { + expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name'); + }); + + it('returns correct string with different page', () => { + expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain'); + }); + + it('returns correct string with descending sort', () => { + expect(buildCompanyParams(1, '-created_at')).toBe( + 'page=1&sort=-created_at' + ); + }); + + it('returns correct string without sort parameter', () => { + expect(buildCompanyParams(2, '')).toBe('page=2'); + }); +}); + +describe('#buildSearchParams', () => { + it('returns correct string with all parameters', () => { + expect(buildSearchParams('acme', 1, 'name')).toBe( + 'q=acme&page=1&sort=name' + ); + }); + + it('returns correct string with special characters', () => { + expect(buildSearchParams('acme & co', 2, 'domain')).toBe( + 'q=acme%20%26%20co&page=2&sort=domain' + ); + }); + + it('returns correct string with empty query', () => { + expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name'); + }); + + it('returns correct string without sort parameter', () => { + expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1'); + }); + + it('returns correct string with descending sort', () => { + expect(buildSearchParams('company', 3, '-created_at')).toBe( + 'q=company&page=3&sort=-created_at' + ); + }); + + it('encodes special characters correctly', () => { + expect(buildSearchParams('test@example.com', 1, 'name')).toBe( + 'q=test%40example.com&page=1&sort=name' + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue new file mode 100644 index 000000000..d5f191733 --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue @@ -0,0 +1,95 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue new file mode 100644 index 000000000..f0dc4255f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue @@ -0,0 +1,55 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue new file mode 100644 index 000000000..768123b9f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue new file mode 100644 index 000000000..430a2b835 --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index f5725d2e1..cd4309e6e 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -351,6 +351,23 @@ const menuItems = computed(() => { }, ], }, + { + name: 'Companies', + label: t('SIDEBAR.COMPANIES'), + icon: 'i-lucide-building-2', + children: [ + { + name: 'All Companies', + label: t('SIDEBAR.ALL_COMPANIES'), + to: accountScopedRoute( + 'companies_dashboard_index', + {}, + { page: 1, search: undefined } + ), + activeOn: ['companies_dashboard_index'], + }, + ], + }, { name: 'Reports', label: t('SIDEBAR.REPORTS'), diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 87227e74b..0508826d6 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -41,6 +41,7 @@ export const FEATURE_FLAGS = { CAPTAIN_V2: 'captain_integration_v2', SAML: 'saml', QUOTED_EMAIL_REPLY: 'quoted_email_reply', + COMPANIES: 'companies', }; export const PREMIUM_FEATURES = [ diff --git a/app/javascript/dashboard/i18n/locale/en/companies.json b/app/javascript/dashboard/i18n/locale/en/companies.json new file mode 100644 index 000000000..2c491fa7d --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/companies.json @@ -0,0 +1,32 @@ +{ + "COMPANIES": { + "HEADER": "Companies", + "SORT_BY": { + "LABEL": "Sort by", + "OPTIONS": { + "NAME": "Name", + "DOMAIN": "Domain", + "CREATED_AT": "Created at" + } + }, + "ORDER": { + "LABEL": "Order", + "OPTIONS": { + "ASCENDING": "Ascending", + "DESCENDING": "Descending" + } + }, + "SEARCH_PLACEHOLDER": "Search companies...", + "LOADING": "Loading companies...", + "UNNAMED": "Unnamed Company", + "CONTACTS_COUNT": "{count} contacts", + "EMPTY_STATE": { + "TITLE": "No companies found" + } + }, + "COMPANIES_LAYOUT": { + "PAGINATION_FOOTER": { + "SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies" + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index e93dcd88e..17121fc61 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -8,6 +8,7 @@ import bulkActions from './bulkActions.json'; import campaign from './campaign.json'; import cannedMgmt from './cannedMgmt.json'; import chatlist from './chatlist.json'; +import companies from './companies.json'; import components from './components.json'; import contact from './contact.json'; import contactFilters from './contactFilters.json'; @@ -49,6 +50,7 @@ export default { ...campaign, ...cannedMgmt, ...chatlist, + ...companies, ...components, ...contact, ...contactFilters, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 4553572cb..b53bfed7b 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -306,6 +306,8 @@ "SETTINGS": "Settings", "CONTACTS": "Contacts", "ACTIVE": "Active", + "COMPANIES": "Companies", + "ALL_COMPANIES": "All Companies", "CAPTAIN": "Captain", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/routes/dashboard/companies/pages/CompaniesIndex.vue b/app/javascript/dashboard/routes/dashboard/companies/pages/CompaniesIndex.vue new file mode 100644 index 000000000..3e86c97f4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/companies/pages/CompaniesIndex.vue @@ -0,0 +1,150 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/companies/routes.js b/app/javascript/dashboard/routes/dashboard/companies/routes.js new file mode 100644 index 000000000..69cbba762 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/companies/routes.js @@ -0,0 +1,26 @@ +import { frontendURL } from '../../../helper/URLHelper'; +import CompaniesIndex from './pages/CompaniesIndex.vue'; +import { FEATURE_FLAGS } from '../../../featureFlags'; +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; + +const commonMeta = { + featureFlag: FEATURE_FLAGS.COMPANIES, + permissions: ['administrator', 'agent'], + installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE], +}; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/companies'), + component: CompaniesIndex, + meta: commonMeta, + children: [ + { + path: '', + name: 'companies_dashboard_index', + component: CompaniesIndex, + meta: commonMeta, + }, + ], + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 5d27d6e49..1882923bd 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -2,6 +2,7 @@ import settings from './settings/settings.routes'; import conversation from './conversation/conversation.routes'; import { routes as searchRoutes } from '../../modules/search/search.routes'; import { routes as contactRoutes } from './contacts/routes'; +import { routes as companyRoutes } from './companies/routes'; import { routes as notificationRoutes } from './notifications/routes'; import { routes as inboxRoutes } from './inbox/routes'; import { frontendURL } from '../../helper/URLHelper'; @@ -23,6 +24,7 @@ export default { ...conversation.routes, ...settings.routes, ...contactRoutes, + ...companyRoutes, ...searchRoutes, ...notificationRoutes, ...helpcenterRoutes.routes, diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index d56958eb5..705ce1c2e 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -14,6 +14,7 @@ import bulkActions from './modules/bulkActions'; import campaigns from './modules/campaigns'; import cannedResponse from './modules/cannedResponse'; import categories from './modules/helpCenterCategories'; +import companies from './modules/companies'; import contactConversations from './modules/contactConversations'; import contactLabels from './modules/contactLabels'; import contactNotes from './modules/contactNotes'; @@ -77,6 +78,7 @@ export default createStore({ campaigns, cannedResponse, categories, + companies, contactConversations, contactLabels, contactNotes, diff --git a/app/javascript/dashboard/store/modules/companies.js b/app/javascript/dashboard/store/modules/companies.js new file mode 100644 index 000000000..116ed7e15 --- /dev/null +++ b/app/javascript/dashboard/store/modules/companies.js @@ -0,0 +1,29 @@ +import CompanyAPI from 'dashboard/api/companies'; +import { createStore } from 'dashboard/store/captain/storeFactory'; +import camelcaseKeys from 'camelcase-keys'; + +export default createStore({ + name: 'Company', + API: CompanyAPI, + getters: { + getCompaniesList: state => { + return camelcaseKeys(state.records, { deep: true }); + }, + }, + actions: mutationTypes => ({ + search: async ({ commit }, { search, page, sort }) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); + try { + const { + data: { payload, meta }, + } = await CompanyAPI.search(search, page, sort); + commit(mutationTypes.SET, payload); + commit(mutationTypes.SET_META, meta); + } catch (error) { + // Error + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); + } + }, + }), +}); diff --git a/config/features.yml b/config/features.yml index e1f3b20d9..0813c1c0f 100644 --- a/config/features.yml +++ b/config/features.yml @@ -223,3 +223,8 @@ - name: quoted_email_reply display_name: Quoted Email Reply enabled: false +- name: companies + display_name: Companies + enabled: false + premium: true + chatwoot_internal: true