Companies table (#79)

* Add columns to companies:
* account_owner_id
* employees
* address

Add foreign key constraint companies_account_owner_id_fkey
to auth.users.id

* Add select permissions to:
* account_owner_id
* employees
* address

Add relationship between companies and auth.users.

* Update Companies interface to include:
* account_owner_id
* employees
* address

Opportunity is expected to be replace by actual opportunity in a separate PR.

* Add GetCompanies query

* Add initial companies table

* Update test to use mock apollo provider

* Update to match changed company column names

* Add company interface mapping tests

* Test entire object

* Add test for companies being rendered in table.

* Add test for sorting reduce.

* Fix prettier errors
This commit is contained in:
Anders Borch
2023-04-27 12:46:43 +02:00
committed by GitHub
parent 42bf653e4a
commit d4b1b2f661
21 changed files with 450 additions and 12 deletions

View File

@ -0,0 +1,68 @@
import { mapGqlCompany, mapCompany } from './company.interface';
describe('mapCompany', () => {
it('should map company', () => {
const now = new Date();
now.setMilliseconds(0);
const company = mapCompany({
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: 'ACME',
domain_name: 'exmaple.com',
created_at: now.toUTCString(),
account_owner: {
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
email: 'john@example.com',
displayName: 'John Doe',
},
employees: 10,
address: '1 Infinite Loop, 95014 Cupertino, California, USA',
});
expect(company.id).toBe('7dfbc3f7-6e5e-4128-957e-8d86808cdf6b');
expect(company.name).toBe('ACME');
expect(company.domain_name).toBe('exmaple.com');
expect(company.creationDate).toEqual(now);
expect(company.accountOwner.id).toBe(
'7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
);
expect(company.accountOwner.email).toBe('john@example.com');
expect(company.accountOwner.first_name).toBe('John');
expect(company.accountOwner.last_name).toBe('Doe');
expect(company.employees).toBe(10);
expect(company.address).toBe(
'1 Infinite Loop, 95014 Cupertino, California, USA',
);
});
it('should map company back', () => {
const now = new Date();
now.setMilliseconds(0);
const company = mapGqlCompany({
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: 'ACME',
domain_name: 'exmaple.com',
employees: 10,
address: '1 Infinite Loop, 95014 Cupertino, California, USA',
opportunities: [],
accountOwner: {
id: '522d4ec4-c46b-4360-a0a7-df8df170be81',
email: 'john@example.com',
first_name: 'John',
last_name: 'Doe',
},
creationDate: now,
});
expect(company.id).toBe('7dfbc3f7-6e5e-4128-957e-8d86808cdf6b');
expect(company.name).toBe('ACME');
expect(company.domain_name).toBe('exmaple.com');
expect(company.created_at).toEqual(now.toUTCString());
expect(company.account_owner.id).toBe(
'522d4ec4-c46b-4360-a0a7-df8df170be81',
);
expect(company.account_owner.email).toBe('john@example.com');
expect(company.account_owner.displayName).toBe('John Doe');
expect(company.employees).toBe(10);
expect(company.address).toBe(
'1 Infinite Loop, 95014 Cupertino, California, USA',
);
});
});

View File

@ -1,5 +1,59 @@
import { User } from './user.interface';
export interface Opportunity {
name: string;
icon: string;
}
export interface Company {
id: string;
name: string;
domain_name: string;
employees: number;
address: string;
opportunities: Opportunity[];
accountOwner: User;
creationDate: Date;
}
export type GraphqlQueryAccountOwner = {
id: string;
email: string;
displayName: string;
};
export type GraphqlQueryCompany = {
id: string;
name: string;
domain_name: string;
account_owner: GraphqlQueryAccountOwner;
employees: number;
address: string;
created_at: string;
};
export const mapCompany = (company: GraphqlQueryCompany): Company => ({
...company,
name: company.name,
domain_name: company.domain_name,
accountOwner: {
id: company.account_owner.id,
email: company.account_owner.email,
first_name: company.account_owner.displayName.split(' ').shift() || '',
last_name: company.account_owner.displayName.split(' ').slice(1).join(' '),
},
creationDate: new Date(company.created_at),
opportunities: [{ name: 'Sales Pipeline', icon: '' }],
});
export const mapGqlCompany = (company: Company): GraphqlQueryCompany => ({
...company,
name: company.name,
domain_name: company.domain_name,
created_at: company.creationDate.toUTCString(),
account_owner: {
id: company.accountOwner.id,
email: company.accountOwner.email,
displayName: `${company.accountOwner.first_name} ${company.accountOwner.last_name}`,
},
});

View File

@ -6,7 +6,10 @@ export type Person = {
fullName: string;
picture?: string;
email: string;
company: Company;
company: Omit<
Company,
'employees' | 'address' | 'opportunities' | 'accountOwner' | 'creationDate'
>;
phone: string;
creationDate: Date;
pipe: Pipe;

View File

@ -1,10 +1,45 @@
import { faBuildings } from '@fortawesome/pro-regular-svg-icons';
import { faBuildings, faList } from '@fortawesome/pro-regular-svg-icons';
import WithTopBarContainer from '../../layout/containers/WithTopBarContainer';
import styled from '@emotion/styled';
import { useState, useCallback } from 'react';
import {
CompaniesSelectedSortType,
defaultOrderBy,
reduceSortsToOrderBy,
useCompaniesQuery,
} from '../../services/companies';
import Table from '../../components/table/Table';
import { mapCompany } from '../../interfaces/company.interface';
import { companiesColumns, sortsAvailable } from './companies-table';
const StyledCompaniesContainer = styled.div`
display: flex;
width: 100%;
`;
function Companies() {
const [, setSorts] = useState([] as Array<CompaniesSelectedSortType>);
const [orderBy, setOrderBy] = useState(defaultOrderBy);
const updateSorts = useCallback((sorts: Array<CompaniesSelectedSortType>) => {
setSorts(sorts);
setOrderBy(sorts.length ? reduceSortsToOrderBy(sorts) : defaultOrderBy);
}, []);
const { data } = useCompaniesQuery(orderBy);
return (
<WithTopBarContainer title="Companies" icon={faBuildings}>
<></>
<StyledCompaniesContainer>
<Table
data={data ? data.companies.map(mapCompany) : []}
columns={companiesColumns}
viewName="All Companies"
viewIcon={faList}
onSortsUpdate={updateSorts}
availableSorts={sortsAvailable}
/>
</StyledCompaniesContainer>
</WithTopBarContainer>
);
}

View File

@ -2,6 +2,9 @@ import { MemoryRouter } from 'react-router-dom';
import Companies from '../Companies';
import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../../../layout/styles/themes';
import { GET_COMPANIES } from '../../../services/companies';
import { defaultData } from './mock-data';
import { MockedProvider } from '@apollo/client/testing';
const component = {
title: 'Companies',
@ -10,10 +13,28 @@ const component = {
export default component;
const mocks = [
{
request: {
query: GET_COMPANIES,
variables: {
orderBy: [{ name: 'asc' }],
},
},
result: {
data: {
companies: defaultData,
},
},
},
];
export const CompaniesDefault = () => (
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<Companies />
</MemoryRouter>
</ThemeProvider>
<MockedProvider mocks={mocks}>
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<Companies />
</MemoryRouter>
</ThemeProvider>
</MockedProvider>
);

View File

@ -0,0 +1,17 @@
import { GraphqlQueryCompany } from '../../../interfaces/company.interface';
export const defaultData: Array<GraphqlQueryCompany> = [
{
id: 'f121ab32-fac4-4b8c-9a3d-150c877319c2',
name: 'ACME',
domain_name: 'example.com',
account_owner: {
id: '91510aa5-ede6-451f-8029-a7fa69e4bad6',
email: 'john@example.com',
displayName: 'John Doe',
},
employees: 10,
address: '1 Infinity Loop, 95014 Cupertino, California',
created_at: new Date().toUTCString(),
},
];

View File

@ -1,10 +1,15 @@
import { render } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import { CompaniesDefault } from '../__stories__/Companies.stories';
it('Checks the Companies page render', () => {
it('Checks the Companies page render', async () => {
const { getByTestId } = render(<CompaniesDefault />);
const title = getByTestId('top-bar-title');
expect(title).toHaveTextContent('Companies');
await waitFor(() => {
const row = getByTestId('row-id-0');
expect(row).toBeDefined();
});
});

View File

@ -0,0 +1,116 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Company } from '../../interfaces/company.interface';
import { OrderByFields } from '../../services/companies';
import ColumnHead from '../../components/table/ColumnHead';
import HorizontalyAlignedContainer from '../../layout/containers/HorizontalyAlignedContainer';
import Checkbox from '../../components/form/Checkbox';
import CompanyChip from '../../components/chips/CompanyChip';
import EditableCell from '../../components/table/EditableCell';
import PipeChip from '../../components/chips/PipeChip';
import { faCalendar } from '@fortawesome/pro-regular-svg-icons';
import ClickableCell from '../../components/table/ClickableCell';
import PersonChip from '../../components/chips/PersonChip';
import { SortType } from '../../components/table/table-header/interface';
export const sortsAvailable = [
{
key: 'name',
label: 'Name',
icon: undefined,
},
{
key: 'domain_name',
label: 'Domain',
icon: undefined,
},
] satisfies Array<SortType<OrderByFields>>;
const columnHelper = createColumnHelper<Company>();
export const companiesColumns = [
columnHelper.accessor('name', {
header: () => <ColumnHead viewName="Name" />,
cell: (props) => (
<HorizontalyAlignedContainer>
<Checkbox
id={`company-selected-${props.row.original.id}`}
name={`company-selected-${props.row.original.id}`}
/>
<CompanyChip
name={props.row.original.name}
picture={`https://www.google.com/s2/favicons?domain=${props.row.original.domain_name}&sz=256`}
/>
</HorizontalyAlignedContainer>
),
}),
columnHelper.accessor('employees', {
header: () => <ColumnHead viewName="Employees" />,
cell: (props) => (
<EditableCell
content={props.row.original.employees.toFixed(0)}
changeHandler={(value) => {
const company = props.row.original;
company.employees = parseInt(value);
// TODO: update company
}}
/>
),
}),
columnHelper.accessor('domain_name', {
header: () => <ColumnHead viewName="URL" />,
cell: (props) => (
<EditableCell
content={props.row.original.domain_name}
changeHandler={(value) => {
const company = props.row.original;
company.domain_name = value;
// TODO: update company
}}
/>
),
}),
columnHelper.accessor('address', {
header: () => <ColumnHead viewName="Address" />,
cell: (props) => (
<EditableCell
content={props.row.original.address}
changeHandler={(value) => {
const company = props.row.original;
company.address = value;
// TODO: update company
}}
/>
),
}),
columnHelper.accessor('opportunities', {
header: () => <ColumnHead viewName="Opportunities" />,
cell: (props) => (
<HorizontalyAlignedContainer>
{props.row.original.opportunities.map((opportunity) => (
<PipeChip name={opportunity.name} picture={opportunity.icon} />
))}
</HorizontalyAlignedContainer>
),
}),
columnHelper.accessor('creationDate', {
header: () => <ColumnHead viewName="Creation" viewIcon={faCalendar} />,
cell: (props) => (
<ClickableCell href="#">
{new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(props.row.original.creationDate)}
</ClickableCell>
),
}),
columnHelper.accessor('accountOwner', {
header: () => <ColumnHead viewName="Account Owner" />,
cell: (props) => (
<HorizontalyAlignedContainer>
<PersonChip
name={`${props.row.original.accountOwner.first_name} ${props.row.original.accountOwner.last_name}`}
/>
</HorizontalyAlignedContainer>
),
}),
];

View File

@ -0,0 +1 @@
export * from './select';

View File

@ -0,0 +1,12 @@
import { CompaniesSelectedSortType, reduceSortsToOrderBy } from './select';
describe('reduceSortsToOrderBy', () => {
it('should return an array of objects with the id as key and the order as value', () => {
const sorts = [
{ key: 'name', label: 'name', order: 'asc' },
{ key: 'domain_name', label: 'domain_name', order: 'desc' },
] satisfies CompaniesSelectedSortType[];
const result = reduceSortsToOrderBy(sorts);
expect(result).toEqual([{ name: 'asc', domain_name: 'desc' }]);
});
});

View File

@ -0,0 +1,56 @@
import { QueryResult, gql, useQuery } from '@apollo/client';
import { Order_By, Companies_Order_By } from '../../generated/graphql';
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
import { SelectedSortType } from '../../components/table/table-header/interface';
export type OrderByFields = keyof Companies_Order_By | 'domain_name' | 'name';
export type CompaniesSelectedSortType = SelectedSortType<OrderByFields>;
const mapOrder = (order: 'asc' | 'desc'): Order_By => {
return order === 'asc' ? Order_By.Asc : Order_By.Desc;
};
export const reduceSortsToOrderBy = (
sorts: Array<CompaniesSelectedSortType>,
): Companies_Order_By[] => {
const mappedSorts = sorts.reduce((acc, sort) => {
const id = sort.key;
const order = mapOrder(sort.order);
acc[id] = order;
return acc;
}, {} as Companies_Order_By);
return [mappedSorts];
};
export const GET_COMPANIES = gql`
query GetCompanies($orderBy: [companies_order_by!]) {
companies(order_by: $orderBy) {
id
domain_name
name
created_at
address
employees
account_owner {
id
email
displayName
}
}
}
`;
export function useCompaniesQuery(
orderBy: Companies_Order_By[],
): QueryResult<{ companies: GraphqlQueryCompany[] }> {
return useQuery<{ companies: GraphqlQueryCompany[] }>(GET_COMPANIES, {
variables: { orderBy },
});
}
export const defaultOrderBy: Companies_Order_By[] = [
{
name: Order_By.Asc,
},
];

View File

@ -129,6 +129,14 @@ array_relationships:
table:
name: user_providers
schema: auth
select_permissions:
- role: user
permission:
columns:
- display_name
- email
- id
filter: {}
event_triggers:
- name: user-created
definition:
@ -142,4 +150,4 @@ event_triggers:
webhook: '{{HASURA_EVENT_HANDLER_URL}}'
headers:
- name: secret-header
value: secret
value: secret

View File

@ -2,6 +2,15 @@ table:
name: companies
schema: public
object_relationships:
- name: account_owner
using:
manual_configuration:
column_mapping:
account_owner_id: id
insertion_order: null
remote_table:
name: users
schema: auth
- name: workspace
using:
foreign_key_constraint_on: workspace_id
@ -14,6 +23,9 @@ insert_permissions:
columns:
- id
- workspace_id
- account_owner_id
- address
- employees
- name
- domain_name
- created_at
@ -25,6 +37,9 @@ select_permissions:
columns:
- domain_name
- name
- account_owner_id
- address
- employees
- created_at
- deleted_at
- updated_at
@ -39,6 +54,9 @@ update_permissions:
columns:
- domain_name
- name
- employees
- address
- account_owner_id
- created_at
- deleted_at
- updated_at
@ -53,4 +71,4 @@ delete_permissions:
permission:
filter:
workspace_id:
_eq: x-hasura-workspace-id
_eq: x-hasura-workspace-id

View File

@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."companies" add column "account_owner_id" uuid
-- null;

View File

@ -0,0 +1,2 @@
alter table "public"."companies" add column "account_owner_id" uuid
null;

View File

@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."companies" add column "employees" integer
-- not null default '1';

View File

@ -0,0 +1,2 @@
alter table "public"."companies" add column "employees" integer
not null default '1';

View File

@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."companies" add column "address" text
-- null;

View File

@ -0,0 +1,2 @@
alter table "public"."companies" add column "address" text
null;

View File

@ -0,0 +1 @@
alter table "public"."companies" drop constraint "companies_account_owner_id_fkey";

View File

@ -0,0 +1,5 @@
alter table "public"."companies"
add constraint "companies_account_owner_id_fkey"
foreign key ("account_owner_id")
references "auth"."users"
("id") on update set null on delete set null;