feat: Column title menus (#1616)

* view field index to float

* gql codegen and package.json

* list implementation

* db call

* reposition logic

* lint fix

* edge case fix

* review changes

* handleColumnMove refactor

* dropdown recoil scope

* rename props

* Update server/src/database/migrations/20230727124244_add_view_fields_table/migration.sql

---------

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
Aditya Pimpalkar
2023-09-19 23:27:02 +01:00
committed by GitHub
parent fc139f89ab
commit 321488ad3c
13 changed files with 253 additions and 63 deletions

View File

@ -1016,15 +1016,15 @@ export enum FileFolder {
WorkspaceLogo = 'WorkspaceLogo' WorkspaceLogo = 'WorkspaceLogo'
} }
export type IntFilter = { export type FloatFilter = {
equals?: InputMaybe<Scalars['Int']>; equals?: InputMaybe<Scalars['Float']>;
gt?: InputMaybe<Scalars['Int']>; gt?: InputMaybe<Scalars['Float']>;
gte?: InputMaybe<Scalars['Int']>; gte?: InputMaybe<Scalars['Float']>;
in?: InputMaybe<Array<Scalars['Int']>>; in?: InputMaybe<Array<Scalars['Float']>>;
lt?: InputMaybe<Scalars['Int']>; lt?: InputMaybe<Scalars['Float']>;
lte?: InputMaybe<Scalars['Int']>; lte?: InputMaybe<Scalars['Float']>;
not?: InputMaybe<NestedIntFilter>; not?: InputMaybe<NestedFloatFilter>;
notIn?: InputMaybe<Array<Scalars['Int']>>; notIn?: InputMaybe<Array<Scalars['Float']>>;
}; };
export type IntNullableFilter = { export type IntNullableFilter = {
@ -1478,15 +1478,15 @@ export type NestedEnumViewTypeFilter = {
notIn?: InputMaybe<Array<ViewType>>; notIn?: InputMaybe<Array<ViewType>>;
}; };
export type NestedIntFilter = { export type NestedFloatFilter = {
equals?: InputMaybe<Scalars['Int']>; equals?: InputMaybe<Scalars['Float']>;
gt?: InputMaybe<Scalars['Int']>; gt?: InputMaybe<Scalars['Float']>;
gte?: InputMaybe<Scalars['Int']>; gte?: InputMaybe<Scalars['Float']>;
in?: InputMaybe<Array<Scalars['Int']>>; in?: InputMaybe<Array<Scalars['Float']>>;
lt?: InputMaybe<Scalars['Int']>; lt?: InputMaybe<Scalars['Float']>;
lte?: InputMaybe<Scalars['Int']>; lte?: InputMaybe<Scalars['Float']>;
not?: InputMaybe<NestedIntFilter>; not?: InputMaybe<NestedFloatFilter>;
notIn?: InputMaybe<Array<Scalars['Int']>>; notIn?: InputMaybe<Array<Scalars['Float']>>;
}; };
export type NestedIntNullableFilter = { export type NestedIntNullableFilter = {
@ -2587,7 +2587,7 @@ export type ViewCreateNestedOneWithoutFieldsInput = {
export type ViewField = { export type ViewField = {
__typename?: 'ViewField'; __typename?: 'ViewField';
index: Scalars['Int']; index: Scalars['Float'];
isVisible: Scalars['Boolean']; isVisible: Scalars['Boolean'];
key: Scalars['String']; key: Scalars['String'];
name: Scalars['String']; name: Scalars['String'];
@ -2598,7 +2598,7 @@ export type ViewField = {
}; };
export type ViewFieldCreateInput = { export type ViewFieldCreateInput = {
index: Scalars['Int']; index: Scalars['Float'];
isVisible: Scalars['Boolean']; isVisible: Scalars['Boolean'];
key: Scalars['String']; key: Scalars['String'];
name: Scalars['String']; name: Scalars['String'];
@ -2608,7 +2608,7 @@ export type ViewFieldCreateInput = {
}; };
export type ViewFieldCreateManyInput = { export type ViewFieldCreateManyInput = {
index: Scalars['Int']; index: Scalars['Float'];
isVisible: Scalars['Boolean']; isVisible: Scalars['Boolean'];
key: Scalars['String']; key: Scalars['String'];
name: Scalars['String']; name: Scalars['String'];
@ -2654,7 +2654,7 @@ export enum ViewFieldScalarFieldEnum {
} }
export type ViewFieldUpdateInput = { export type ViewFieldUpdateInput = {
index?: InputMaybe<Scalars['Int']>; index?: InputMaybe<Scalars['Float']>;
isVisible?: InputMaybe<Scalars['Boolean']>; isVisible?: InputMaybe<Scalars['Boolean']>;
key?: InputMaybe<Scalars['String']>; key?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>; name?: InputMaybe<Scalars['String']>;
@ -2684,7 +2684,7 @@ export type ViewFieldWhereInput = {
AND?: InputMaybe<Array<ViewFieldWhereInput>>; AND?: InputMaybe<Array<ViewFieldWhereInput>>;
NOT?: InputMaybe<Array<ViewFieldWhereInput>>; NOT?: InputMaybe<Array<ViewFieldWhereInput>>;
OR?: InputMaybe<Array<ViewFieldWhereInput>>; OR?: InputMaybe<Array<ViewFieldWhereInput>>;
index?: InputMaybe<IntFilter>; index?: InputMaybe<FloatFilter>;
isVisible?: InputMaybe<BoolFilter>; isVisible?: InputMaybe<BoolFilter>;
key?: InputMaybe<StringFilter>; key?: InputMaybe<StringFilter>;
name?: InputMaybe<StringFilter>; name?: InputMaybe<StringFilter>;

View File

@ -12,7 +12,7 @@ import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScop
import { DropdownCloseEffect } from './DropdownCloseEffect'; import { DropdownCloseEffect } from './DropdownCloseEffect';
type OwnProps = { type OwnProps = {
buttonComponents: JSX.Element | JSX.Element[]; buttonComponents?: JSX.Element | JSX.Element[];
dropdownComponents: JSX.Element | JSX.Element[]; dropdownComponents: JSX.Element | JSX.Element[];
dropdownId: string; dropdownId: string;
hotkey?: { hotkey?: {
@ -75,7 +75,9 @@ export const DropdownButton = ({
onHotkeyTriggered={handleHotkeyTriggered} onHotkeyTriggered={handleHotkeyTriggered}
/> />
)} )}
<div ref={refs.setReference}>{buttonComponents}</div> {buttonComponents && (
<div ref={refs.setReference}>{buttonComponents}</div>
)}
{isDropdownButtonOpen && ( {isDropdownButtonOpen && (
<div ref={refs.setFloating} style={floatingStyles}> <div ref={refs.setFloating} style={floatingStyles}>
{dropdownComponents} {dropdownComponents}

View File

@ -5,6 +5,10 @@ export {
IconAlertTriangle, IconAlertTriangle,
IconArchive, IconArchive,
IconArrowBack, IconArrowBack,
IconArrowNarrowDown,
IconArrowNarrowLeft,
IconArrowNarrowRight,
IconArrowNarrowUp,
IconArrowDown, IconArrowDown,
IconArrowRight, IconArrowRight,
IconArrowUp, IconArrowUp,

View File

@ -1,11 +1,18 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent } from '@/ui/icon/types/IconComponent'; import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
import { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField';
import { ColumnHeaderDropdownId } from '../constants/ColumnHeaderDropdownId';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { EntityTableHeaderOptions } from './EntityTableHeaderOptions';
type OwnProps = { type OwnProps = {
viewName: string; column: ColumnDefinition<ViewFieldMetadata>;
ViewIcon?: IconComponent; isFirstColumn: boolean;
isLastColumn: boolean;
}; };
const StyledTitle = styled.div` const StyledTitle = styled.div`
@ -34,14 +41,30 @@ const StyledText = styled.span`
white-space: nowrap; white-space: nowrap;
`; `;
export const ColumnHead = ({ viewName, ViewIcon }: OwnProps) => { export const ColumnHead = ({
column,
isFirstColumn,
isLastColumn,
}: OwnProps) => {
const theme = useTheme(); const theme = useTheme();
const { openDropdownButton } = useDropdownButton({
dropdownId: ColumnHeaderDropdownId,
});
return ( return (
<StyledTitle> <>
<StyledIcon> <StyledTitle onClick={openDropdownButton}>
{ViewIcon && <ViewIcon size={theme.icon.size.md} />} <StyledIcon>
</StyledIcon> {column.Icon && <column.Icon size={theme.icon.size.md} />}
<StyledText>{viewName}</StyledText> </StyledIcon>
</StyledTitle> <StyledText>{column.name}</StyledText>
</StyledTitle>
<EntityTableHeaderOptions
column={column}
isFirstColumn={isFirstColumn}
isLastColumn={isLastColumn}
/>
</>
); );
}; };

View File

@ -3,8 +3,10 @@ import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilState } from 'recoil'; import { useRecoilCallback, useRecoilState } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton'; import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext';
import { IconPlus } from '@/ui/icon'; import { IconPlus } from '@/ui/icon';
import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer'; import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
@ -163,25 +165,31 @@ export const EntityTableHeader = () => {
<SelectAllCheckbox /> <SelectAllCheckbox />
</th> </th>
{visibleTableColumns.map((column) => ( {visibleTableColumns.map((column, index) => (
<StyledColumnHeaderCell <RecoilScope CustomRecoilScopeContext={DropdownRecoilScopeContext}>
key={column.key} <StyledColumnHeaderCell
isResizing={resizedFieldKey === column.key} key={column.key}
columnWidth={Math.max( isResizing={resizedFieldKey === column.key}
tableColumnsByKey[column.key].size + columnWidth={Math.max(
(resizedFieldKey === column.key ? resizeFieldOffset : 0), tableColumnsByKey[column.key].size +
COLUMN_MIN_WIDTH, (resizedFieldKey === column.key ? resizeFieldOffset : 0),
)} COLUMN_MIN_WIDTH,
> )}
<ColumnHead viewName={column.name} ViewIcon={column.Icon} /> >
<StyledResizeHandler <ColumnHead
className="cursor-col-resize" column={column}
role="separator" isFirstColumn={index === 0}
onPointerDown={() => { isLastColumn={index === visibleTableColumns.length - 1}
setResizedFieldKey(column.key); />
}} <StyledResizeHandler
/> className="cursor-col-resize"
</StyledColumnHeaderCell> role="separator"
onPointerDown={() => {
setResizedFieldKey(column.key);
}}
/>
</StyledColumnHeaderCell>
</RecoilScope>
))} ))}
<th> <th>
{hiddenTableColumns.length > 0 && ( {hiddenTableColumns.length > 0 && (

View File

@ -0,0 +1,96 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { DropdownButton } from '@/ui/dropdown/components/DropdownButton';
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
import { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField';
import {
IconArrowNarrowLeft,
IconArrowNarrowRight,
IconEyeOff,
} from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { ColumnHeaderDropdownId } from '../constants/ColumnHeaderDropdownId';
import { useTableColumns } from '../hooks/useTableColumns';
import { ColumnDefinition } from '../types/ColumnDefinition';
const StyledDropdownContainer = styled.div`
left: 0px;
position: absolute;
top: 32px;
z-index: 1;
`;
type EntityTableHeaderOptionsProps = {
column: ColumnDefinition<ViewFieldMetadata>;
isFirstColumn: boolean;
isLastColumn: boolean;
};
export const EntityTableHeaderOptions = ({
column,
isFirstColumn,
isLastColumn,
}: EntityTableHeaderOptionsProps) => {
const theme = useTheme();
const {
handleColumnVisibilityChange,
handleColumnLeftMove,
handleColumnRightMove,
} = useTableColumns();
const { closeDropdownButton } = useDropdownButton({
dropdownId: ColumnHeaderDropdownId,
});
const handleColumnMoveLeft = () => {
closeDropdownButton();
if (isFirstColumn) return;
else handleColumnLeftMove(column);
};
const handleColumnMoveRight = () => {
closeDropdownButton();
if (isLastColumn) return;
else handleColumnRightMove(column);
};
const handleColumnVisibility = () => {
handleColumnVisibilityChange(column);
};
return (
<StyledDropdownContainer>
<DropdownButton
dropdownId={ColumnHeaderDropdownId}
dropdownComponents={
<StyledDropdownMenu>
<StyledDropdownMenuItemsContainer>
<MenuItem
LeftIcon={() => (
<IconArrowNarrowLeft size={theme.icon.size.md} />
)}
onClick={handleColumnMoveLeft}
text="Move left"
/>
<MenuItem
LeftIcon={() => (
<IconArrowNarrowRight size={theme.icon.size.md} />
)}
onClick={handleColumnMoveRight}
text="Move right"
/>
<MenuItem
LeftIcon={() => <IconEyeOff size={theme.icon.size.md} />}
onClick={handleColumnVisibility}
text="Hide"
/>
</StyledDropdownMenuItemsContainer>
</StyledDropdownMenu>
}
/>
</StyledDropdownContainer>
);
};

View File

@ -0,0 +1 @@
export const ColumnHeaderDropdownId = 'table-header-options';

View File

@ -36,5 +36,53 @@ export const useTableColumns = () => {
[tableColumnsByKey, tableColumns, setTableColumns], [tableColumnsByKey, tableColumns, setTableColumns],
); );
return { handleColumnVisibilityChange }; const handleColumnMove = useCallback(
(direction: string, column: ColumnDefinition<ViewFieldMetadata>) => {
const tableColumnIndex = tableColumns.findIndex(
(tableColumn) => tableColumn.key === column.key,
);
if (tableColumnIndex >= 0) {
const currentColumn = tableColumns[tableColumnIndex];
const targetColumn =
direction === 'left'
? tableColumns[tableColumnIndex - 1]
: tableColumns[tableColumnIndex + 1];
const updatedColumns = tableColumns
.map((tableColumn) => {
switch (tableColumn.key) {
case targetColumn.key:
return { ...tableColumn, index: currentColumn.index };
case currentColumn.key:
return { ...tableColumn, index: targetColumn.index };
default:
return tableColumn;
}
})
.sort((columnA, columnB) => columnA.index - columnB.index);
setTableColumns(updatedColumns);
}
},
[tableColumns, setTableColumns],
);
const handleColumnLeftMove = useCallback(
(column: ColumnDefinition<ViewFieldMetadata>) => {
handleColumnMove('left', column);
},
[handleColumnMove],
);
const handleColumnRightMove = useCallback(
(column: ColumnDefinition<ViewFieldMetadata>) => {
handleColumnMove('right', column);
},
[handleColumnMove],
);
return {
handleColumnVisibilityChange,
handleColumnLeftMove,
handleColumnRightMove,
};
}; };

View File

@ -94,6 +94,7 @@ export const useTableViewFields = ({
data: { data: {
isVisible: column.isVisible, isVisible: column.isVisible,
size: column.size, size: column.size,
index: column.index,
}, },
where: { where: {
viewId_key: { key: column.key, viewId: currentViewId }, viewId_key: { key: column.key, viewId: currentViewId },
@ -177,5 +178,5 @@ export const useTableViewFields = ({
updateViewFields, updateViewFields,
]); ]);
return { createViewFields, persistColumns }; return { createViewFields, persistColumns, updateViewFields };
}; };

View File

@ -41,12 +41,12 @@ export const useTableViews = ({
type: ViewType.Table, type: ViewType.Table,
RecoilScopeContext: TableRecoilScopeContext, RecoilScopeContext: TableRecoilScopeContext,
}); });
const { createViewFields, persistColumns, updateViewFields } =
const { createViewFields, persistColumns } = useTableViewFields({ useTableViewFields({
objectId, objectId,
columnDefinitions, columnDefinitions,
skipFetch: isFetchingViews, skipFetch: isFetchingViews,
}); });
const { createViewFilters, persistFilters } = useViewFilters({ const { createViewFilters, persistFilters } = useViewFilters({
RecoilScopeContext: TableRecoilScopeContext, RecoilScopeContext: TableRecoilScopeContext,
@ -62,6 +62,7 @@ export const useTableViews = ({
await persistColumns(); await persistColumns();
await persistFilters(); await persistFilters();
await persistSorts(); await persistSorts();
await updateViewFields(tableColumns);
}; };
return { createView, deleteView, submitCurrentView, updateView }; return { createView, deleteView, submitCurrentView, updateView };

View File

@ -76,6 +76,7 @@
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"prisma-graphql-type-decimal": "^3.0.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^7.2.0", "rxjs": "^7.2.0",

View File

@ -685,7 +685,7 @@ model ViewSort {
} }
model ViewField { model ViewField {
index Int index Float
isVisible Boolean isVisible Boolean
key String key String
name String name String

View File

@ -7684,6 +7684,11 @@ pretty-format@^28.0.0, pretty-format@^28.1.3:
ansi-styles "^5.0.0" ansi-styles "^5.0.0"
react-is "^18.0.0" react-is "^18.0.0"
prisma-graphql-type-decimal@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/prisma-graphql-type-decimal/-/prisma-graphql-type-decimal-3.0.0.tgz#f3fa524b9e7aeab4fa0f969829bdddf199eff9fe"
integrity sha512-jrALv8ShVZoBBNyIOBCxwRoLM/DAOSD/OBKTWeJa9UigQXUTZniNXvLd4fLXwm+3v00A7cOZ1fFh3b74Ndgxhw==
prisma-nestjs-graphql@^18.0.2: prisma-nestjs-graphql@^18.0.2:
version "18.0.2" version "18.0.2"
resolved "https://registry.yarnpkg.com/prisma-nestjs-graphql/-/prisma-nestjs-graphql-18.0.2.tgz#852b9386d2c26bad0bd82254a5cc2e483a96d5b5" resolved "https://registry.yarnpkg.com/prisma-nestjs-graphql/-/prisma-nestjs-graphql-18.0.2.tgz#852b9386d2c26bad0bd82254a5cc2e483a96d5b5"