feat: add views dropdown (list, add & edit views) (#1220)

Closes #1218
This commit is contained in:
Thaïs 2023-08-15 21:08:02 +02:00 committed by GitHub
parent 7a330b4a02
commit 4e654654da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1037 additions and 212 deletions

View File

@ -953,6 +953,7 @@ export type Mutation = {
createEvent: Analytics;
createFavoriteForCompany: Favorite;
createFavoriteForPerson: Favorite;
createManyView: AffectedRows;
createManyViewField: AffectedRows;
createManyViewSort: AffectedRows;
createOneActivity: Activity;
@ -978,6 +979,7 @@ export type Mutation = {
updateOnePerson?: Maybe<Person>;
updateOnePipelineProgress?: Maybe<PipelineProgress>;
updateOnePipelineStage?: Maybe<PipelineStage>;
updateOneView: View;
updateOneViewField: ViewField;
updateOneViewSort: ViewSort;
updateUser: User;
@ -1019,6 +1021,12 @@ export type MutationCreateFavoriteForPersonArgs = {
};
export type MutationCreateManyViewArgs = {
data: Array<ViewCreateManyInput>;
skipDuplicates?: InputMaybe<Scalars['Boolean']>;
};
export type MutationCreateManyViewFieldArgs = {
data: Array<ViewFieldCreateManyInput>;
skipDuplicates?: InputMaybe<Scalars['Boolean']>;
@ -1143,6 +1151,12 @@ export type MutationUpdateOnePipelineStageArgs = {
};
export type MutationUpdateOneViewArgs = {
data: ViewUpdateInput;
where: ViewWhereUniqueInput;
};
export type MutationUpdateOneViewFieldArgs = {
data: ViewFieldUpdateInput;
where: ViewFieldWhereUniqueInput;
@ -1866,6 +1880,7 @@ export type Query = {
findManyPipelineProgress: Array<PipelineProgress>;
findManyPipelineStage: Array<PipelineStage>;
findManyUser: Array<User>;
findManyView: Array<View>;
findManyViewField: Array<ViewField>;
findManyViewSort: Array<ViewSort>;
findManyWorkspaceMember: Array<WorkspaceMember>;
@ -1955,6 +1970,16 @@ export type QueryFindManyUserArgs = {
};
export type QueryFindManyViewArgs = {
cursor?: InputMaybe<ViewWhereUniqueInput>;
distinct?: InputMaybe<Array<ViewScalarFieldEnum>>;
orderBy?: InputMaybe<Array<ViewOrderByWithRelationInput>>;
skip?: InputMaybe<Scalars['Int']>;
take?: InputMaybe<Scalars['Int']>;
where?: InputMaybe<ViewWhereInput>;
};
export type QueryFindManyViewFieldArgs = {
cursor?: InputMaybe<ViewFieldWhereUniqueInput>;
distinct?: InputMaybe<Array<ViewFieldScalarFieldEnum>>;
@ -2283,6 +2308,13 @@ export type View = {
type: ViewType;
};
export type ViewCreateManyInput = {
id?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
objectId: Scalars['String'];
type: ViewType;
};
export type ViewCreateNestedOneWithoutFieldsInput = {
connect?: InputMaybe<ViewWhereUniqueInput>;
};
@ -2361,6 +2393,12 @@ export type ViewFieldUpdateInput = {
view?: InputMaybe<ViewUpdateOneWithoutFieldsNestedInput>;
};
export type ViewFieldUpdateManyWithoutViewNestedInput = {
connect?: InputMaybe<Array<ViewFieldWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ViewFieldWhereUniqueInput>>;
set?: InputMaybe<Array<ViewFieldWhereUniqueInput>>;
};
export type ViewFieldUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ViewFieldWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ViewFieldWhereUniqueInput>>;
@ -2406,6 +2444,14 @@ export type ViewRelationFilter = {
isNot?: InputMaybe<ViewWhereInput>;
};
export enum ViewScalarFieldEnum {
Id = 'id',
Name = 'name',
ObjectId = 'objectId',
Type = 'type',
WorkspaceId = 'workspaceId'
}
export type ViewSort = {
__typename?: 'ViewSort';
direction: ViewSortDirection;
@ -2460,6 +2506,12 @@ export type ViewSortUpdateInput = {
view?: InputMaybe<ViewUpdateOneRequiredWithoutSortsNestedInput>;
};
export type ViewSortUpdateManyWithoutViewNestedInput = {
connect?: InputMaybe<Array<ViewSortWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ViewSortWhereUniqueInput>>;
set?: InputMaybe<Array<ViewSortWhereUniqueInput>>;
};
export type ViewSortUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ViewSortWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ViewSortWhereUniqueInput>>;
@ -2491,6 +2543,15 @@ export enum ViewType {
Table = 'Table'
}
export type ViewUpdateInput = {
fields?: InputMaybe<ViewFieldUpdateManyWithoutViewNestedInput>;
id?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
objectId?: InputMaybe<Scalars['String']>;
sorts?: InputMaybe<ViewSortUpdateManyWithoutViewNestedInput>;
type?: InputMaybe<ViewType>;
};
export type ViewUpdateManyWithoutWorkspaceNestedInput = {
connect?: InputMaybe<Array<ViewWhereUniqueInput>>;
disconnect?: InputMaybe<Array<ViewWhereUniqueInput>>;
@ -3086,6 +3147,13 @@ export type GetUsersQueryVariables = Exact<{ [key: string]: never; }>;
export type GetUsersQuery = { __typename?: 'Query', findManyUser: Array<{ __typename?: 'User', id: string, email: string, displayName: string, firstName?: string | null, lastName?: string | null }> };
export type CreateViewsMutationVariables = Exact<{
data: Array<ViewCreateManyInput> | ViewCreateManyInput;
}>;
export type CreateViewsMutation = { __typename?: 'Mutation', createManyView: { __typename?: 'AffectedRows', count: number } };
export type CreateViewFieldsMutationVariables = Exact<{
data: Array<ViewFieldCreateManyInput> | ViewFieldCreateManyInput;
}>;
@ -3107,6 +3175,14 @@ export type DeleteViewSortsMutationVariables = Exact<{
export type DeleteViewSortsMutation = { __typename?: 'Mutation', deleteManyViewSort: { __typename?: 'AffectedRows', count: number } };
export type UpdateViewMutationVariables = Exact<{
data: ViewUpdateInput;
where: ViewWhereUniqueInput;
}>;
export type UpdateViewMutation = { __typename?: 'Mutation', updateOneView: { __typename?: 'View', id: string, name: string } };
export type UpdateViewFieldMutationVariables = Exact<{
data: ViewFieldUpdateInput;
where: ViewFieldWhereUniqueInput;
@ -3123,6 +3199,13 @@ export type UpdateViewSortMutationVariables = Exact<{
export type UpdateViewSortMutation = { __typename?: 'Mutation', viewSort: { __typename?: 'ViewSort', direction: ViewSortDirection, key: string, name: string } };
export type GetViewsQueryVariables = Exact<{
where?: InputMaybe<ViewWhereInput>;
}>;
export type GetViewsQuery = { __typename?: 'Query', views: Array<{ __typename?: 'View', id: string, name: string }> };
export type GetViewFieldsQueryVariables = Exact<{
where?: InputMaybe<ViewFieldWhereInput>;
orderBy?: InputMaybe<Array<ViewFieldOrderByWithRelationInput> | ViewFieldOrderByWithRelationInput>;
@ -5680,6 +5763,39 @@ export function useGetUsersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<G
export type GetUsersQueryHookResult = ReturnType<typeof useGetUsersQuery>;
export type GetUsersLazyQueryHookResult = ReturnType<typeof useGetUsersLazyQuery>;
export type GetUsersQueryResult = Apollo.QueryResult<GetUsersQuery, GetUsersQueryVariables>;
export const CreateViewsDocument = gql`
mutation CreateViews($data: [ViewCreateManyInput!]!) {
createManyView(data: $data) {
count
}
}
`;
export type CreateViewsMutationFn = Apollo.MutationFunction<CreateViewsMutation, CreateViewsMutationVariables>;
/**
* __useCreateViewsMutation__
*
* To run a mutation, you first call `useCreateViewsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateViewsMutation` 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 [createViewsMutation, { data, loading, error }] = useCreateViewsMutation({
* variables: {
* data: // value for 'data'
* },
* });
*/
export function useCreateViewsMutation(baseOptions?: Apollo.MutationHookOptions<CreateViewsMutation, CreateViewsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateViewsMutation, CreateViewsMutationVariables>(CreateViewsDocument, options);
}
export type CreateViewsMutationHookResult = ReturnType<typeof useCreateViewsMutation>;
export type CreateViewsMutationResult = Apollo.MutationResult<CreateViewsMutation>;
export type CreateViewsMutationOptions = Apollo.BaseMutationOptions<CreateViewsMutation, CreateViewsMutationVariables>;
export const CreateViewFieldsDocument = gql`
mutation CreateViewFields($data: [ViewFieldCreateManyInput!]!) {
createManyViewField(data: $data) {
@ -5779,6 +5895,41 @@ export function useDeleteViewSortsMutation(baseOptions?: Apollo.MutationHookOpti
export type DeleteViewSortsMutationHookResult = ReturnType<typeof useDeleteViewSortsMutation>;
export type DeleteViewSortsMutationResult = Apollo.MutationResult<DeleteViewSortsMutation>;
export type DeleteViewSortsMutationOptions = Apollo.BaseMutationOptions<DeleteViewSortsMutation, DeleteViewSortsMutationVariables>;
export const UpdateViewDocument = gql`
mutation UpdateView($data: ViewUpdateInput!, $where: ViewWhereUniqueInput!) {
updateOneView(data: $data, where: $where) {
id
name
}
}
`;
export type UpdateViewMutationFn = Apollo.MutationFunction<UpdateViewMutation, UpdateViewMutationVariables>;
/**
* __useUpdateViewMutation__
*
* To run a mutation, you first call `useUpdateViewMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUpdateViewMutation` 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 [updateViewMutation, { data, loading, error }] = useUpdateViewMutation({
* variables: {
* data: // value for 'data'
* where: // value for 'where'
* },
* });
*/
export function useUpdateViewMutation(baseOptions?: Apollo.MutationHookOptions<UpdateViewMutation, UpdateViewMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UpdateViewMutation, UpdateViewMutationVariables>(UpdateViewDocument, options);
}
export type UpdateViewMutationHookResult = ReturnType<typeof useUpdateViewMutation>;
export type UpdateViewMutationResult = Apollo.MutationResult<UpdateViewMutation>;
export type UpdateViewMutationOptions = Apollo.BaseMutationOptions<UpdateViewMutation, UpdateViewMutationVariables>;
export const UpdateViewFieldDocument = gql`
mutation UpdateViewField($data: ViewFieldUpdateInput!, $where: ViewFieldWhereUniqueInput!) {
updateOneViewField(data: $data, where: $where) {
@ -5853,6 +6004,42 @@ export function useUpdateViewSortMutation(baseOptions?: Apollo.MutationHookOptio
export type UpdateViewSortMutationHookResult = ReturnType<typeof useUpdateViewSortMutation>;
export type UpdateViewSortMutationResult = Apollo.MutationResult<UpdateViewSortMutation>;
export type UpdateViewSortMutationOptions = Apollo.BaseMutationOptions<UpdateViewSortMutation, UpdateViewSortMutationVariables>;
export const GetViewsDocument = gql`
query GetViews($where: ViewWhereInput) {
views: findManyView(where: $where) {
id
name
}
}
`;
/**
* __useGetViewsQuery__
*
* To run a query within a React component, call `useGetViewsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetViewsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetViewsQuery({
* variables: {
* where: // value for 'where'
* },
* });
*/
export function useGetViewsQuery(baseOptions?: Apollo.QueryHookOptions<GetViewsQuery, GetViewsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetViewsQuery, GetViewsQueryVariables>(GetViewsDocument, options);
}
export function useGetViewsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetViewsQuery, GetViewsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetViewsQuery, GetViewsQueryVariables>(GetViewsDocument, options);
}
export type GetViewsQueryHookResult = ReturnType<typeof useGetViewsQuery>;
export type GetViewsLazyQueryHookResult = ReturnType<typeof useGetViewsLazyQuery>;
export type GetViewsQueryResult = Apollo.QueryResult<GetViewsQuery, GetViewsQueryVariables>;
export const GetViewFieldsDocument = gql`
query GetViewFields($where: ViewFieldWhereInput, $orderBy: [ViewFieldOrderByWithRelationInput!]) {
viewFields: findManyViewField(where: $where, orderBy: $orderBy) {

View File

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { companyViewFields } from '@/companies/constants/companyViewFields';
import { useCompanyTableActionBarEntries } from '@/companies/hooks/useCompanyTableActionBarEntries';
@ -7,15 +6,15 @@ import { useCompanyTableContextMenuEntries } from '@/companies/hooks/useCompanyT
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { IconList } from '@/ui/icon';
import { EntityTable } from '@/ui/table/components/EntityTable';
import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData';
import { useUpsertEntityTableItem } from '@/ui/table/hooks/useUpsertEntityTableItem';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useTableViewFields } from '@/views/hooks/useTableViewFields';
import { useTableViews } from '@/views/hooks/useTableViews';
import { useViewSorts } from '@/views/hooks/useViewSorts';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import {
SortOrder,
UpdateOneCompanyMutationVariables,
@ -26,7 +25,10 @@ import { companiesFilters } from '~/pages/companies/companies-filters';
import { availableSorts } from '~/pages/companies/companies-sorts';
export function CompanyTable() {
const currentViewId = useRecoilValue(currentViewIdState);
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const orderBy = useRecoilScopedValue(
sortsOrderByScopedState,
TableRecoilScopeContext,
@ -34,14 +36,13 @@ export function CompanyTable() {
const [updateEntityMutation] = useUpdateOneCompanyMutation();
const upsertEntityTableItem = useUpsertEntityTableItem();
const objectId = 'company';
const { handleViewsChange } = useTableViews({ objectId });
const { handleColumnsChange } = useTableViewFields({
objectName: 'company',
objectName: objectId,
viewFieldDefinitions: companyViewFields,
});
const { updateSorts } = useViewSorts({
availableSorts,
Context: TableRecoilScopeContext,
});
const { updateSorts } = useViewSorts({ availableSorts });
const filters = useRecoilScopedValue(
filtersScopedState,
@ -76,10 +77,10 @@ export function CompanyTable() {
/>
<EntityTable
viewName="All Companies"
viewIcon={<IconList size={16} />}
availableSorts={availableSorts}
onColumnsChange={handleColumnsChange}
onSortsUpdate={currentViewId ? updateSorts : undefined}
onViewsChange={handleViewsChange}
updateEntityMutation={({
variables,
}: {

View File

@ -1,4 +1,3 @@
import { IconList } from '@/ui/icon';
import { EntityTable } from '@/ui/table/components/EntityTable';
import { useUpdateOneCompanyMutation } from '~/generated/graphql';
import { availableSorts } from '~/pages/companies/companies-sorts';
@ -11,7 +10,6 @@ export function CompanyTableMockMode() {
<CompanyTableMockData />
<EntityTable
viewName="All Companies"
viewIcon={<IconList size={16} />}
availableSorts={availableSorts}
updateEntityMutation={[useUpdateOneCompanyMutation()]}
/>

View File

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { peopleViewFields } from '@/people/constants/peopleViewFields';
import { usePersonTableContextMenuEntries } from '@/people/hooks/usePeopleTableContextMenuEntries';
@ -7,15 +6,15 @@ import { usePersonTableActionBarEntries } from '@/people/hooks/usePersonTableAct
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
import { sortsOrderByScopedState } from '@/ui/filter-n-sort/states/sortScopedState';
import { turnFilterIntoWhereClause } from '@/ui/filter-n-sort/utils/turnFilterIntoWhereClause';
import { IconList } from '@/ui/icon';
import { EntityTable } from '@/ui/table/components/EntityTable';
import { GenericEntityTableData } from '@/ui/table/components/GenericEntityTableData';
import { useUpsertEntityTableItem } from '@/ui/table/hooks/useUpsertEntityTableItem';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useTableViewFields } from '@/views/hooks/useTableViewFields';
import { useTableViews } from '@/views/hooks/useTableViews';
import { useViewSorts } from '@/views/hooks/useViewSorts';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import {
SortOrder,
UpdateOnePersonMutationVariables,
@ -26,7 +25,10 @@ import { peopleFilters } from '~/pages/people/people-filters';
import { availableSorts } from '~/pages/people/people-sorts';
export function PeopleTable() {
const currentViewId = useRecoilValue(currentViewIdState);
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const orderBy = useRecoilScopedValue(
sortsOrderByScopedState,
TableRecoilScopeContext,
@ -34,14 +36,13 @@ export function PeopleTable() {
const [updateEntityMutation] = useUpdateOnePersonMutation();
const upsertEntityTableItem = useUpsertEntityTableItem();
const objectId = 'person';
const { handleViewsChange } = useTableViews({ objectId });
const { handleColumnsChange } = useTableViewFields({
objectName: 'person',
objectName: objectId,
viewFieldDefinitions: peopleViewFields,
});
const { updateSorts } = useViewSorts({
availableSorts,
Context: TableRecoilScopeContext,
});
const { updateSorts } = useViewSorts({ availableSorts });
const filters = useRecoilScopedValue(
filtersScopedState,
@ -76,10 +77,10 @@ export function PeopleTable() {
/>
<EntityTable
viewName="All People"
viewIcon={<IconList size={16} />}
availableSorts={availableSorts}
onColumnsChange={handleColumnsChange}
onSortsUpdate={currentViewId ? updateSorts : undefined}
onViewsChange={handleViewsChange}
updateEntityMutation={({
variables,
}: {

View File

@ -0,0 +1,34 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
export const DropdownMenuInputContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: calc(100%);
`;
const StyledInput = styled.input`
font-size: ${({ theme }) => theme.font.size.sm};
${textInputStyle}
width: 100%;
`;
export const DropdownMenuInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>((props, ref) => (
<DropdownMenuInputContainer>
<StyledInput autoComplete="off" placeholder="Search" {...props} ref={ref} />
</DropdownMenuInputContainer>
));

View File

@ -1,39 +0,0 @@
import { InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
export const DropdownMenuSearchContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: calc(100%);
`;
const StyledEditModeSearchInput = styled.input`
font-size: ${({ theme }) => theme.font.size.sm};
${textInputStyle}
width: 100%;
`;
export function DropdownMenuSearch(
props: InputHTMLAttributes<HTMLInputElement>,
) {
return (
<DropdownMenuSearchContainer>
<StyledEditModeSearchInput
autoComplete="off"
{...props}
placeholder={props.placeholder ?? 'Search'}
/>
</DropdownMenuSearchContainer>
);
}

View File

@ -11,9 +11,9 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenu } from '../DropdownMenu';
import { DropdownMenuCheckableItem } from '../DropdownMenuCheckableItem';
import { DropdownMenuHeader } from '../DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput';
import { DropdownMenuItem } from '../DropdownMenuItem';
import { DropdownMenuItemsContainer } from '../DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '../DropdownMenuSearch';
import { DropdownMenuSelectableItem } from '../DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
import { DropdownMenuSubheader } from '../DropdownMenuSubheader';
@ -256,7 +256,7 @@ export const LoadingMenu: Story = {
...WithContentBelow,
render: () => (
<DropdownMenu>
<DropdownMenuSearch value={'query'} autoFocus />
<DropdownMenuInput value={'query'} autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem />
@ -269,7 +269,7 @@ export const Search: Story = {
...WithContentBelow,
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuSearch />
<DropdownMenuInput />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{mockSelectArray.map(({ name }) => (

View File

@ -4,19 +4,18 @@ import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { DropdownMenuContainer } from './DropdownMenuContainer';
type OwnProps = {
label: string;
anchor?: 'left' | 'right';
label: ReactNode;
isActive: boolean;
children?: ReactNode;
isUnfolded?: boolean;
icon?: ReactNode;
onIsUnfoldedChange?: (newIsUnfolded: boolean) => void;
resetState?: () => void;
HotkeyScope: FiltersHotkeyScope;
HotkeyScope: string;
color?: string;
};
@ -59,7 +58,12 @@ const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
}
`;
const StyledDropdownMenuContainer = styled(DropdownMenuContainer)`
z-index: 2;
`;
function DropdownButton({
anchor,
label,
isActive,
children,
@ -99,9 +103,9 @@ function DropdownButton({
{label}
</StyledDropdownButton>
{isUnfolded && (
<DropdownMenuContainer onClose={onOutsideClick}>
<StyledDropdownMenuContainer anchor={anchor} onClose={onOutsideClick}>
{children}
</DropdownMenuContainer>
</StyledDropdownMenuContainer>
)}
</StyledDropdownButtonContainer>
);

View File

@ -1,23 +1,32 @@
import { useRef } from 'react';
import { type HTMLAttributes, useRef } from 'react';
import styled from '@emotion/styled';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
export const StyledDropdownMenuContainer = styled.ul`
export const StyledDropdownMenuContainer = styled.ul<{
anchor: 'left' | 'right';
}>`
padding: 0;
position: absolute;
right: 0;
${({ anchor }) => {
if (anchor === 'right') return 'right: 0';
}};
top: 14px;
`;
export function DropdownMenuContainer({
children,
onClose,
}: {
type DropdownMenuContainerProps = {
anchor?: 'left' | 'right';
children: React.ReactNode;
onClose?: () => void;
}) {
} & HTMLAttributes<HTMLUListElement>;
export function DropdownMenuContainer({
anchor = 'right',
children,
onClose,
...props
}: DropdownMenuContainerProps) {
const dropdownRef = useRef(null);
useListenClickOutside({
@ -28,7 +37,7 @@ export function DropdownMenuContainer({
});
return (
<StyledDropdownMenuContainer data-select-disable>
<StyledDropdownMenuContainer data-select-disable {...props} anchor={anchor}>
<DropdownMenu ref={dropdownRef}>{children}</DropdownMenu>
</StyledDropdownMenuContainer>
);

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState';
@ -27,7 +27,7 @@ export function FilterDropdownEntitySearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="text"
value={filterDropdownSearchInput}
placeholder={filterDefinitionUsedInDropdown.label}

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRemoveFilter } from '../hooks/useRemoveFilter';
@ -29,7 +29,7 @@ export function FilterDropdownNumberSearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="number"
placeholder={filterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
@ -36,7 +36,7 @@ export function FilterDropdownTextSearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="text"
placeholder={filterDefinitionUsedInDropdown.label}
value={filterCurrentlyEdited?.value ?? filterDropdownSearchInput}

View File

@ -1,58 +1,60 @@
export { IconAddressBook } from './components/IconAddressBook';
export { IconBuildingSkyscraper } from '@tabler/icons-react';
export { IconMessageCircle as IconComment } from '@tabler/icons-react';
export { IconCheck } from '@tabler/icons-react';
export { IconTrash } from '@tabler/icons-react';
export { IconLayoutSidebarRightCollapse } from '@tabler/icons-react';
export { IconLayoutSidebarLeftCollapse } from '@tabler/icons-react';
export { IconUser } from '@tabler/icons-react';
export { IconBell } from '@tabler/icons-react';
export { IconList } from '@tabler/icons-react';
export { IconInbox } from '@tabler/icons-react';
export { IconSearch } from '@tabler/icons-react';
export { IconArchive } from '@tabler/icons-react';
export { IconSettings } from '@tabler/icons-react';
export { IconLogout } from '@tabler/icons-react';
export { IconColorSwatch } from '@tabler/icons-react';
export { IconProgressCheck } from '@tabler/icons-react';
export { IconX } from '@tabler/icons-react';
export { IconChevronLeft } from '@tabler/icons-react';
export { IconBriefcase } from '@tabler/icons-react';
export { IconPlus } from '@tabler/icons-react';
export { IconMinus } from '@tabler/icons-react';
export { IconLink } from '@tabler/icons-react';
export { IconBrandLinkedin } from '@tabler/icons-react';
export { IconUsers } from '@tabler/icons-react';
export { IconCalendarEvent } from '@tabler/icons-react';
export { IconMap } from '@tabler/icons-react';
export { IconMail } from '@tabler/icons-react';
export { IconPhone } from '@tabler/icons-react';
export { IconTargetArrow } from '@tabler/icons-react';
export { IconChevronDown } from '@tabler/icons-react';
export { IconArrowNarrowDown } from '@tabler/icons-react';
export { IconArrowNarrowUp } from '@tabler/icons-react';
export { IconArrowRight } from '@tabler/icons-react';
export { IconArrowUpRight } from '@tabler/icons-react';
export { IconBrandGoogle } from '@tabler/icons-react';
export { IconUpload } from '@tabler/icons-react';
export { IconFileUpload } from '@tabler/icons-react';
export { IconChevronsRight } from '@tabler/icons-react';
export { IconNotes } from '@tabler/icons-react';
export { IconCirclePlus } from '@tabler/icons-react';
export { IconCheckbox } from '@tabler/icons-react';
export { IconTimelineEvent } from '@tabler/icons-react';
export { IconAlertCircle } from '@tabler/icons-react';
export { IconEye } from '@tabler/icons-react';
export { IconEyeOff } from '@tabler/icons-react';
export { IconAlertTriangle } from '@tabler/icons-react';
export { IconCopy } from '@tabler/icons-react';
export { IconCurrencyDollar } from '@tabler/icons-react';
export { IconUserCircle } from '@tabler/icons-react';
export { IconCalendar } from '@tabler/icons-react';
export { IconPencil } from '@tabler/icons-react';
export { IconCircleDot } from '@tabler/icons-react';
export { IconHeart } from '@tabler/icons-react';
export { IconBrandX } from '@tabler/icons-react';
export { IconTag } from '@tabler/icons-react';
export { IconHelpCircle } from '@tabler/icons-react';
export { IconBrandGithub } from '@tabler/icons-react';
export {
IconAlertCircle,
IconAlertTriangle,
IconArchive,
IconArrowNarrowDown,
IconArrowNarrowUp,
IconArrowRight,
IconArrowUpRight,
IconBell,
IconBrandGithub,
IconBrandGoogle,
IconBrandLinkedin,
IconBrandX,
IconBriefcase,
IconBuildingSkyscraper,
IconCalendar,
IconCalendarEvent,
IconCheck,
IconCheckbox,
IconChevronDown,
IconChevronLeft,
IconChevronsRight,
IconCircleDot,
IconCirclePlus,
IconColorSwatch,
IconMessageCircle as IconComment,
IconCopy,
IconCurrencyDollar,
IconEye,
IconEyeOff,
IconFileUpload,
IconHeart,
IconHelpCircle,
IconInbox,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse,
IconLink,
IconList,
IconLogout,
IconMail,
IconMap,
IconMinus,
IconNotes,
IconPencil,
IconPhone,
IconPlus,
IconProgressCheck,
IconSearch,
IconSettings,
IconTag,
IconTargetArrow,
IconTimelineEvent,
IconTrash,
IconUpload,
IconUser,
IconUserCircle,
IconUsers,
IconX,
} from '@tabler/icons-react';

View File

@ -3,9 +3,9 @@ import debounce from 'lodash.debounce';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/dropdown/components/DropdownMenuCheckableItem';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
@ -73,7 +73,7 @@ export function MultipleEntitySelect<
return (
<DropdownMenu ref={containerRef}>
<DropdownMenuSearch
<DropdownMenuInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus

View File

@ -2,9 +2,9 @@ import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { IconPlus } from '@/ui/icon';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -65,7 +65,7 @@ export function SingleEntitySelect<
ref={containerRef}
width={width}
>
<DropdownMenuSearch
<DropdownMenuInput
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus

View File

@ -14,6 +14,7 @@ import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
import { useResetTableRowSelection } from '../hooks/useResetTableRowSelection';
import { useSetRowSelectedState } from '../hooks/useSetRowSelectedState';
import type { TableView } from '../states/tableViewsState';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityTableBody } from './EntityTableBody';
@ -97,16 +98,16 @@ type OwnProps<SortField> = {
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
onViewsChange?: (views: TableView[]) => void;
updateEntityMutation: any;
};
export function EntityTable<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
updateEntityMutation,
}: OwnProps<SortField>) {
const tableBodyRef = useRef<HTMLDivElement>(null);
@ -131,10 +132,10 @@ export function EntityTable<SortField>({
<StyledTableContainer ref={tableBodyRef}>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
onColumnsChange={onColumnsChange}
onSortsUpdate={onSortsUpdate}
onViewsChange={onViewsChange}
/>
<StyledTableWrapper>
<StyledTable>

View File

@ -1,30 +1,50 @@
import { useCallback, useState } from 'react';
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import {
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { IconChevronLeft, IconMinus, IconPlus, IconTag } from '@/ui/icon';
import {
hiddenTableColumnsState,
tableColumnsState,
visibleTableColumnsState,
} from '@/ui/table/states/tableColumnsState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
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 {
type TableView,
tableViewEditModeState,
tableViewsByIdState,
tableViewsState,
} from '../../states/tableViewsState';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableOptionsDropdownSection } from './TableOptionsDropdownSection';
type TableOptionsDropdownButtonProps = {
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
HotkeyScope: FiltersHotkeyScope;
onViewsChange?: (views: TableView[]) => void;
HotkeyScope: TableOptionsHotkeyScope;
};
enum Option {
@ -33,17 +53,37 @@ enum Option {
export const TableOptionsDropdownButton = ({
onColumnsChange,
onViewsChange,
HotkeyScope,
}: TableOptionsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
undefined,
);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [columns, setColumns] = useRecoilState(tableColumnsState);
const [viewEditMode, setViewEditMode] = useRecoilState(
tableViewEditModeState,
);
const [views, setViews] = useRecoilScopedState(
tableViewsState,
TableRecoilScopeContext,
);
const visibleColumns = useRecoilValue(visibleTableColumnsState);
const hiddenColumns = useRecoilValue(hiddenTableColumnsState);
const viewsById = useRecoilScopedValue(
tableViewsByIdState,
TableRecoilScopeContext,
);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleColumnVisibilityChange = useCallback(
(columnId: string, nextIsVisible: boolean) => {
@ -79,25 +119,109 @@ export const TableOptionsDropdownButton = ({
[handleColumnVisibilityChange, theme.icon.size.sm, visibleColumns.length],
);
const resetViewEditMode = useCallback(() => {
setViewEditMode({ mode: undefined, viewId: undefined });
if (viewEditInputRef.current) {
viewEditInputRef.current.value = '';
}
}, [setViewEditMode]);
const handleViewNameSubmit = useCallback(
(event?: FormEvent) => {
event?.preventDefault();
if (viewEditMode.mode && viewEditInputRef.current?.value) {
const name = viewEditInputRef.current.value;
const nextViews =
viewEditMode.mode === 'create'
? [...views, { id: v4(), name }]
: views.map((view) =>
view.id === viewEditMode.viewId ? { ...view, name } : view,
);
(onViewsChange ?? setViews)(nextViews);
}
resetViewEditMode();
},
[
onViewsChange,
resetViewEditMode,
setViews,
viewEditMode.mode,
viewEditMode.viewId,
views,
],
);
const handleSelectOption = useCallback(
(option: Option) => {
handleViewNameSubmit();
setIsUnfolded(true);
setSelectedOption(option);
},
[handleViewNameSubmit],
);
const resetSelectedOption = useCallback(() => {
setSelectedOption(undefined);
}, []);
const handleUnfoldedChange = useCallback(
(nextIsUnfolded: boolean) => {
setIsUnfolded(nextIsUnfolded);
if (!nextIsUnfolded) {
handleViewNameSubmit();
resetSelectedOption();
}
},
[handleViewNameSubmit, resetSelectedOption],
);
useEffect(() => {
isUnfolded || viewEditMode.mode
? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope)
: goBackToPreviousHotkeyScope();
}, [
HotkeyScope,
goBackToPreviousHotkeyScope,
isUnfolded,
setHotkeyScopeAndMemorizePreviousScope,
viewEditMode.mode,
]);
return (
<DropdownButton
label="Options"
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
isUnfolded={isUnfolded || !!viewEditMode.mode}
onIsUnfoldedChange={handleUnfoldedChange}
HotkeyScope={HotkeyScope}
>
{!selectedOption && (
<>
<DropdownMenuHeader>View settings</DropdownMenuHeader>
{!!viewEditMode.mode ? (
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus
placeholder={
viewEditMode.mode === 'create' ? 'New view' : 'View name'
}
defaultValue={
viewEditMode.viewId
? viewsById[viewEditMode.viewId]?.name
: undefined
}
/>
) : (
<DropdownMenuHeader>View settings</DropdownMenuHeader>
)}
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuItem
onClick={() => setSelectedOption(Option.Properties)}
onClick={() => handleSelectOption(Option.Properties)}
>
<IconTag size={theme.icon.size.md} />
Properties

View File

@ -0,0 +1,149 @@
import { type MouseEvent, useCallback, useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { IconChevronDown, IconList, IconPencil, IconPlus } from '@/ui/icon';
import {
currentTableViewIdState,
currentTableViewState,
tableViewEditModeState,
tableViewsState,
} from '@/ui/table/states/tableViewsState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
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 { TableViewsHotkeyScope } from '../../types/TableViewsHotkeyScope';
const StyledDropdownMenuItemsContainer = styled(DropdownMenuItemsContainer)`
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
const StyledDropdownLabelAdornments = styled.span`
align-items: center;
color: ${({ theme }) => theme.grayScale.gray35};
display: inline-flex;
gap: ${({ theme }) => theme.spacing(1)};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledViewIcon = styled(IconList)`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
type TableViewsDropdownButtonProps = {
defaultViewName: string;
HotkeyScope: TableViewsHotkeyScope;
};
export const TableViewsDropdownButton = ({
defaultViewName,
HotkeyScope,
}: TableViewsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const currentView = useRecoilScopedValue(
currentTableViewState,
TableRecoilScopeContext,
);
const views = useRecoilScopedValue(tableViewsState, TableRecoilScopeContext);
const [, setCurrentViewId] = useRecoilScopedState(
currentTableViewIdState,
TableRecoilScopeContext,
);
const setViewEditMode = useSetRecoilState(tableViewEditModeState);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleViewSelect = useCallback(
(viewId?: string) => {
setCurrentViewId(viewId);
setIsUnfolded(false);
},
[setCurrentViewId],
);
const handleAddViewButtonClick = useCallback(() => {
setViewEditMode({ mode: 'create', viewId: undefined });
setIsUnfolded(false);
}, [setViewEditMode]);
const handleEditViewButtonClick = useCallback(
(event: MouseEvent<HTMLButtonElement>, viewId: string) => {
event.stopPropagation();
setViewEditMode({ mode: 'edit', viewId });
setIsUnfolded(false);
},
[setViewEditMode],
);
useEffect(() => {
isUnfolded
? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope)
: goBackToPreviousHotkeyScope();
}, [
HotkeyScope,
goBackToPreviousHotkeyScope,
isUnfolded,
setHotkeyScopeAndMemorizePreviousScope,
]);
return (
<DropdownButton
label={
<>
<StyledViewIcon size={theme.icon.size.md} />
{currentView?.name || defaultViewName}{' '}
<StyledDropdownLabelAdornments>
· {views.length + 1} <IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownLabelAdornments>
</>
}
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
anchor="left"
HotkeyScope={HotkeyScope}
>
<StyledDropdownMenuItemsContainer>
<DropdownMenuItem onClick={() => handleViewSelect(undefined)}>
<IconList size={theme.icon.size.md} />
{defaultViewName}
</DropdownMenuItem>
{views.map((view) => (
<DropdownMenuItem
key={view.id}
actions={
<IconButton
onClick={(event) => handleEditViewButtonClick(event, view.id)}
icon={<IconPencil size={theme.icon.size.sm} />}
/>
}
onClick={() => handleViewSelect(view.id)}
>
<IconList size={theme.icon.size.md} />
{view.name}
</DropdownMenuItem>
))}
</StyledDropdownMenuItemsContainer>
<DropdownMenuSeparator />
<StyledDropdownMenuItemsContainer>
<DropdownMenuItem onClick={handleAddViewButtonClick}>
<IconPlus size={theme.icon.size.md} />
Add view
</DropdownMenuItem>
</StyledDropdownMenuItemsContainer>
</DropdownButton>
);
};

View File

@ -0,0 +1,50 @@
import { atom, atomFamily, selectorFamily } from 'recoil';
export type TableView = { id: string; name: string };
export const tableViewsState = atomFamily<TableView[], string>({
key: 'tableViewsState',
default: [],
});
export const tableViewsByIdState = selectorFamily<
Record<string, TableView>,
string
>({
key: 'tableViewsByIdState',
get:
(scopeId) =>
({ get }) =>
get(tableViewsState(scopeId)).reduce<Record<string, TableView>>(
(result, view) => ({ ...result, [view.id]: view }),
{},
),
});
export const currentTableViewIdState = atomFamily<string | undefined, string>({
key: 'currentTableViewIdState',
default: undefined,
});
export const currentTableViewState = selectorFamily<
TableView | undefined,
string
>({
key: 'currentTableViewState',
get:
(scopeId) =>
({ get }) => {
const currentViewId = get(currentTableViewIdState(scopeId));
return currentViewId
? get(tableViewsByIdState(scopeId))[currentViewId]
: undefined;
},
});
export const tableViewEditModeState = atom<{
mode: 'create' | 'edit' | undefined;
viewId: string | undefined;
}>({
key: 'tableViewEditModeState',
default: { mode: undefined, viewId: undefined },
});

View File

@ -1,5 +1,4 @@
import { ReactNode, useCallback } from 'react';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import type {
ViewFieldDefinition,
@ -15,32 +14,26 @@ import { TableOptionsDropdownButton } from '@/ui/table/options/components/TableO
import { TopBar } from '@/ui/top-bar/TopBar';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { TableViewsDropdownButton } from '../../options/components/TableViewsDropdownButton';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import type { TableView } from '../../states/tableViewsState';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableViewsHotkeyScope } from '../../types/TableViewsHotkeyScope';
type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onViewsChange?: (views: TableView[]) => void;
};
const StyledIcon = styled.div`
display: flex;
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(2)};
& > svg {
font-size: ${({ theme }) => theme.icon.size.sm};
}
`;
export function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
}: OwnProps<SortField>) {
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
sortScopedState,
@ -67,10 +60,10 @@ export function TableHeader<SortField>({
return (
<TopBar
leftComponent={
<>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</>
<TableViewsDropdownButton
defaultViewName={viewName}
HotkeyScope={TableViewsHotkeyScope.Dropdown}
/>
}
displayBottomBorder={false}
rightComponent={
@ -90,7 +83,8 @@ export function TableHeader<SortField>({
/>
<TableOptionsDropdownButton
onColumnsChange={onColumnsChange}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
onViewsChange={onViewsChange}
HotkeyScope={TableOptionsHotkeyScope.Dropdown}
/>
</>
}

View File

@ -0,0 +1,3 @@
export enum TableOptionsHotkeyScope {
Dropdown = 'table-options-dropdown',
}

View File

@ -0,0 +1,3 @@
export enum TableViewsHotkeyScope {
Dropdown = 'table-views-dropdown',
}

View File

@ -4,13 +4,15 @@ import { v4 } from 'uuid';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
export function RecoilScope({
SpecificContext,
children,
scopeId,
SpecificContext,
}: {
SpecificContext?: Context<string | null>;
children: React.ReactNode;
scopeId?: string;
SpecificContext?: Context<string | null>;
}) {
const currentScopeId = useRef(v4());
const currentScopeId = useRef(scopeId || v4());
return SpecificContext ? (
<SpecificContext.Provider value={currentScopeId.current}>

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const CREATE_VIEWS = gql`
mutation CreateViews($data: [ViewCreateManyInput!]!) {
createManyView(data: $data) {
count
}
}
`;

View File

@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const UPDATE_VIEW = gql`
mutation UpdateView($data: ViewUpdateInput!, $where: ViewWhereUniqueInput!) {
updateOneView(data: $data, where: $where) {
id
name
}
}
`;

View File

@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const GET_VIEWS = gql`
query GetViews($where: ViewWhereInput) {
views: findManyView(where: $where) {
id
name
}
}
`;

View File

@ -7,11 +7,13 @@ import {
ViewFieldMetadata,
ViewFieldTextMetadata,
} from '@/ui/editable-field/types/ViewField';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import {
tableColumnsByIdState,
tableColumnsState,
} from '@/ui/table/states/tableColumnsState';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import {
SortOrder,
useCreateViewFieldsMutation,
@ -45,7 +47,10 @@ export const useTableViewFields = ({
objectName: 'company' | 'person';
viewFieldDefinitions: ViewFieldDefinition<ViewFieldMetadata>[];
}) => {
const currentViewId = useRecoilValue(currentViewIdState);
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const setColumns = useSetRecoilState(tableColumnsState);
const columnsById = useRecoilValue(tableColumnsByIdState);

View File

@ -0,0 +1,109 @@
import { useCallback } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import {
type TableView,
tableViewsByIdState,
tableViewsState,
} from '@/ui/table/states/tableViewsState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import {
useCreateViewsMutation,
useGetViewsQuery,
useUpdateViewMutation,
ViewType,
} from '~/generated/graphql';
import { GET_VIEWS } from '../graphql/queries/getViews';
export const useTableViews = ({
objectId,
}: {
objectId: 'company' | 'person';
}) => {
const [, setViews] = useRecoilScopedState(
tableViewsState,
TableRecoilScopeContext,
);
const viewsById = useRecoilScopedValue(
tableViewsByIdState,
TableRecoilScopeContext,
);
const [createViewsMutation] = useCreateViewsMutation();
const [updateViewMutation] = useUpdateViewMutation();
const createViews = useCallback(
(views: TableView[]) => {
if (!views.length) return;
return createViewsMutation({
variables: {
data: views.map((view) => ({
...view,
objectId,
type: ViewType.Table,
})),
},
refetchQueries: [getOperationName(GET_VIEWS) ?? ''],
});
},
[createViewsMutation, objectId],
);
const updateViewFields = useCallback(
(views: TableView[]) => {
if (!views.length) return;
return Promise.all(
views.map((view) =>
updateViewMutation({
variables: {
data: { name: view.name },
where: { id: view.id },
},
refetchQueries: [getOperationName(GET_VIEWS) ?? ''],
}),
),
);
},
[updateViewMutation],
);
useGetViewsQuery({
variables: {
where: {
objectId: { equals: objectId },
},
},
onCompleted: (data) => {
setViews(
data.views.map((view) => ({
id: view.id,
name: view.name,
})),
);
},
});
const handleViewsChange = useCallback(
async (nextViews: TableView[]) => {
const viewsToCreate = nextViews.filter(
(nextView) => !viewsById[nextView.id],
);
await createViews(viewsToCreate);
const viewsToUpdate = nextViews.filter(
(nextView) =>
viewsById[nextView.id] &&
viewsById[nextView.id].name !== nextView.name,
);
await updateViewFields(viewsToUpdate);
},
[createViews, updateViewFields, viewsById],
);
return { handleViewsChange };
};

View File

@ -1,6 +1,5 @@
import { Context, useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue } from 'recoil';
import {
sortsByKeyScopedState,
@ -10,9 +9,10 @@ import type {
SelectedSortType,
SortType,
} from '@/ui/filter-n-sort/types/interface';
import { TableRecoilScopeContext } from '@/ui/table/states/recoil-scope-contexts/TableRecoilScopeContext';
import { currentTableViewIdState } from '@/ui/table/states/tableViewsState';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { currentViewIdState } from '@/views/states/currentViewIdState';
import {
useCreateViewSortsMutation,
useDeleteViewSortsMutation,
@ -25,14 +25,25 @@ import { GET_VIEW_SORTS } from '../graphql/queries/getViewSorts';
export const useViewSorts = <SortField>({
availableSorts,
Context,
}: {
availableSorts: SortType<SortField>[];
Context?: Context<string | null>;
}) => {
const currentViewId = useRecoilValue(currentViewIdState);
const [, setSorts] = useRecoilScopedState(sortScopedState, Context);
const sortsByKey = useRecoilScopedValue(sortsByKeyScopedState, Context);
const currentViewId = useRecoilScopedValue(
currentTableViewIdState,
TableRecoilScopeContext,
);
const [, setSorts] = useRecoilScopedState(
sortScopedState,
TableRecoilScopeContext,
);
const sortsByKey = useRecoilScopedValue(
sortsByKeyScopedState,
TableRecoilScopeContext,
);
useEffect(() => {
if (!currentViewId) setSorts([]);
}, [currentViewId, setSorts]);
useGetViewSortsQuery({
skip: !currentViewId,
@ -44,11 +55,19 @@ export const useViewSorts = <SortField>({
onCompleted: (data) => {
setSorts(
data.viewSorts
.map((viewSort) => ({
...availableSorts.find((sort) => sort.key === viewSort.key),
label: viewSort.name,
order: viewSort.direction.toLowerCase(),
}))
.map((viewSort) => {
const availableSort = availableSorts.find(
(sort) => sort.key === viewSort.key,
);
return availableSort
? {
...availableSort,
label: viewSort.name,
order: viewSort.direction.toLowerCase(),
}
: undefined;
})
.filter((sort): sort is SelectedSortType<SortField> => !!sort),
);
},

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const currentViewIdState = atom<string | undefined>({
key: 'currentViewIdState',
default: undefined,
});

View File

@ -68,7 +68,10 @@ export function Companies() {
icon={<IconBuildingSkyscraper size={theme.icon.size.md} />}
onAddButtonClick={handleAddButtonClick}
>
<RecoilScope SpecificContext={TableRecoilScopeContext}>
<RecoilScope
scopeId="companies"
SpecificContext={TableRecoilScopeContext}
>
<StyledTableContainer>
<CompanyTable />
</StyledTableContainer>

View File

@ -56,7 +56,7 @@ export function People() {
const theme = useTheme();
return (
<RecoilScope SpecificContext={TableRecoilScopeContext}>
<RecoilScope scopeId="people" SpecificContext={TableRecoilScopeContext}>
<WithTopBarContainer
title="People"
icon={<IconUser size={theme.icon.size.sm} />}

View File

@ -110,6 +110,11 @@ import {
UpdateViewSortAbilityHandler,
DeleteViewSortAbilityHandler,
} from './handlers/view-sort.ability-handler';
import {
CreateViewAbilityHandler,
ReadViewAbilityHandler,
UpdateViewAbilityHandler,
} from './handlers/view.ability-handler';
@Global()
@Module({
@ -194,14 +199,18 @@ import {
CreatePipelineProgressAbilityHandler,
UpdatePipelineProgressAbilityHandler,
DeletePipelineProgressAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
//Favorite
ReadFavoriteAbilityHandler,
CreateFavoriteAbilityHandler,
DeleteFavoriteAbilityHandler,
// View
ReadViewAbilityHandler,
CreateViewAbilityHandler,
UpdateViewAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
// ViewSort
ReadViewSortAbilityHandler,
CreateViewSortAbilityHandler,
@ -288,14 +297,18 @@ import {
CreatePipelineProgressAbilityHandler,
UpdatePipelineProgressAbilityHandler,
DeletePipelineProgressAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
//Favorite
ReadFavoriteAbilityHandler,
CreateFavoriteAbilityHandler,
DeleteFavoriteAbilityHandler,
// View
ReadViewAbilityHandler,
CreateViewAbilityHandler,
UpdateViewAbilityHandler,
// ViewField
ReadViewFieldAbilityHandler,
CreateViewFieldAbilityHandler,
UpdateViewFieldAbilityHandler,
// ViewSort
ReadViewSortAbilityHandler,
CreateViewSortAbilityHandler,

View File

@ -0,0 +1,87 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { accessibleBy } from '@casl/prisma';
import { Prisma, Workspace } from '@prisma/client';
import { AppAbility } from 'src/ability/ability.factory';
import {
CreateViewAbilityHandler,
ReadViewAbilityHandler,
UpdateViewAbilityHandler,
} from 'src/ability/handlers/view.ability-handler';
import { FindManyViewArgs } from 'src/core/@generated/view/find-many-view.args';
import { View } from 'src/core/@generated/view/view.model';
import { ViewService } from 'src/core/view/services/view.service';
import { CheckAbilities } from 'src/decorators/check-abilities.decorator';
import {
PrismaSelect,
PrismaSelector,
} from 'src/decorators/prisma-select.decorator';
import { UserAbility } from 'src/decorators/user-ability.decorator';
import { AbilityGuard } from 'src/guards/ability.guard';
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { UpdateOneViewArgs } from 'src/core/@generated/view/update-one-view.args';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { AffectedRows } from 'src/core/@generated/prisma/affected-rows.output';
import { CreateManyViewArgs } from 'src/core/@generated/view/create-many-view.args';
@UseGuards(JwtAuthGuard)
@Resolver(() => View)
export class ViewResolver {
constructor(private readonly viewService: ViewService) {}
@Mutation(() => AffectedRows)
@UseGuards(AbilityGuard)
@CheckAbilities(CreateViewAbilityHandler)
async createManyView(
@Args() args: CreateManyViewArgs,
@AuthWorkspace() workspace: Workspace,
): Promise<AffectedRows> {
return this.viewService.createMany({
data: args.data.map((data) => ({
...data,
workspaceId: workspace.id,
})),
});
}
@Query(() => [View])
@UseGuards(AbilityGuard)
@CheckAbilities(ReadViewAbilityHandler)
async findManyView(
@Args() args: FindManyViewArgs,
@UserAbility() ability: AppAbility,
@PrismaSelector({ modelName: 'View' })
prismaSelect: PrismaSelect<'View'>,
): Promise<Partial<View>[]> {
return this.viewService.findMany({
where: args.where
? {
AND: [args.where, accessibleBy(ability).View],
}
: accessibleBy(ability).View,
orderBy: args.orderBy,
cursor: args.cursor,
take: args.take,
skip: args.skip,
distinct: args.distinct,
select: prismaSelect.value,
});
}
@Mutation(() => View)
@UseGuards(AbilityGuard)
@CheckAbilities(UpdateViewAbilityHandler)
async updateOneView(
@Args() args: UpdateOneViewArgs,
@PrismaSelector({ modelName: 'View' })
prismaSelect: PrismaSelect<'View'>,
): Promise<Partial<View>> {
return this.viewService.update({
data: args.data,
where: args.where,
select: prismaSelect.value,
} as Prisma.ViewUpdateArgs);
}
}

View File

@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/database/prisma.service';
@Injectable()
export class ViewService {
constructor(private readonly prismaService: PrismaService) {}
// Find
findFirst = this.prismaService.client.view.findFirst;
findFirstOrThrow = this.prismaService.client.view.findFirstOrThrow;
findUnique = this.prismaService.client.view.findUnique;
findUniqueOrThrow = this.prismaService.client.view.findUniqueOrThrow;
findMany = this.prismaService.client.view.findMany;
// Create
create = this.prismaService.client.view.create;
createMany = this.prismaService.client.view.createMany;
// Update
update = this.prismaService.client.view.update;
upsert = this.prismaService.client.view.upsert;
updateMany = this.prismaService.client.view.updateMany;
// Delete
delete = this.prismaService.client.view.delete;
deleteMany = this.prismaService.client.view.deleteMany;
// Aggregate
aggregate = this.prismaService.client.view.aggregate;
// Count
count = this.prismaService.client.view.count;
// GroupBy
groupBy = this.prismaService.client.view.groupBy;
}

View File

@ -4,11 +4,15 @@ import { ViewFieldService } from './services/view-field.service';
import { ViewFieldResolver } from './resolvers/view-field.resolver';
import { ViewSortService } from './services/view-sort.service';
import { ViewSortResolver } from './resolvers/view-sort.resolver';
import { ViewService } from './services/view.service';
import { ViewResolver } from './resolvers/view.resolver';
@Module({
providers: [
ViewService,
ViewFieldService,
ViewSortService,
ViewResolver,
ViewFieldResolver,
ViewSortResolver,
],