feat: add table columns (#1056)
* feat: add table columns Closes #879 * refactor: ComponentProps first
This commit is contained in:
@ -905,6 +905,7 @@ export type Mutation = {
|
|||||||
createOneCompany: Company;
|
createOneCompany: Company;
|
||||||
createOnePerson: Person;
|
createOnePerson: Person;
|
||||||
createOnePipelineProgress: PipelineProgress;
|
createOnePipelineProgress: PipelineProgress;
|
||||||
|
createOneViewField: ViewField;
|
||||||
deleteCurrentWorkspace: Workspace;
|
deleteCurrentWorkspace: Workspace;
|
||||||
deleteManyActivities: AffectedRows;
|
deleteManyActivities: AffectedRows;
|
||||||
deleteManyCompany: AffectedRows;
|
deleteManyCompany: AffectedRows;
|
||||||
@ -980,6 +981,11 @@ export type MutationCreateOnePipelineProgressArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type MutationCreateOneViewFieldArgs = {
|
||||||
|
data: ViewFieldCreateInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type MutationDeleteManyActivitiesArgs = {
|
export type MutationDeleteManyActivitiesArgs = {
|
||||||
where?: InputMaybe<ActivityWhereInput>;
|
where?: InputMaybe<ActivityWhereInput>;
|
||||||
};
|
};
|
||||||
@ -2137,6 +2143,15 @@ export type ViewField = {
|
|||||||
sizeInPx: Scalars['Int'];
|
sizeInPx: Scalars['Int'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ViewFieldCreateInput = {
|
||||||
|
fieldName: Scalars['String'];
|
||||||
|
id?: InputMaybe<Scalars['String']>;
|
||||||
|
index: Scalars['Int'];
|
||||||
|
isVisible: Scalars['Boolean'];
|
||||||
|
objectName: Scalars['String'];
|
||||||
|
sizeInPx: Scalars['Int'];
|
||||||
|
};
|
||||||
|
|
||||||
export type ViewFieldCreateManyInput = {
|
export type ViewFieldCreateManyInput = {
|
||||||
fieldName: Scalars['String'];
|
fieldName: Scalars['String'];
|
||||||
id?: InputMaybe<Scalars['String']>;
|
id?: InputMaybe<Scalars['String']>;
|
||||||
@ -2709,6 +2724,13 @@ export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }
|
|||||||
|
|
||||||
export type DeleteUserAccountMutation = { __typename?: 'Mutation', deleteUserAccount: { __typename?: 'User', id: string } };
|
export type DeleteUserAccountMutation = { __typename?: 'Mutation', deleteUserAccount: { __typename?: 'User', id: string } };
|
||||||
|
|
||||||
|
export type CreateViewFieldMutationVariables = Exact<{
|
||||||
|
data: ViewFieldCreateInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type CreateViewFieldMutation = { __typename?: 'Mutation', createOneViewField: { __typename?: 'ViewField', id: string, fieldName: string, isVisible: boolean, sizeInPx: number, index: number } };
|
||||||
|
|
||||||
export type CreateViewFieldsMutationVariables = Exact<{
|
export type CreateViewFieldsMutationVariables = Exact<{
|
||||||
data: Array<ViewFieldCreateManyInput> | ViewFieldCreateManyInput;
|
data: Array<ViewFieldCreateManyInput> | ViewFieldCreateManyInput;
|
||||||
}>;
|
}>;
|
||||||
@ -5128,6 +5150,43 @@ export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOp
|
|||||||
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
export type DeleteUserAccountMutationHookResult = ReturnType<typeof useDeleteUserAccountMutation>;
|
||||||
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
export type DeleteUserAccountMutationResult = Apollo.MutationResult<DeleteUserAccountMutation>;
|
||||||
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions<DeleteUserAccountMutation, DeleteUserAccountMutationVariables>;
|
||||||
|
export const CreateViewFieldDocument = gql`
|
||||||
|
mutation CreateViewField($data: ViewFieldCreateInput!) {
|
||||||
|
createOneViewField(data: $data) {
|
||||||
|
id
|
||||||
|
fieldName
|
||||||
|
isVisible
|
||||||
|
sizeInPx
|
||||||
|
index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
export type CreateViewFieldMutationFn = Apollo.MutationFunction<CreateViewFieldMutation, CreateViewFieldMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useCreateViewFieldMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useCreateViewFieldMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useCreateViewFieldMutation` returns a tuple that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [createViewFieldMutation, { data, loading, error }] = useCreateViewFieldMutation({
|
||||||
|
* variables: {
|
||||||
|
* data: // value for 'data'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useCreateViewFieldMutation(baseOptions?: Apollo.MutationHookOptions<CreateViewFieldMutation, CreateViewFieldMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<CreateViewFieldMutation, CreateViewFieldMutationVariables>(CreateViewFieldDocument, options);
|
||||||
|
}
|
||||||
|
export type CreateViewFieldMutationHookResult = ReturnType<typeof useCreateViewFieldMutation>;
|
||||||
|
export type CreateViewFieldMutationResult = Apollo.MutationResult<CreateViewFieldMutation>;
|
||||||
|
export type CreateViewFieldMutationOptions = Apollo.BaseMutationOptions<CreateViewFieldMutation, CreateViewFieldMutationVariables>;
|
||||||
export const CreateViewFieldsDocument = gql`
|
export const CreateViewFieldsDocument = gql`
|
||||||
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
|
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
|
||||||
createManyViewField(data: $data) {
|
createManyViewField(data: $data) {
|
||||||
|
|||||||
@ -13,18 +13,21 @@ export function CompanyTableMockData() {
|
|||||||
const setEntityTableDimensions = useSetRecoilState(
|
const setEntityTableDimensions = useSetRecoilState(
|
||||||
entityTableDimensionsState,
|
entityTableDimensionsState,
|
||||||
);
|
);
|
||||||
const setViewFields = useSetRecoilState(viewFieldsState);
|
const setViewFieldsState = useSetRecoilState(viewFieldsState);
|
||||||
const setEntityTableData = useSetEntityTableData();
|
const setEntityTableData = useSetEntityTableData();
|
||||||
|
|
||||||
setEntityTableData(mockedCompaniesData, []);
|
setEntityTableData(mockedCompaniesData, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewFields(companyViewFields);
|
setViewFieldsState({
|
||||||
|
objectName: 'company',
|
||||||
|
viewFields: companyViewFields,
|
||||||
|
});
|
||||||
setEntityTableDimensions((prevState) => ({
|
setEntityTableDimensions((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
numberOfColumns: companyViewFields.length,
|
numberOfColumns: companyViewFields.length,
|
||||||
}));
|
}));
|
||||||
}, [setEntityTableDimensions, setViewFields]);
|
}, [setEntityTableDimensions, setViewFieldsState]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { type ComponentProps } from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
import { IconButtonSize, IconButtonVariant } from './IconButton';
|
import type { IconButtonSize, IconButtonVariant } from './IconButton';
|
||||||
|
|
||||||
const StyledIconButtonGroupContainer = styled.div`
|
const StyledIconButtonGroupContainer = styled.div`
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
background: ${({ theme }) => theme.background.transparent.primary};
|
background: ${({ theme }) => theme.background.transparent.primary};
|
||||||
@ -11,24 +12,27 @@ const StyledIconButtonGroupContainer = styled.div`
|
|||||||
padding: ${({ theme }) => theme.spacing(0.5)};
|
padding: ${({ theme }) => theme.spacing(0.5)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type IconButtonGroupProps = {
|
type IconButtonGroupProps = Omit<ComponentProps<'div'>, 'children'> & {
|
||||||
variant: IconButtonVariant;
|
variant: IconButtonVariant;
|
||||||
size: IconButtonSize;
|
size: IconButtonSize;
|
||||||
children: React.ReactElement[];
|
children: React.ReactElement | React.ReactElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IconButtonGroup({
|
export function IconButtonGroup({
|
||||||
children,
|
children,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
|
...props
|
||||||
}: IconButtonGroupProps) {
|
}: IconButtonGroupProps) {
|
||||||
return (
|
return (
|
||||||
<StyledIconButtonGroupContainer>
|
<StyledIconButtonGroupContainer {...props}>
|
||||||
{React.Children.map(children, (child) =>
|
{React.Children.map(
|
||||||
React.cloneElement(child, {
|
Array.isArray(children) ? children : [children],
|
||||||
...(variant ? { variant } : {}),
|
(child) =>
|
||||||
...(size ? { size } : {}),
|
React.cloneElement(child, {
|
||||||
}),
|
...(variant ? { variant } : {}),
|
||||||
|
...(size ? { size } : {}),
|
||||||
|
}),
|
||||||
)}
|
)}
|
||||||
</StyledIconButtonGroupContainer>
|
</StyledIconButtonGroupContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,7 +20,6 @@ const StyledTable = styled.table`
|
|||||||
margin-left: ${({ theme }) => theme.table.horizontalCellMargin};
|
margin-left: ${({ theme }) => theme.table.horizontalCellMargin};
|
||||||
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
|
margin-right: ${({ theme }) => theme.table.horizontalCellMargin};
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
width: calc(100% - ${({ theme }) => theme.table.horizontalCellMargin} * 2);
|
|
||||||
|
|
||||||
th {
|
th {
|
||||||
border: 1px solid ${({ theme }) => theme.border.color.light};
|
border: 1px solid ${({ theme }) => theme.border.color.light};
|
||||||
@ -37,7 +36,7 @@ const StyledTable = styled.table`
|
|||||||
border-right-color: transparent;
|
border-right-color: transparent;
|
||||||
}
|
}
|
||||||
:last-of-type {
|
:last-of-type {
|
||||||
min-width: 0;
|
min-width: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -58,7 +57,7 @@ const StyledTable = styled.table`
|
|||||||
border-right-color: transparent;
|
border-right-color: transparent;
|
||||||
}
|
}
|
||||||
:last-of-type {
|
:last-of-type {
|
||||||
min-width: 0;
|
min-width: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import React, { ComponentProps, useRef } from 'react';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { IconButton } from '@/ui/button/components/IconButton';
|
||||||
|
import { IconButtonGroup } from '@/ui/button/components/IconButtonGroup';
|
||||||
|
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
|
||||||
|
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
|
||||||
|
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
|
||||||
|
import { IconPlus } from '@/ui/icon';
|
||||||
|
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ViewFieldDefinition,
|
||||||
|
ViewFieldMetadata,
|
||||||
|
} from '../types/ViewField';
|
||||||
|
|
||||||
|
const StyledColumnMenu = styled(DropdownMenu)`
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledIconButtonGroup = styled(IconButtonGroup)`
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: ${({ theme }) => theme.spacing(1)};
|
||||||
|
`;
|
||||||
|
const styledIconButtonGroupClassName = 'column-menu-item-icon-button-group';
|
||||||
|
|
||||||
|
const StyledColumnMenuItem = styled(DropdownMenuItem)`
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.${styledIconButtonGroupClassName} {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type EntityTableColumnMenuProps = {
|
||||||
|
onAddViewField: (
|
||||||
|
viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>,
|
||||||
|
) => void;
|
||||||
|
onClickOutside?: () => void;
|
||||||
|
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
|
||||||
|
} & ComponentProps<'div'>;
|
||||||
|
|
||||||
|
export const EntityTableColumnMenu = ({
|
||||||
|
onAddViewField,
|
||||||
|
onClickOutside = () => undefined,
|
||||||
|
viewFieldDefinitions,
|
||||||
|
...props
|
||||||
|
}: EntityTableColumnMenuProps) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
useListenClickOutside({
|
||||||
|
refs: [ref],
|
||||||
|
callback: onClickOutside,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledColumnMenu {...props} ref={ref}>
|
||||||
|
<DropdownMenuItemsContainer>
|
||||||
|
{viewFieldDefinitions.map((viewFieldDefinition) => (
|
||||||
|
<StyledColumnMenuItem key={viewFieldDefinition.id}>
|
||||||
|
{viewFieldDefinition.columnIcon &&
|
||||||
|
React.cloneElement(viewFieldDefinition.columnIcon, {
|
||||||
|
size: theme.icon.size.md,
|
||||||
|
})}
|
||||||
|
{viewFieldDefinition.columnLabel}
|
||||||
|
<StyledIconButtonGroup
|
||||||
|
className={styledIconButtonGroupClassName}
|
||||||
|
variant="transparent"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon={<IconPlus size={theme.icon.size.sm} />}
|
||||||
|
onClick={() => onAddViewField(viewFieldDefinition)}
|
||||||
|
/>
|
||||||
|
</StyledIconButtonGroup>
|
||||||
|
</StyledColumnMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuItemsContainer>
|
||||||
|
</StyledColumnMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,25 +1,44 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
import { getOperationName } from '@apollo/client/utilities';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import {
|
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
|
||||||
useRecoilCallback,
|
|
||||||
useRecoilState,
|
|
||||||
useRecoilValue,
|
|
||||||
useSetRecoilState,
|
|
||||||
} from 'recoil';
|
|
||||||
|
|
||||||
|
import { IconButton } from '@/ui/button/components/IconButton';
|
||||||
|
import { IconPlus } from '@/ui/icon';
|
||||||
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
|
||||||
import { useUpdateViewFieldMutation } from '~/generated/graphql';
|
import { GET_VIEW_FIELDS } from '@/views/queries/select';
|
||||||
|
import {
|
||||||
|
useCreateViewFieldMutation,
|
||||||
|
useUpdateViewFieldMutation,
|
||||||
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
|
import { toViewFieldInput } from '../hooks/useLoadView';
|
||||||
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
|
import { resizeFieldOffsetState } from '../states/resizeFieldOffsetState';
|
||||||
import { viewFieldsState } from '../states/viewFieldsState';
|
import {
|
||||||
|
addableViewFieldDefinitionsState,
|
||||||
|
columnWidthByViewFieldIdState,
|
||||||
|
viewFieldsState,
|
||||||
|
} from '../states/viewFieldsState';
|
||||||
|
import type {
|
||||||
|
ViewFieldDefinition,
|
||||||
|
ViewFieldMetadata,
|
||||||
|
} from '../types/ViewField';
|
||||||
|
|
||||||
import { ColumnHead } from './ColumnHead';
|
import { ColumnHead } from './ColumnHead';
|
||||||
|
import { EntityTableColumnMenu } from './EntityTableColumnMenu';
|
||||||
import { SelectAllCheckbox } from './SelectAllCheckbox';
|
import { SelectAllCheckbox } from './SelectAllCheckbox';
|
||||||
|
|
||||||
const COLUMN_MIN_WIDTH = 75;
|
const COLUMN_MIN_WIDTH = 75;
|
||||||
|
|
||||||
const StyledColumnHeaderCell = styled.th<{ isResizing?: boolean }>`
|
const StyledColumnHeaderCell = styled.th<{
|
||||||
min-width: ${COLUMN_MIN_WIDTH}px;
|
columnWidth: number;
|
||||||
|
isResizing?: boolean;
|
||||||
|
}>`
|
||||||
|
${({ columnWidth }) => `
|
||||||
|
min-width: ${columnWidth}px;
|
||||||
|
width: ${columnWidth}px;
|
||||||
|
`}
|
||||||
position: relative;
|
position: relative;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
${({ isResizing, theme }) => {
|
${({ isResizing, theme }) => {
|
||||||
@ -49,57 +68,48 @@ const StyledResizeHandler = styled.div`
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function EntityTableHeader() {
|
const StyledAddIconButtonWrapper = styled.div`
|
||||||
const viewFields = useRecoilValue(viewFieldsState);
|
display: inline-flex;
|
||||||
const setViewFields = useSetRecoilState(viewFieldsState);
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
|
const StyledAddIconButton = styled(IconButton)`
|
||||||
const columnWidths = useMemo(
|
border-radius: 0;
|
||||||
() =>
|
`;
|
||||||
viewFields.reduce<Record<string, number>>(
|
|
||||||
(result, viewField) => ({
|
const StyledEntityTableColumnMenu = styled(EntityTableColumnMenu)`
|
||||||
...result,
|
position: absolute;
|
||||||
[viewField.id]: viewField.columnSize,
|
right: 0;
|
||||||
}),
|
top: 100%;
|
||||||
{},
|
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||||
),
|
`;
|
||||||
[viewFields],
|
|
||||||
|
export function EntityTableHeader() {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const [{ objectName, viewFields }, setViewFieldsState] =
|
||||||
|
useRecoilState(viewFieldsState);
|
||||||
|
const columnWidths = useRecoilValue(columnWidthByViewFieldIdState);
|
||||||
|
const addableViewFieldDefinitions = useRecoilValue(
|
||||||
|
addableViewFieldDefinitionsState,
|
||||||
);
|
);
|
||||||
|
const [offset, setOffset] = useRecoilState(resizeFieldOffsetState);
|
||||||
|
|
||||||
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
|
const [initialPointerPositionX, setInitialPointerPositionX] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const [resizedFieldId, setResizedFieldId] = useState<string | null>(null);
|
const [resizedFieldId, setResizedFieldId] = useState<string | null>(null);
|
||||||
const [offset, setOffset] = useRecoilState(resizeFieldOffsetState);
|
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
||||||
|
|
||||||
const handleColumnResize = useCallback(
|
const [createViewFieldMutation] = useCreateViewFieldMutation();
|
||||||
(resizedFieldId: string, width: number) => {
|
const [updateViewFieldMutation] = useUpdateViewFieldMutation();
|
||||||
setViewFields((previousViewFields) =>
|
|
||||||
previousViewFields.map((viewField) =>
|
|
||||||
viewField.id === resizedFieldId
|
|
||||||
? { ...viewField, columnSize: width }
|
|
||||||
: viewField,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
updateViewFieldMutation({
|
|
||||||
variables: {
|
|
||||||
data: { sizeInPx: width },
|
|
||||||
where: { id: resizedFieldId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setViewFields, updateViewFieldMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResizeHandlerStart = useCallback(
|
const handleResizeHandlerStart = useCallback((positionX: number) => {
|
||||||
(positionX: number, _: number) => {
|
setInitialPointerPositionX(positionX);
|
||||||
setInitialPointerPositionX(positionX);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleResizeHandlerMove = useCallback(
|
const handleResizeHandlerMove = useCallback(
|
||||||
(positionX: number, _positionY: number) => {
|
(positionX: number) => {
|
||||||
if (!initialPointerPositionX) return;
|
if (!initialPointerPositionX) return;
|
||||||
setOffset(positionX - initialPointerPositionX);
|
setOffset(positionX - initialPointerPositionX);
|
||||||
},
|
},
|
||||||
@ -108,8 +118,9 @@ export function EntityTableHeader() {
|
|||||||
|
|
||||||
const handleResizeHandlerEnd = useRecoilCallback(
|
const handleResizeHandlerEnd = useRecoilCallback(
|
||||||
({ snapshot, set }) =>
|
({ snapshot, set }) =>
|
||||||
(_positionX: number, _positionY: number) => {
|
() => {
|
||||||
if (!resizedFieldId) return;
|
if (!resizedFieldId) return;
|
||||||
|
|
||||||
const nextWidth = Math.round(
|
const nextWidth = Math.round(
|
||||||
Math.max(
|
Math.max(
|
||||||
columnWidths[resizedFieldId] +
|
columnWidths[resizedFieldId] +
|
||||||
@ -119,13 +130,30 @@ export function EntityTableHeader() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextWidth !== columnWidths[resizedFieldId]) {
|
if (nextWidth !== columnWidths[resizedFieldId]) {
|
||||||
handleColumnResize(resizedFieldId, nextWidth);
|
// Optimistic update to avoid "bouncing width" visual effect on resize.
|
||||||
|
setViewFieldsState((previousState) => ({
|
||||||
|
...previousState,
|
||||||
|
viewFields: previousState.viewFields.map((viewField) =>
|
||||||
|
viewField.id === resizedFieldId
|
||||||
|
? { ...viewField, columnSize: nextWidth }
|
||||||
|
: viewField,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
updateViewFieldMutation({
|
||||||
|
variables: {
|
||||||
|
data: { sizeInPx: nextWidth },
|
||||||
|
where: { id: resizedFieldId },
|
||||||
|
},
|
||||||
|
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
set(resizeFieldOffsetState, 0);
|
set(resizeFieldOffsetState, 0);
|
||||||
setInitialPointerPositionX(null);
|
setInitialPointerPositionX(null);
|
||||||
setResizedFieldId(null);
|
setResizedFieldId(null);
|
||||||
},
|
},
|
||||||
[resizedFieldId, columnWidths, setResizedFieldId, handleColumnResize],
|
[resizedFieldId, columnWidths, setResizedFieldId],
|
||||||
);
|
);
|
||||||
|
|
||||||
useTrackPointer({
|
useTrackPointer({
|
||||||
@ -135,6 +163,29 @@ export function EntityTableHeader() {
|
|||||||
onMouseUp: handleResizeHandlerEnd,
|
onMouseUp: handleResizeHandlerEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleColumnMenu = useCallback(() => {
|
||||||
|
setIsColumnMenuOpen((previousValue) => !previousValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddViewField = useCallback(
|
||||||
|
(viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>) => {
|
||||||
|
setIsColumnMenuOpen(false);
|
||||||
|
|
||||||
|
if (!objectName) return;
|
||||||
|
|
||||||
|
createViewFieldMutation({
|
||||||
|
variables: {
|
||||||
|
data: toViewFieldInput(objectName, {
|
||||||
|
...viewFieldDefinition,
|
||||||
|
columnOrder: viewFields.length + 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[createViewFieldMutation, objectName, viewFields.length],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -150,15 +201,13 @@ export function EntityTableHeader() {
|
|||||||
|
|
||||||
{viewFields.map((viewField) => (
|
{viewFields.map((viewField) => (
|
||||||
<StyledColumnHeaderCell
|
<StyledColumnHeaderCell
|
||||||
key={viewField.columnOrder.toString()}
|
key={viewField.id}
|
||||||
isResizing={resizedFieldId === viewField.id}
|
isResizing={resizedFieldId === viewField.id}
|
||||||
style={{
|
columnWidth={Math.max(
|
||||||
width: Math.max(
|
columnWidths[viewField.id] +
|
||||||
columnWidths[viewField.id] +
|
(resizedFieldId === viewField.id ? offset : 0),
|
||||||
(resizedFieldId === viewField.id ? offset : 0),
|
COLUMN_MIN_WIDTH,
|
||||||
COLUMN_MIN_WIDTH,
|
)}
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ColumnHead
|
<ColumnHead
|
||||||
viewName={viewField.columnLabel}
|
viewName={viewField.columnLabel}
|
||||||
@ -173,7 +222,24 @@ export function EntityTableHeader() {
|
|||||||
/>
|
/>
|
||||||
</StyledColumnHeaderCell>
|
</StyledColumnHeaderCell>
|
||||||
))}
|
))}
|
||||||
<th></th>
|
<th>
|
||||||
|
{addableViewFieldDefinitions.length > 0 && (
|
||||||
|
<StyledAddIconButtonWrapper>
|
||||||
|
<StyledAddIconButton
|
||||||
|
size="large"
|
||||||
|
icon={<IconPlus size={theme.icon.size.md} />}
|
||||||
|
onClick={toggleColumnMenu}
|
||||||
|
/>
|
||||||
|
{isColumnMenuOpen && (
|
||||||
|
<StyledEntityTableColumnMenu
|
||||||
|
onAddViewField={handleAddViewField}
|
||||||
|
onClickOutside={toggleColumnMenu}
|
||||||
|
viewFieldDefinitions={addableViewFieldDefinitions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledAddIconButtonWrapper>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const StyledRow = styled.tr<{ selected: boolean }>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export function EntityTableRow({ rowId }: { rowId: string }) {
|
export function EntityTableRow({ rowId }: { rowId: string }) {
|
||||||
const viewFields = useRecoilValue(viewFieldsState);
|
const { viewFields } = useRecoilValue(viewFieldsState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledRow data-testid={`row-id-${rowId}`} selected={false}>
|
<StyledRow data-testid={`row-id-${rowId}`} selected={false}>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
|
import { entityTableDimensionsState } from '../states/entityTableDimensionsState';
|
||||||
import { viewFieldsState } from '../states/viewFieldsState';
|
import { viewFieldsState } from '../states/viewFieldsState';
|
||||||
import {
|
import type {
|
||||||
ViewFieldDefinition,
|
ViewFieldDefinition,
|
||||||
ViewFieldMetadata,
|
ViewFieldMetadata,
|
||||||
ViewFieldTextMetadata,
|
ViewFieldTextMetadata,
|
||||||
@ -22,6 +22,17 @@ const DEFAULT_VIEW_FIELD_METADATA: ViewFieldTextMetadata = {
|
|||||||
fieldName: '',
|
fieldName: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toViewFieldInput = (
|
||||||
|
objectName: 'company' | 'person',
|
||||||
|
viewFieldDefinition: ViewFieldDefinition<ViewFieldMetadata>,
|
||||||
|
) => ({
|
||||||
|
fieldName: viewFieldDefinition.columnLabel,
|
||||||
|
index: viewFieldDefinition.columnOrder,
|
||||||
|
isVisible: true,
|
||||||
|
objectName,
|
||||||
|
sizeInPx: viewFieldDefinition.columnSize,
|
||||||
|
});
|
||||||
|
|
||||||
export const useLoadView = ({
|
export const useLoadView = ({
|
||||||
objectName,
|
objectName,
|
||||||
viewFieldDefinitions,
|
viewFieldDefinitions,
|
||||||
@ -32,7 +43,7 @@ export const useLoadView = ({
|
|||||||
const setEntityTableDimensions = useSetRecoilState(
|
const setEntityTableDimensions = useSetRecoilState(
|
||||||
entityTableDimensionsState,
|
entityTableDimensionsState,
|
||||||
);
|
);
|
||||||
const setViewFields = useSetRecoilState(viewFieldsState);
|
const setViewFieldsState = useSetRecoilState(viewFieldsState);
|
||||||
|
|
||||||
const [createViewFieldsMutation] = useCreateViewFieldsMutation();
|
const [createViewFieldsMutation] = useCreateViewFieldsMutation();
|
||||||
|
|
||||||
@ -43,36 +54,33 @@ export const useLoadView = ({
|
|||||||
},
|
},
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
if (data.viewFields.length) {
|
if (data.viewFields.length) {
|
||||||
setViewFields(
|
const viewFields = data.viewFields.map<
|
||||||
data.viewFields.map<ViewFieldDefinition<ViewFieldMetadata>>(
|
ViewFieldDefinition<ViewFieldMetadata>
|
||||||
(viewField) => ({
|
>((viewField) => ({
|
||||||
...(viewFieldDefinitions.find(
|
...(viewFieldDefinitions.find(
|
||||||
({ columnLabel }) => viewField.fieldName === columnLabel,
|
({ columnLabel }) => viewField.fieldName === columnLabel,
|
||||||
) || { metadata: DEFAULT_VIEW_FIELD_METADATA }),
|
) || { metadata: DEFAULT_VIEW_FIELD_METADATA }),
|
||||||
id: viewField.id,
|
id: viewField.id,
|
||||||
columnLabel: viewField.fieldName,
|
columnLabel: viewField.fieldName,
|
||||||
columnOrder: viewField.index,
|
columnOrder: viewField.index,
|
||||||
columnSize: viewField.sizeInPx,
|
columnSize: viewField.sizeInPx,
|
||||||
}),
|
}));
|
||||||
),
|
|
||||||
);
|
setViewFieldsState({ objectName, viewFields });
|
||||||
setEntityTableDimensions((prevState) => ({
|
setEntityTableDimensions((prevState) => ({
|
||||||
...prevState,
|
...prevState,
|
||||||
numberOfColumns: data.viewFields.length,
|
numberOfColumns: data.viewFields.length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate if empty
|
// Populate if empty
|
||||||
createViewFieldsMutation({
|
createViewFieldsMutation({
|
||||||
variables: {
|
variables: {
|
||||||
data: viewFieldDefinitions.map((viewFieldDefinition) => ({
|
data: viewFieldDefinitions.map((viewFieldDefinition) =>
|
||||||
fieldName: viewFieldDefinition.columnLabel,
|
toViewFieldInput(objectName, viewFieldDefinition),
|
||||||
index: viewFieldDefinition.columnOrder,
|
),
|
||||||
isVisible: true,
|
|
||||||
objectName,
|
|
||||||
sizeInPx: viewFieldDefinition.columnSize,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
|
refetchQueries: [getOperationName(GET_VIEW_FIELDS) ?? ''],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,49 @@
|
|||||||
import { atom } from 'recoil';
|
import { atom, selector } from 'recoil';
|
||||||
|
|
||||||
import { ViewFieldDefinition, ViewFieldMetadata } from '../types/ViewField';
|
import { companyViewFields } from '@/companies/constants/companyViewFields';
|
||||||
|
import { peopleViewFields } from '@/people/constants/peopleViewFields';
|
||||||
|
|
||||||
export const viewFieldsState = atom<ViewFieldDefinition<ViewFieldMetadata>[]>({
|
import type {
|
||||||
|
ViewFieldDefinition,
|
||||||
|
ViewFieldMetadata,
|
||||||
|
} from '../types/ViewField';
|
||||||
|
|
||||||
|
export const viewFieldsState = atom<{
|
||||||
|
objectName: 'company' | 'person' | '';
|
||||||
|
viewFields: ViewFieldDefinition<ViewFieldMetadata>[];
|
||||||
|
}>({
|
||||||
key: 'viewFieldsState',
|
key: 'viewFieldsState',
|
||||||
default: [],
|
default: { objectName: '', viewFields: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const columnWidthByViewFieldIdState = selector({
|
||||||
|
key: 'columnWidthByViewFieldIdState',
|
||||||
|
get: ({ get }) =>
|
||||||
|
get(viewFieldsState).viewFields.reduce<Record<string, number>>(
|
||||||
|
(result, viewField) => ({
|
||||||
|
...result,
|
||||||
|
[viewField.id]: viewField.columnSize,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addableViewFieldDefinitionsState = selector({
|
||||||
|
key: 'addableViewFieldDefinitionsState',
|
||||||
|
get: ({ get }) => {
|
||||||
|
const { objectName, viewFields } = get(viewFieldsState);
|
||||||
|
|
||||||
|
if (!objectName) return [];
|
||||||
|
|
||||||
|
const existingColumnLabels = viewFields.map(
|
||||||
|
(viewField) => viewField.columnLabel,
|
||||||
|
);
|
||||||
|
const viewFieldDefinitions =
|
||||||
|
objectName === 'company' ? companyViewFields : peopleViewFields;
|
||||||
|
|
||||||
|
return viewFieldDefinitions.filter(
|
||||||
|
(viewFieldDefinition) =>
|
||||||
|
!existingColumnLabels.includes(viewFieldDefinition.columnLabel),
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const CREATE_VIEW_FIELD = gql`
|
||||||
|
mutation CreateViewField($data: ViewFieldCreateInput!) {
|
||||||
|
createOneViewField(data: $data) {
|
||||||
|
id
|
||||||
|
fieldName
|
||||||
|
isVisible
|
||||||
|
sizeInPx
|
||||||
|
index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const CREATE_VIEW_FIELDS = gql`
|
export const CREATE_VIEW_FIELDS = gql`
|
||||||
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
|
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
|
||||||
createManyViewField(data: $data) {
|
createManyViewField(data: $data) {
|
||||||
|
|||||||
@ -25,12 +25,33 @@ import {
|
|||||||
import { UserAbility } from 'src/decorators/user-ability.decorator';
|
import { UserAbility } from 'src/decorators/user-ability.decorator';
|
||||||
import { AbilityGuard } from 'src/guards/ability.guard';
|
import { AbilityGuard } from 'src/guards/ability.guard';
|
||||||
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
||||||
|
import { CreateOneViewFieldArgs } from 'src/core/@generated/view-field/create-one-view-field.args';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Resolver(() => ViewField)
|
@Resolver(() => ViewField)
|
||||||
export class ViewFieldResolver {
|
export class ViewFieldResolver {
|
||||||
constructor(private readonly viewFieldService: ViewFieldService) {}
|
constructor(private readonly viewFieldService: ViewFieldService) {}
|
||||||
|
|
||||||
|
@Mutation(() => ViewField, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@UseGuards(AbilityGuard)
|
||||||
|
@CheckAbilities(CreateViewFieldAbilityHandler)
|
||||||
|
async createOneViewField(
|
||||||
|
@Args() args: CreateOneViewFieldArgs,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@PrismaSelector({ modelName: 'ViewField' })
|
||||||
|
prismaSelect: PrismaSelect<'ViewField'>,
|
||||||
|
): Promise<Partial<ViewField>> {
|
||||||
|
return this.viewFieldService.create({
|
||||||
|
data: {
|
||||||
|
...args.data,
|
||||||
|
workspace: { connect: { id: workspace.id } },
|
||||||
|
},
|
||||||
|
select: prismaSelect.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => AffectedRows)
|
@Mutation(() => AffectedRows)
|
||||||
@UseGuards(AbilityGuard)
|
@UseGuards(AbilityGuard)
|
||||||
@CheckAbilities(CreateViewFieldAbilityHandler)
|
@CheckAbilities(CreateViewFieldAbilityHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user