From 4aae22ab34e0fc5aaab8d98982d2a03caa413b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tha=C3=AFs?= Date: Wed, 30 Aug 2023 11:33:21 +0200 Subject: [PATCH] feat: allow adding available pre-defined table columns to views (#1371) * feat: allow adding available pre-defined table columns to views Closes #1360 * fix: allow creating views with the same name for the same table * refactor: code review - rename things - move handleColumnVisibilityChange to useTableColumns hook --- front/src/generated/graphql.tsx | 7 -- .../components/EntityTableColumnMenu.tsx | 20 +++++- .../ui/table/components/EntityTableHeader.tsx | 15 +---- .../modules/ui/table/hooks/useTableColumns.ts | 40 ++++++++++++ .../TableOptionsDropdownContent.tsx | 24 ++----- .../components/TableUpdateViewButtonGroup.tsx | 11 ++-- .../availableTableColumnsScopedState.ts | 13 ++++ .../hiddenTableColumnsScopedSelector.ts | 17 +++-- .../modules/views/hooks/useTableViewFields.ts | 64 +++++++++++++------ .../migration.sql | 2 + server/src/database/schema.prisma | 17 +++-- 11 files changed, 148 insertions(+), 82 deletions(-) create mode 100644 front/src/modules/ui/table/hooks/useTableColumns.ts create mode 100644 front/src/modules/ui/table/states/availableTableColumnsScopedState.ts create mode 100644 server/src/database/migrations/20230829120042_allow_views_with_same_name/migration.sql diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index a784a978f7..ec4bde2296 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -2805,13 +2805,6 @@ export type ViewWhereInput = { export type ViewWhereUniqueInput = { id?: InputMaybe; - workspaceId_type_objectId_name?: InputMaybe; -}; - -export type ViewWorkspaceIdTypeObjectIdNameCompoundUniqueInput = { - name: Scalars['String']; - objectId: Scalars['String']; - type: ViewType; }; export type Workspace = { diff --git a/front/src/modules/ui/table/components/EntityTableColumnMenu.tsx b/front/src/modules/ui/table/components/EntityTableColumnMenu.tsx index 51057b8731..b766be87e9 100644 --- a/front/src/modules/ui/table/components/EntityTableColumnMenu.tsx +++ b/front/src/modules/ui/table/components/EntityTableColumnMenu.tsx @@ -1,4 +1,4 @@ -import { cloneElement, ComponentProps, useRef } from 'react'; +import { cloneElement, type ComponentProps, useCallback, useRef } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -6,19 +6,22 @@ import { IconButton } from '@/ui/button/components/IconButton'; import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem'; import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu'; import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; +import type { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField'; import { IconPlus } from '@/ui/icon'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { useTableColumns } from '../hooks/useTableColumns'; import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext'; import { hiddenTableColumnsScopedSelector } from '../states/selectors/hiddenTableColumnsScopedSelector'; +import type { ColumnDefinition } from '../types/ColumnDefinition'; const StyledColumnMenu = styled(StyledDropdownMenu)` font-weight: ${({ theme }) => theme.font.weight.regular}; `; type EntityTableColumnMenuProps = { - onAddColumn: (columnId: string) => void; + onAddColumn?: () => void; onClickOutside?: () => void; } & ComponentProps<'div'>; @@ -40,6 +43,16 @@ export const EntityTableColumnMenu = ({ callback: onClickOutside, }); + const { handleColumnVisibilityChange } = useTableColumns(); + + const handleAddColumn = useCallback( + (column: ColumnDefinition) => { + onAddColumn?.(); + handleColumnVisibilityChange(column); + }, + [handleColumnVisibilityChange, onAddColumn], + ); + return ( @@ -48,8 +61,9 @@ export const EntityTableColumnMenu = ({ key={column.id} actions={[ } - onClick={() => onAddColumn(column.id)} + onClick={() => handleAddColumn(column)} />, ]} > diff --git a/front/src/modules/ui/table/components/EntityTableHeader.tsx b/front/src/modules/ui/table/components/EntityTableHeader.tsx index f1b0bff1c2..ea08c97141 100644 --- a/front/src/modules/ui/table/components/EntityTableHeader.tsx +++ b/front/src/modules/ui/table/components/EntityTableHeader.tsx @@ -148,19 +148,6 @@ export function EntityTableHeader() { setIsColumnMenuOpen((previousValue) => !previousValue); }, []); - const handleAddColumn = useCallback( - (columnId: string) => { - setIsColumnMenuOpen(false); - - const nextColumns = columns.map((column) => - column.id === columnId ? { ...column, isVisible: true } : column, - ); - - setColumns(nextColumns); - }, - [columns, setColumns], - ); - return ( @@ -204,7 +191,7 @@ export function EntityTableHeader() { /> {isColumnMenuOpen && ( )} diff --git a/front/src/modules/ui/table/hooks/useTableColumns.ts b/front/src/modules/ui/table/hooks/useTableColumns.ts new file mode 100644 index 0000000000..8dbd28952f --- /dev/null +++ b/front/src/modules/ui/table/hooks/useTableColumns.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import type { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField'; +import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; +import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; + +import { TableRecoilScopeContext } from '../states/recoil-scope-contexts/TableRecoilScopeContext'; +import { tableColumnsByIdScopedSelector } from '../states/selectors/tableColumnsByIdScopedSelector'; +import { tableColumnsScopedState } from '../states/tableColumnsScopedState'; +import type { ColumnDefinition } from '../types/ColumnDefinition'; + +export const useTableColumns = () => { + const [tableColumns, setTableColumns] = useRecoilScopedState( + tableColumnsScopedState, + TableRecoilScopeContext, + ); + const tableColumnsById = useRecoilScopedValue( + tableColumnsByIdScopedSelector, + TableRecoilScopeContext, + ); + + const handleColumnVisibilityChange = useCallback( + (column: ColumnDefinition) => { + const nextColumns = tableColumnsById[column.id] + ? tableColumns.map((previousColumn) => + previousColumn.id === column.id + ? { ...previousColumn, isVisible: !column.isVisible } + : previousColumn, + ) + : [...tableColumns, { ...column, isVisible: true }].sort( + (columnA, columnB) => columnA.order - columnB.order, + ); + + setTableColumns(nextColumns); + }, + [setTableColumns, tableColumns, tableColumnsById], + ); + + return { handleColumnVisibilityChange }; +}; diff --git a/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx b/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx index 1a23d3c7c4..2f4318fe86 100644 --- a/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx +++ b/front/src/modules/ui/table/options/components/TableOptionsDropdownContent.tsx @@ -27,9 +27,9 @@ import { import { tableColumnsScopedState } from '@/ui/table/states/tableColumnsScopedState'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId'; -import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue'; +import { useTableColumns } from '../../hooks/useTableColumns'; import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext'; import { savedTableColumnsScopedState } from '../../states/savedTableColumnsScopedState'; import { hiddenTableColumnsScopedSelector } from '../../states/selectors/hiddenTableColumnsScopedSelector'; @@ -74,10 +74,6 @@ export function TableOptionsDropdownContent({ const [viewEditMode, setViewEditMode] = useRecoilState( tableViewEditModeState, ); - const [columns, setColumns] = useRecoilScopedState( - tableColumnsScopedState, - TableRecoilScopeContext, - ); const visibleColumns = useRecoilScopedValue( visibleTableColumnsScopedSelector, TableRecoilScopeContext, @@ -91,18 +87,7 @@ export function TableOptionsDropdownContent({ TableRecoilScopeContext, ); - const handleColumnVisibilityChange = useCallback( - async (columnId: string, nextIsVisible: boolean) => { - const nextColumns = columns.map((column) => - column.id === columnId - ? { ...column, isVisible: nextIsVisible } - : column, - ); - - setColumns(nextColumns); - }, - [columns, setColumns], - ); + const { handleColumnVisibilityChange } = useTableColumns(); const renderFieldActions = useCallback( (column: ColumnDefinition) => @@ -110,6 +95,7 @@ export function TableOptionsDropdownContent({ !column.isVisible || visibleColumns.length > 1 ? [ @@ -117,9 +103,7 @@ export function TableOptionsDropdownContent({ ) } - onClick={() => - handleColumnVisibilityChange(column.id, !column.isVisible) - } + onClick={() => handleColumnVisibilityChange(column)} />, ] : undefined, diff --git a/front/src/modules/ui/table/options/components/TableUpdateViewButtonGroup.tsx b/front/src/modules/ui/table/options/components/TableUpdateViewButtonGroup.tsx index db1e0b4015..ac93d5a839 100644 --- a/front/src/modules/ui/table/options/components/TableUpdateViewButtonGroup.tsx +++ b/front/src/modules/ui/table/options/components/TableUpdateViewButtonGroup.tsx @@ -112,12 +112,15 @@ export const TableUpdateViewButtonGroup = ({ }, []); const handleViewSubmit = useCallback(async () => { - await Promise.resolve(onViewSubmit?.()); + if (canPersistColumns) setSavedColumns(currentColumns); + if (canPersistFilters) setSavedFilters(selectedFilters); + if (canPersistSorts) setSavedSorts(selectedSorts); - setSavedColumns(currentColumns); - setSavedFilters(selectedFilters); - setSavedSorts(selectedSorts); + await Promise.resolve(onViewSubmit?.()); }, [ + canPersistColumns, + canPersistFilters, + canPersistSorts, currentColumns, onViewSubmit, selectedFilters, diff --git a/front/src/modules/ui/table/states/availableTableColumnsScopedState.ts b/front/src/modules/ui/table/states/availableTableColumnsScopedState.ts new file mode 100644 index 0000000000..da6d0fbeae --- /dev/null +++ b/front/src/modules/ui/table/states/availableTableColumnsScopedState.ts @@ -0,0 +1,13 @@ +import { atomFamily } from 'recoil'; + +import type { ViewFieldMetadata } from '@/ui/editable-field/types/ViewField'; + +import type { ColumnDefinition } from '../types/ColumnDefinition'; + +export const availableTableColumnsScopedState = atomFamily< + ColumnDefinition[], + string +>({ + key: 'availableTableColumnsScopedState', + default: [], +}); diff --git a/front/src/modules/ui/table/states/selectors/hiddenTableColumnsScopedSelector.ts b/front/src/modules/ui/table/states/selectors/hiddenTableColumnsScopedSelector.ts index 078b782770..e6b1195871 100644 --- a/front/src/modules/ui/table/states/selectors/hiddenTableColumnsScopedSelector.ts +++ b/front/src/modules/ui/table/states/selectors/hiddenTableColumnsScopedSelector.ts @@ -1,13 +1,22 @@ import { selectorFamily } from 'recoil'; +import { availableTableColumnsScopedState } from '../availableTableColumnsScopedState'; import { tableColumnsScopedState } from '../tableColumnsScopedState'; export const hiddenTableColumnsScopedSelector = selectorFamily({ key: 'hiddenTableColumnsScopedSelector', get: (scopeId: string) => - ({ get }) => - get(tableColumnsScopedState(scopeId)).filter( - (column) => !column.isVisible, - ), + ({ get }) => { + const columns = get(tableColumnsScopedState(scopeId)); + const columnLabels = columns.map(({ label }) => label); + const otherAvailableColumns = get( + availableTableColumnsScopedState(scopeId), + ).filter(({ label }) => !columnLabels.includes(label)); + + return [ + ...columns.filter((column) => !column.isVisible), + ...otherAvailableColumns, + ]; + }, }); diff --git a/front/src/modules/views/hooks/useTableViewFields.ts b/front/src/modules/views/hooks/useTableViewFields.ts index c99d4d2344..8e6aae5519 100644 --- a/front/src/modules/views/hooks/useTableViewFields.ts +++ b/front/src/modules/views/hooks/useTableViewFields.ts @@ -5,6 +5,7 @@ import type { ViewFieldMetadata, ViewFieldTextMetadata, } from '@/ui/editable-field/types/ViewField'; +import { availableTableColumnsScopedState } from '@/ui/table/states/availableTableColumnsScopedState'; import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext'; import { savedTableColumnsScopedState } from '@/ui/table/states/savedTableColumnsScopedState'; import { savedTableColumnsByIdScopedSelector } from '@/ui/table/states/selectors/savedTableColumnsByIdScopedSelector'; @@ -45,19 +46,24 @@ export const useTableViewFields = ({ objectName: 'company' | 'person'; columnDefinitions: ColumnDefinition[]; }) => { - const currentViewId = useRecoilScopedValue( + const currentTableViewId = useRecoilScopedValue( currentTableViewIdState, TableRecoilScopeContext, ); - const [columns, setColumns] = useRecoilScopedState( + const [availableTableColumns, setAvailableTableColumns] = + useRecoilScopedState( + availableTableColumnsScopedState, + TableRecoilScopeContext, + ); + const [tableColumns, setTableColumns] = useRecoilScopedState( tableColumnsScopedState, TableRecoilScopeContext, ); - const setSavedColumns = useSetRecoilState( - savedTableColumnsScopedState(currentViewId), + const setSavedTableColumns = useSetRecoilState( + savedTableColumnsScopedState(currentTableViewId), ); - const savedColumnsById = useRecoilValue( - savedTableColumnsByIdScopedSelector(currentViewId), + const savedTableColumnsById = useRecoilValue( + savedTableColumnsByIdScopedSelector(currentTableViewId), ); const [createViewFieldsMutation] = useCreateViewFieldsMutation(); @@ -66,7 +72,7 @@ export const useTableViewFields = ({ const createViewFields = useCallback( ( columns: ColumnDefinition[], - viewId = currentViewId, + viewId = currentTableViewId, ) => { if (!viewId || !columns.length) return; @@ -79,12 +85,12 @@ export const useTableViewFields = ({ }, }); }, - [createViewFieldsMutation, currentViewId, objectName], + [createViewFieldsMutation, currentTableViewId, objectName], ); const updateViewFields = useCallback( (columns: ColumnDefinition[]) => { - if (!currentViewId || !columns.length) return; + if (!currentTableViewId || !columns.length) return; return Promise.all( columns.map((column) => @@ -100,16 +106,16 @@ export const useTableViewFields = ({ ), ); }, - [currentViewId, updateViewFieldMutation], + [currentTableViewId, updateViewFieldMutation], ); const { refetch } = useGetViewFieldsQuery({ - skip: !currentViewId, + skip: !currentTableViewId, variables: { orderBy: { index: SortOrder.Asc }, where: { objectName: { equals: objectName }, - viewId: { equals: currentViewId ?? null }, + viewId: { equals: currentTableViewId ?? null }, }, }, onCompleted: async (data) => { @@ -132,26 +138,42 @@ export const useTableViewFields = ({ isVisible: viewField.isVisible, })); - if (!isDeeplyEqual(columns, nextColumns)) { - setSavedColumns(nextColumns); - setColumns(nextColumns); + if (!isDeeplyEqual(tableColumns, nextColumns)) { + setSavedTableColumns(nextColumns); + setTableColumns(nextColumns); + } + + if (!availableTableColumns.length) { + setAvailableTableColumns(columnDefinitions); } }, }); const persistColumns = useCallback(async () => { - if (!currentViewId) return; + if (!currentTableViewId) return; - const viewFieldsToUpdate = columns.filter( + const viewFieldsToCreate = tableColumns.filter( + (column) => !savedTableColumnsById[column.id], + ); + await createViewFields(viewFieldsToCreate); + + const viewFieldsToUpdate = tableColumns.filter( (column) => - savedColumnsById[column.id] && - (savedColumnsById[column.id].size !== column.size || - savedColumnsById[column.id].isVisible !== column.isVisible), + savedTableColumnsById[column.id] && + (savedTableColumnsById[column.id].size !== column.size || + savedTableColumnsById[column.id].isVisible !== column.isVisible), ); await updateViewFields(viewFieldsToUpdate); return refetch(); - }, [columns, currentViewId, refetch, savedColumnsById, updateViewFields]); + }, [ + createViewFields, + currentTableViewId, + refetch, + savedTableColumnsById, + tableColumns, + updateViewFields, + ]); return { createViewFields, persistColumns }; }; diff --git a/server/src/database/migrations/20230829120042_allow_views_with_same_name/migration.sql b/server/src/database/migrations/20230829120042_allow_views_with_same_name/migration.sql new file mode 100644 index 0000000000..1f4e16edb0 --- /dev/null +++ b/server/src/database/migrations/20230829120042_allow_views_with_same_name/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "views_workspaceId_type_objectId_name_key"; diff --git a/server/src/database/schema.prisma b/server/src/database/schema.prisma index 1efbbdc5a0..3ae8a12a15 100644 --- a/server/src/database/schema.prisma +++ b/server/src/database/schema.prisma @@ -216,31 +216,31 @@ model WorkspaceMember { model Company { /// @Validator.IsString() /// @Validator.IsOptional() - id String @id @default(uuid()) + id String @id @default(uuid()) /// @Validator.IsString() /// @Validator.IsOptional() - name String + name String /// @Validator.IsString() /// @Validator.IsOptional() - domainName String + domainName String /// @Validator.IsString() /// @Validator.IsOptional() - linkedinUrl String? + linkedinUrl String? /// @Validator.IsNumber() /// @Validator.IsOptional() annualRecurringRevenue Int? /// @Validator.IsBoolean() /// @Validator.IsOptional() - idealCustomerProfile Boolean @default(false) + idealCustomerProfile Boolean @default(false) /// @Validator.IsString() /// @Validator.IsOptional() - xUrl String? + xUrl String? /// @Validator.IsString() /// @Validator.IsOptional() - address String + address String /// @Validator.IsNumber() /// @Validator.IsOptional() - employees Int? + employees Int? people Person[] accountOwner User? @relation(fields: [accountOwnerId], references: [id], onDelete: SetNull) @@ -603,7 +603,6 @@ model View { /// @TypeGraphQL.omit(input: true, output: true) workspaceId String - @@unique([workspaceId, type, objectId, name]) @@map("views") }