Field level permission checks on widgets (#14774)

This commit is contained in:
nitin
2025-09-30 16:00:09 +02:00
committed by GitHub
parent c3b1f6c2b2
commit 274f4333b4
12 changed files with 786 additions and 148 deletions
@@ -0,0 +1,160 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { checkFieldPermissions } from '../checkFieldPermissions';
describe('checkFieldPermissions', () => {
const mockObjectPermissions: ObjectPermissions = {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
};
it('should return true when no fields are restricted', () => {
const fieldIds = ['field-1', 'field-2', 'field-3'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should return true when field is not in restrictedFields map', () => {
const fieldIds = ['field-1', 'field-2'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-3': { canRead: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should return false when any field has canRead set to false', () => {
const fieldIds = ['field-1', 'field-2', 'field-3'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-2': { canRead: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(false);
});
it('should return false when multiple fields are restricted', () => {
const fieldIds = ['field-1', 'field-2', 'field-3'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: false },
'field-2': { canRead: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(false);
});
it('should return true when field has canRead set to true', () => {
const fieldIds = ['field-1', 'field-2'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: true },
'field-2': { canRead: true },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should return true when field has canRead set to null', () => {
const fieldIds = ['field-1'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: null },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should return true when field has canRead set to undefined', () => {
const fieldIds = ['field-1'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: undefined },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should return true for empty field list', () => {
const fieldIds: string[] = [];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should handle mixed permissions correctly', () => {
const fieldIds = ['field-1', 'field-2', 'field-3', 'field-4'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: true },
'field-2': { canRead: null },
'field-3': { canRead: undefined },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should fail fast and return false on first restricted field', () => {
const fieldIds = ['field-1', 'field-2', 'field-3'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: false },
'field-2': { canRead: false },
'field-3': { canRead: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(false);
});
it('should only check canRead, not canUpdate', () => {
const fieldIds = ['field-1'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canRead: true, canUpdate: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
it('should handle field with only canUpdate restriction', () => {
const fieldIds = ['field-1'];
const permissions = {
...mockObjectPermissions,
restrictedFields: {
'field-1': { canUpdate: false },
},
};
expect(checkFieldPermissions(fieldIds, permissions)).toBe(true);
});
});
@@ -0,0 +1,186 @@
import {
type PageLayoutWidget,
WidgetType,
} from '~/generated-metadata/graphql';
import {
AggregateOperations,
GraphOrderBy,
GraphType,
} from '~/generated/graphql';
import { extractFieldMetadataIdsFromWidget } from '../extractFieldMetadataIdsFromWidget';
const createMockWidget = (
overrides: Partial<PageLayoutWidget>,
): PageLayoutWidget => ({
id: 'widget-1',
type: WidgetType.GRAPH,
title: 'Test',
configuration: null,
objectMetadataId: null,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
pageLayoutTabId: 'tab-1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
...overrides,
});
describe('extractFieldMetadataIdsFromWidget', () => {
it('should return empty array for IFRAME widget', () => {
const widget = createMockWidget({
type: WidgetType.IFRAME,
configuration: {
__typename: 'IframeConfiguration' as const,
url: 'https://example.com',
},
});
expect(extractFieldMetadataIdsFromWidget(widget)).toEqual([]);
});
it('should return empty array for widget without configuration', () => {
const widget = createMockWidget({
configuration: null,
});
expect(extractFieldMetadataIdsFromWidget(widget)).toEqual([]);
});
it('should extract field IDs from BarChartConfiguration', () => {
const widget = createMockWidget({
configuration: {
__typename: 'BarChartConfiguration' as const,
graphType: GraphType.BAR,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: 'field-2',
orderByX: GraphOrderBy.FIELD_ASC,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(2);
expect(result).toContain('field-1');
expect(result).toContain('field-2');
});
it('should extract field IDs from BarChartConfiguration with optional Y grouping', () => {
const widget = createMockWidget({
configuration: {
__typename: 'BarChartConfiguration' as const,
graphType: GraphType.BAR,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: 'field-2',
groupByFieldMetadataIdY: 'field-3',
orderByX: GraphOrderBy.FIELD_ASC,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(3);
expect(result).toContain('field-1');
expect(result).toContain('field-2');
expect(result).toContain('field-3');
});
it('should extract field IDs from LineChartConfiguration', () => {
const widget = createMockWidget({
configuration: {
__typename: 'LineChartConfiguration' as const,
graphType: GraphType.LINE,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataIdX: 'field-2',
orderByX: GraphOrderBy.FIELD_DESC,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(2);
expect(result).toContain('field-1');
expect(result).toContain('field-2');
});
it('should extract field IDs from PieChartConfiguration', () => {
const widget = createMockWidget({
configuration: {
__typename: 'PieChartConfiguration' as const,
graphType: GraphType.PIE,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataId: 'field-2',
orderBy: GraphOrderBy.FIELD_ASC,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(2);
expect(result).toContain('field-1');
expect(result).toContain('field-2');
});
it('should extract field IDs from NumberChartConfiguration', () => {
const widget = createMockWidget({
configuration: {
__typename: 'NumberChartConfiguration' as const,
graphType: GraphType.NUMBER,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.AVG,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(1);
expect(result).toContain('field-1');
});
it('should extract field IDs from GaugeChartConfiguration', () => {
const widget = createMockWidget({
configuration: {
__typename: 'GaugeChartConfiguration' as const,
graphType: GraphType.GAUGE,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.SUM,
aggregateFieldMetadataIdTotal: 'field-2',
aggregateOperationTotal: AggregateOperations.COUNT,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(2);
expect(result).toContain('field-1');
expect(result).toContain('field-2');
});
it('should not include undefined optional fields', () => {
const widget = createMockWidget({
configuration: {
__typename: 'BarChartConfiguration' as const,
graphType: GraphType.BAR,
aggregateFieldMetadataId: 'field-1',
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: 'field-2',
groupByFieldMetadataIdY: undefined,
orderByX: GraphOrderBy.FIELD_ASC,
},
objectMetadataId: 'object-1',
});
const result = extractFieldMetadataIdsFromWidget(widget);
expect(result).toHaveLength(2);
expect(result).not.toContain(undefined);
});
});
@@ -0,0 +1,21 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const checkFieldPermissions = (
fieldMetadataIds: string[],
objectPermissions: ObjectPermissions,
): boolean => {
const { restrictedFields } = objectPermissions;
const hasInaccessibleField = fieldMetadataIds.some((fieldId) => {
const fieldPermission = restrictedFields[fieldId];
if (!isDefined(fieldPermission)) {
return false;
}
return fieldPermission.canRead === false;
});
return !hasInaccessibleField;
};
@@ -0,0 +1,45 @@
import { isDefined } from 'twenty-shared/utils';
import {
type PageLayoutWidget,
WidgetType,
} from '~/generated-metadata/graphql';
export const extractFieldMetadataIdsFromWidget = (
widget: PageLayoutWidget,
): string[] => {
if (widget.type !== WidgetType.GRAPH || !widget.configuration) {
return [];
}
const config = widget.configuration;
switch (config.__typename) {
case 'BarChartConfiguration':
case 'LineChartConfiguration':
return [
config.aggregateFieldMetadataId,
config.groupByFieldMetadataIdX,
...(isDefined(config.groupByFieldMetadataIdY)
? [config.groupByFieldMetadataIdY]
: []),
];
case 'PieChartConfiguration':
return [config.aggregateFieldMetadataId, config.groupByFieldMetadataId];
case 'NumberChartConfiguration':
return [config.aggregateFieldMetadataId];
case 'GaugeChartConfiguration':
return [
config.aggregateFieldMetadataId,
config.aggregateFieldMetadataIdTotal,
];
case 'IframeConfiguration':
return [];
default:
return [];
}
};
@@ -0,0 +1,50 @@
import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo';
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { AppTooltip } from 'twenty-ui/display';
type PageLayoutWidgetForbiddenDisplayProps = {
widgetId: string;
restriction: WidgetAccessDenialInfo;
};
export const PageLayoutWidgetForbiddenDisplay = ({
widgetId,
restriction,
}: PageLayoutWidgetForbiddenDisplayProps) => {
const tooltipId = `widget-forbidden-tooltip-${widgetId}`;
const getTooltipContent = () => {
if (restriction.type === 'object' && isDefined(restriction.objectName)) {
const objectName = restriction.objectName;
return t`You do not have permission to access the ${objectName} object`;
}
if (
restriction.type === 'field' &&
isDefined(restriction.fieldNames) &&
restriction.fieldNames.length > 0
) {
const fieldsList = restriction.fieldNames.join(', ');
const fieldWord =
restriction.fieldNames.length === 1 ? 'field' : 'fields';
return t`You do not have permission to access the ${fieldsList} ${fieldWord}`;
}
return t`You do not have permission to view this widget`;
};
return (
<>
<div id={tooltipId}>
<ForbiddenFieldDisplay />
</div>
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={getTooltipContent()}
place="top"
/>
</>
);
};
@@ -1,14 +1,14 @@
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWidget';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader';
import { useWidgetObjectPermissions } from '@/page-layout/widgets/hooks/useWidgetObjectPermissions';
import { type PageLayoutWidget } from '~/generated/graphql';
import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { type PageLayoutWidget } from '~/generated/graphql';
type WidgetRendererProps = {
widget: PageLayoutWidget;
@@ -25,14 +25,14 @@ const StyledContent = styled.div`
export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { handleEditWidget } = useEditPageLayoutWidget();
const { haveAccessToWidgetsObject } = useWidgetObjectPermissions(widget);
const { hasAccess, restriction } = useWidgetPermissions(widget);
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
return (
<WidgetContainer isRestricted={!haveAccessToWidgetsObject}>
<WidgetContainer isRestricted={!hasAccess}>
<WidgetHeader
isInEditMode={isPageLayoutInEditMode}
title={widget.title}
@@ -42,8 +42,11 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
onRemove={() => deletePageLayoutWidget(widget.id)}
/>
<StyledContent>
{!haveAccessToWidgetsObject ? (
<ForbiddenFieldDisplay />
{!hasAccess ? (
<PageLayoutWidgetForbiddenDisplay
widgetId={widget.id}
restriction={restriction}
/>
) : (
<WidgetContentRenderer widget={widget} />
)}
@@ -1,29 +0,0 @@
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { isDefined } from 'twenty-shared/utils';
import { type PageLayoutWidget } from '~/generated-metadata/graphql';
type UseWidgetObjectPermissionsReturn = {
haveAccessToWidgetsObject: boolean;
};
export const useWidgetObjectPermissions = (
widget: PageLayoutWidget,
): UseWidgetObjectPermissionsReturn => {
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
if (!isDefined(widget.objectMetadataId)) {
return {
haveAccessToWidgetsObject: true,
};
}
const objectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
widget.objectMetadataId,
);
return {
haveAccessToWidgetsObject: objectPermissions.canReadObjectRecords,
};
};
@@ -0,0 +1,85 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { checkFieldPermissions } from '@/page-layout/utils/checkFieldPermissions';
import { extractFieldMetadataIdsFromWidget } from '@/page-layout/utils/extractFieldMetadataIdsFromWidget';
import { type WidgetAccessDenialInfo } from '@/page-layout/widgets/types/WidgetAccessDenialInfo';
import { isDefined } from 'twenty-shared/utils';
import { type PageLayoutWidget } from '~/generated-metadata/graphql';
export type UseWidgetPermissionsReturn = {
hasAccess: boolean;
restriction: WidgetAccessDenialInfo;
};
export const useWidgetPermissions = (
widget: PageLayoutWidget,
): UseWidgetPermissionsReturn => {
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItems } = useObjectMetadataItems();
if (!isDefined(widget.objectMetadataId)) {
return {
hasAccess: true,
restriction: {
type: null,
},
};
}
const objectMetadata = objectMetadataItems.find(
(item) => item.id === widget.objectMetadataId,
);
const objectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
widget.objectMetadataId,
);
const hasObjectAccess = objectPermissions.canReadObjectRecords;
if (!hasObjectAccess) {
return {
hasAccess: false,
restriction: {
type: 'object',
objectName: objectMetadata?.labelSingular,
},
};
}
const fieldMetadataIds = extractFieldMetadataIdsFromWidget(widget);
const allFieldsAccessible = checkFieldPermissions(
fieldMetadataIds,
objectPermissions,
);
if (!allFieldsAccessible) {
const restrictedFieldNames = fieldMetadataIds
.filter((fieldId) => {
const fieldPermission = objectPermissions.restrictedFields[fieldId];
return isDefined(fieldPermission) && fieldPermission.canRead === false;
})
.map((fieldId) => {
const field = objectMetadata?.fields?.find((f) => f.id === fieldId);
return field?.label || field?.name || 'Unknown';
})
.filter(isDefined);
return {
hasAccess: false,
restriction: {
type: 'field',
objectName: objectMetadata?.labelSingular,
fieldNames: restrictedFieldNames,
},
};
}
return {
hasAccess: true,
restriction: {
type: null,
},
};
};
@@ -0,0 +1,5 @@
export type WidgetAccessDenialInfo = {
type: 'object' | 'field' | null;
objectName?: string;
fieldNames?: string[];
};
@@ -12,6 +12,8 @@ export const PAGE_LAYOUT_WIDGET_SEEDS = {
CUSTOMER_ANNUAL_RECURRING_REVENUE: 'CUSTOMER_ANNUAL_RECURRING_REVENUE_WIDGET',
CUSTOMER_REVENUE_DISTRIBUTION: 'CUSTOMER_REVENUE_DISTRIBUTION_WIDGET',
CUSTOMER_AVERAGE_ARR: 'CUSTOMER_AVERAGE_ARR_WIDGET',
CUSTOMER_LINKEDIN_COUNT: 'CUSTOMER_LINKEDIN_COUNT_WIDGET',
CUSTOMER_LINKEDIN_DISTRIBUTION: 'CUSTOMER_LINKEDIN_DISTRIBUTION_WIDGET',
TEAM_SIZE: 'TEAM_SIZE_WIDGET',
TEAM_GEOGRAPHIC_DISTRIBUTION: 'TEAM_GEOGRAPHIC_DISTRIBUTION_WIDGET',
@@ -1,15 +1,11 @@
import { isDefined } from 'twenty-shared/utils';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PAGE_LAYOUT_TAB_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-tab-seeds.constant';
import { PAGE_LAYOUT_WIDGET_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-widget-seeds.constant';
import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util';
import {
BASE_OBJECT_STANDARD_FIELD_IDS,
COMPANY_STANDARD_FIELD_IDS,
OPPORTUNITY_STANDARD_FIELD_IDS,
PERSON_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
type PageLayoutWidgetDataSeed = {
@@ -27,6 +23,13 @@ type PageLayoutWidgetDataSeed = {
objectMetadataId: string | null;
};
const getFieldId = (
object: ObjectMetadataEntity | undefined,
fieldName: string,
): string | undefined => {
return object?.fields?.find((field) => field.name === fieldName)?.id;
};
export const getPageLayoutWidgetDataSeeds = (
workspaceId: string,
objectMetadataItems: ObjectMetadataEntity[],
@@ -47,6 +50,31 @@ export const getPageLayoutWidgetDataSeeds = (
(obj) => obj.nameSingular === 'rocket',
);
const opportunityAmountFieldId = getFieldId(opportunityObject, 'amount');
const opportunityCloseDateFieldId = getFieldId(
opportunityObject,
'closeDate',
);
const opportunityStageFieldId = getFieldId(opportunityObject, 'stage');
const companyIdFieldId = getFieldId(companyObject, 'id');
const companyCreatedAtFieldId = getFieldId(companyObject, 'createdAt');
const companyEmployeesFieldId = getFieldId(companyObject, 'employees');
const companyArrFieldId = getFieldId(companyObject, 'annualRecurringRevenue');
const companyNameFieldId = getFieldId(companyObject, 'name');
const companyLinkedinLinkFieldId = getFieldId(companyObject, 'linkedinLink');
const personIdFieldId = getFieldId(personObject, 'id');
const personCityFieldId = getFieldId(personObject, 'city');
const personJobTitleFieldId = getFieldId(personObject, 'jobTitle');
const opportunityIdFieldId = getFieldId(opportunityObject, 'id');
const taskIdFieldId = getFieldId(taskObject, 'id');
const rocketIdFieldId = getFieldId(rocketObject, 'id');
const rocketCreatedAtFieldId = getFieldId(rocketObject, 'createdAt');
return [
// Sales Overview Tab Widgets
{
@@ -61,11 +89,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Total Pipeline Value',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount,
aggregateOperation: AggregateOperations.SUM,
},
configuration: isDefined(opportunityAmountFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: opportunityAmountFieldId,
aggregateOperation: AggregateOperations.SUM,
}
: null,
objectMetadataId: opportunityObject?.id ?? null,
},
{
@@ -77,16 +107,16 @@ export const getPageLayoutWidgetDataSeeds = (
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.SALES_OVERVIEW,
),
title: 'Average Deal Size',
title: 'Rocket Count (Object Permission Test)',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 3, rowSpan: 4, columnSpan: 4 },
configuration: {
graphType: 'GAUGE',
aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount,
aggregateOperation: AggregateOperations.AVG,
aggregateFieldMetadataIdTotal: OPPORTUNITY_STANDARD_FIELD_IDS.amount,
aggregateOperationTotal: AggregateOperations.MAX,
},
configuration: isDefined(rocketIdFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: rocketIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: rocketObject?.id ?? null,
},
{
@@ -101,13 +131,17 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Revenue Forecast',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 7, rowSpan: 8, columnSpan: 5 },
configuration: {
graphType: 'LINE',
aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataIdX: OPPORTUNITY_STANDARD_FIELD_IDS.closeDate,
orderByX: 'FIELD_ASC',
},
configuration:
isDefined(opportunityAmountFieldId) &&
isDefined(opportunityCloseDateFieldId)
? {
graphType: 'LINE',
aggregateFieldMetadataId: opportunityAmountFieldId,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataIdX: opportunityCloseDateFieldId,
orderByX: 'FIELD_ASC',
}
: null,
objectMetadataId: opportunityObject?.id ?? null,
},
{
@@ -122,13 +156,17 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Deals by Stage',
type: WidgetType.GRAPH,
gridPosition: { row: 4, column: 0, rowSpan: 4, columnSpan: 6 },
configuration: {
graphType: 'BAR',
aggregateFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.amount,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataIdX: OPPORTUNITY_STANDARD_FIELD_IDS.stage,
orderByX: 'FIELD_DESC',
},
configuration:
isDefined(opportunityAmountFieldId) &&
isDefined(opportunityStageFieldId)
? {
graphType: 'BAR',
aggregateFieldMetadataId: opportunityAmountFieldId,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataIdX: opportunityStageFieldId,
orderByX: 'FIELD_DESC',
}
: null,
objectMetadataId: opportunityObject?.id ?? null,
},
@@ -142,16 +180,19 @@ export const getPageLayoutWidgetDataSeeds = (
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.SALES_DETAILS,
),
title: 'Deal Distribution',
title: 'Rockets by Created Date (Object Permission Test)',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 5, columnSpan: 5 },
configuration: {
graphType: 'PIE',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataId: OPPORTUNITY_STANDARD_FIELD_IDS.stage,
orderBy: 'VALUE_DESC',
},
configuration:
isDefined(rocketIdFieldId) && isDefined(rocketCreatedAtFieldId)
? {
graphType: 'BAR',
aggregateFieldMetadataId: rocketIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: rocketCreatedAtFieldId,
orderByX: 'FIELD_ASC',
}
: null,
objectMetadataId: rocketObject?.id ?? null,
},
{
@@ -166,11 +207,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Opportunity Count',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 5, rowSpan: 5, columnSpan: 7 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
},
configuration: isDefined(opportunityIdFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: opportunityIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: opportunityObject?.id ?? null,
},
@@ -187,11 +230,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Total Customers',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
},
configuration: isDefined(companyIdFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: companyIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
@@ -206,13 +251,16 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'New Customers Over Time',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 3, rowSpan: 6, columnSpan: 5 },
configuration: {
graphType: 'LINE',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: BASE_OBJECT_STANDARD_FIELD_IDS.createdAt,
orderByX: 'FIELD_ASC',
},
configuration:
isDefined(companyIdFieldId) && isDefined(companyCreatedAtFieldId)
? {
graphType: 'LINE',
aggregateFieldMetadataId: companyIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: companyCreatedAtFieldId,
orderByX: 'FIELD_ASC',
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
@@ -227,13 +275,16 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Companies by Size',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 8, rowSpan: 6, columnSpan: 4 },
configuration: {
graphType: 'BAR',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: COMPANY_STANDARD_FIELD_IDS.employees,
orderByX: 'FIELD_ASC',
},
configuration:
isDefined(companyIdFieldId) && isDefined(companyEmployeesFieldId)
? {
graphType: 'BAR',
aggregateFieldMetadataId: companyIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: companyEmployeesFieldId,
orderByX: 'FIELD_ASC',
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
@@ -250,12 +301,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Annual Recurring Revenue',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId:
COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue,
aggregateOperation: AggregateOperations.SUM,
},
configuration: isDefined(companyArrFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: companyArrFieldId,
aggregateOperation: AggregateOperations.SUM,
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
@@ -270,14 +322,16 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Revenue Distribution',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 3 },
configuration: {
graphType: 'PIE',
aggregateFieldMetadataId:
COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataId: COMPANY_STANDARD_FIELD_IDS.name,
orderBy: 'VALUE_DESC',
},
configuration:
isDefined(companyArrFieldId) && isDefined(companyNameFieldId)
? {
graphType: 'PIE',
aggregateFieldMetadataId: companyArrFieldId,
aggregateOperation: AggregateOperations.SUM,
groupByFieldMetadataId: companyNameFieldId,
orderBy: 'VALUE_DESC',
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
@@ -292,15 +346,60 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Average ARR',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 7, rowSpan: 6, columnSpan: 5 },
configuration: {
graphType: 'GAUGE',
aggregateFieldMetadataId:
COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue,
aggregateOperation: AggregateOperations.AVG,
aggregateFieldMetadataIdTotal:
COMPANY_STANDARD_FIELD_IDS.annualRecurringRevenue,
aggregateOperationTotal: AggregateOperations.MAX,
},
configuration: isDefined(companyArrFieldId)
? {
graphType: 'GAUGE',
aggregateFieldMetadataId: companyArrFieldId,
aggregateOperation: AggregateOperations.AVG,
aggregateFieldMetadataIdTotal: companyArrFieldId,
aggregateOperationTotal: AggregateOperations.MAX,
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
id: generateSeedId(
workspaceId,
PAGE_LAYOUT_WIDGET_SEEDS.CUSTOMER_LINKEDIN_COUNT,
),
pageLayoutTabId: generateSeedId(
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW,
),
title: 'LinkedIn Profiles Count (Field Permission Test)',
type: WidgetType.GRAPH,
gridPosition: { row: 2, column: 0, rowSpan: 4, columnSpan: 3 },
configuration: isDefined(companyLinkedinLinkFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: companyLinkedinLinkFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
{
id: generateSeedId(
workspaceId,
PAGE_LAYOUT_WIDGET_SEEDS.CUSTOMER_LINKEDIN_DISTRIBUTION,
),
pageLayoutTabId: generateSeedId(
workspaceId,
PAGE_LAYOUT_TAB_SEEDS.CUSTOMER_OVERVIEW,
),
title: 'Companies by LinkedIn (Field Permission Test)',
type: WidgetType.GRAPH,
gridPosition: { row: 6, column: 0, rowSpan: 4, columnSpan: 6 },
configuration:
isDefined(companyIdFieldId) && isDefined(companyLinkedinLinkFieldId)
? {
graphType: 'PIE',
aggregateFieldMetadataId: companyIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataId: companyLinkedinLinkFieldId,
orderBy: 'VALUE_DESC',
}
: null,
objectMetadataId: companyObject?.id ?? null,
},
@@ -314,11 +413,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Team Size',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 5, columnSpan: 6 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
},
configuration: isDefined(personIdFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: personIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: personObject?.id ?? null,
},
{
@@ -333,13 +434,16 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Geographic Distribution',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 6, rowSpan: 5, columnSpan: 6 },
configuration: {
graphType: 'BAR',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: PERSON_STANDARD_FIELD_IDS.city,
orderByX: 'FIELD_DESC',
},
configuration:
isDefined(personIdFieldId) && isDefined(personCityFieldId)
? {
graphType: 'BAR',
aggregateFieldMetadataId: personIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataIdX: personCityFieldId,
orderByX: 'FIELD_DESC',
}
: null,
objectMetadataId: personObject?.id ?? null,
},
@@ -356,13 +460,16 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Contact Roles',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 6 },
configuration: {
graphType: 'PIE',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataId: PERSON_STANDARD_FIELD_IDS.jobTitle,
orderBy: 'VALUE_DESC',
},
configuration:
isDefined(personIdFieldId) && isDefined(personJobTitleFieldId)
? {
graphType: 'PIE',
aggregateFieldMetadataId: personIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataId: personJobTitleFieldId,
orderBy: 'VALUE_DESC',
}
: null,
objectMetadataId: personObject?.id ?? null,
},
{
@@ -374,11 +481,13 @@ export const getPageLayoutWidgetDataSeeds = (
title: 'Open Tasks',
type: WidgetType.GRAPH,
gridPosition: { row: 0, column: 6, rowSpan: 6, columnSpan: 6 },
configuration: {
graphType: 'NUMBER',
aggregateFieldMetadataId: BASE_OBJECT_STANDARD_FIELD_IDS.id,
aggregateOperation: AggregateOperations.COUNT,
},
configuration: isDefined(taskIdFieldId)
? {
graphType: 'NUMBER',
aggregateFieldMetadataId: taskIdFieldId,
aggregateOperation: AggregateOperations.COUNT,
}
: null,
objectMetadataId: taskObject?.id ?? null,
},
];
@@ -84,6 +84,7 @@ export class DevSeederService {
this.coreDataSource.getRepository(ObjectMetadataEntity);
const objectMetadataItems = await objectMetadataRepository.find({
where: { workspaceId },
relations: { fields: true },
});
await seedPageLayoutWidgets(