Added generic relation cell (#969)

* Added generic relation cell

* Deactivated debug

* Added default warning

* Put back display component

* Removed unused types
This commit is contained in:
Lucas Bordeau 2023-07-28 01:28:42 +02:00 committed by GitHub
parent 3b796ee68c
commit f4b8a3decb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 447 additions and 63 deletions

View File

@ -0,0 +1,60 @@
import { useFilteredSearchCompanyQuery } from '@/companies/queries';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/ui/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/relation-picker/states/relationPickerSearchFilterScopedState';
import { isCreateModeScopedState } from '@/ui/table/editable-cell/states/isCreateModeScopedState';
import { TableHotkeyScope } from '@/ui/table/types/TableHotkeyScope';
import { EntityForSelect } from '../../ui/relation-picker/types/EntityForSelect';
export type OwnProps = {
companyId: string | null;
onSubmit: (newCompany: EntityForSelect | null) => void;
onCancel?: () => void;
createModeEnabled?: boolean;
};
export function CompanyPickerCell({
companyId,
onSubmit,
onCancel,
createModeEnabled,
}: OwnProps) {
const [, setIsCreating] = useRecoilScopedState(isCreateModeScopedState);
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const setHotkeyScope = useSetHotkeyScope();
const companies = useFilteredSearchCompanyQuery({
searchFilter,
selectedIds: [companyId ?? ''],
});
async function handleEntitySelected(
entity: EntityForSelect | null | undefined,
) {
onSubmit(entity ?? null);
}
function handleCreate() {
setIsCreating(true);
setHotkeyScope(TableHotkeyScope.CellDoubleTextInput);
}
return (
<SingleEntitySelect
onCreate={createModeEnabled ? handleCreate : undefined}
onCancel={onCancel}
onEntitySelected={handleEntitySelected}
entities={{
entitiesToSelect: companies.entitiesToSelect,
selectedEntity: companies.selectedEntities[0],
loading: companies.loading,
}}
/>
);
}

View File

@ -1,12 +1,8 @@
import { Key } from 'ts-key-enum';
import { useFilteredSearchCompanyQuery } from '@/companies/queries';
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/ui/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/relation-picker/states/relationPickerSearchFilterScopedState';
import { RelationPickerHotkeyScope } from '@/ui/relation-picker/types/RelationPickerHotkeyScope';
import { useEditableCell } from '@/ui/table/editable-cell/hooks/useEditableCell';
import { isCreateModeScopedState } from '@/ui/table/editable-cell/states/isCreateModeScopedState';
import { TableHotkeyScope } from '@/ui/table/types/TableHotkeyScope';
@ -63,13 +59,6 @@ export function PeopleCompanyPicker({ people }: OwnProps) {
addToScopeStack(TableHotkeyScope.CellDoubleTextInput);
}
useScopedHotkeys(
Key.Escape,
() => closeEditableCell(),
RelationPickerHotkeyScope.RelationPicker,
[closeEditableCell],
);
return (
<SingleEntitySelect
onCreate={handleCreate}

View File

@ -0,0 +1,55 @@
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/ui/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
import { useSearchPeopleQuery } from '~/generated/graphql';
export type OwnProps = {
personId: string;
onSubmit: (newPersonId: string | null) => void;
onCancel?: () => void;
};
type PersonForSelect = EntityForSelect & {
entityType: Entity.Person;
};
export function PeoplePicker({ personId, onSubmit, onCancel }: OwnProps) {
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const people = useFilteredSearchEntityQuery({
queryHook: useSearchPeopleQuery,
selectedIds: [personId],
searchFilter: searchFilter,
mappingFunction: (person) => ({
entityType: Entity.Person,
id: person.id,
name: person.firstName + ' ' + person.lastName,
avatarType: 'rounded',
}),
orderByField: 'firstName',
searchOnFields: ['firstName', 'lastName'],
});
async function handleEntitySelected(
selectedPerson: PersonForSelect | null | undefined,
) {
onSubmit(selectedPerson?.id ?? null);
}
return (
<SingleEntitySelect
onEntitySelected={handleEntitySelected}
onCancel={onCancel}
entities={{
loading: people.loading,
entitiesToSelect: people.entitiesToSelect,
selectedEntity: people.selectedEntities[0],
}}
/>
);
}

View File

@ -1,5 +1,10 @@
import { IconBriefcase, IconMap } from '@tabler/icons-react';
import {
IconBriefcase,
IconBuildingSkyscraper,
IconMap,
} from '@tabler/icons-react';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
import { EntityFieldMetadata } from '@/ui/table/types/EntityFieldMetadata';
export const peopleFieldMetadataArray: EntityFieldMetadata[] = [
@ -17,4 +22,12 @@ export const peopleFieldMetadataArray: EntityFieldMetadata[] = [
columnSize: 150,
type: 'text',
},
{
fieldName: 'company',
label: 'Company',
icon: <IconBuildingSkyscraper size={16} />,
columnSize: 150,
type: 'relation',
relationType: Entity.Company,
},
];

View File

@ -0,0 +1,65 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
import { entityFieldMetadataArrayState } from '@/ui/table/states/entityFieldMetadataArrayState';
import { EntityUpdateMutationHookContext } from '@/ui/table/states/EntityUpdateMutationHookContext';
export function useUpdateEntityField() {
const useUpdateEntityMutation = useContext(EntityUpdateMutationHookContext);
const [updateEntity] = useUpdateEntityMutation();
const entityFieldMetadataArray = useRecoilValue(
entityFieldMetadataArrayState,
);
return function updatePeopleField(
currentEntityId: string,
fieldName: string,
newFieldValue: unknown,
) {
const fieldMetadata = entityFieldMetadataArray.find(
(metadata) => metadata.fieldName === fieldName,
);
if (!fieldMetadata) {
throw new Error(`Field metadata not found for field ${fieldName}`);
}
if (fieldMetadata.type === 'relation') {
const newSelectedEntity = newFieldValue as EntityForSelect | null;
if (!newSelectedEntity) {
updateEntity({
variables: {
where: { id: currentEntityId },
data: {
[fieldName]: {
disconnect: true,
},
},
},
});
} else {
updateEntity({
variables: {
where: { id: currentEntityId },
data: {
[fieldName]: {
connect: { id: newSelectedEntity.id },
},
},
},
});
}
} else {
updateEntity({
variables: {
where: { id: currentEntityId },
data: { [fieldName]: newFieldValue },
},
});
}
};
}

View File

@ -1,18 +0,0 @@
import { useUpdateOnePersonMutation } from '~/generated/graphql';
export function useUpdatePeopleField() {
const [updatePeople] = useUpdateOnePersonMutation();
return function updatePeopleField(
peopleId: string,
fieldName: string,
fieldValue: unknown,
) {
updatePeople({
variables: {
where: { id: peopleId },
data: { [fieldName]: fieldValue },
},
});
};
}

View File

@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from 'react';
import { defaultOrderBy } from '@/companies/queries';
import { GenericEntityTableData } from '@/people/components/GenericEntityTableData';
import { peopleFieldMetadataArray } from '@/people/constants/peopleFieldMetadataArray';
import { useUpdatePeopleField } from '@/people/hooks/useUpdatePeopleField';
import { PeopleSelectedSortType } from '@/people/queries';
import { reduceSortsToOrderBy } from '@/ui/filter-n-sort/helpers';
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
@ -15,6 +14,7 @@ import { TableContext } from '@/ui/table/states/TableContext';
import {
PersonOrderByWithRelationInput,
useGetPeopleQuery,
useUpdateOnePersonMutation,
} from '~/generated/graphql';
import { availableSorts } from '~/pages/people/people-sorts';
@ -46,7 +46,7 @@ export function PeopleTable() {
viewIcon={<IconList size={16} />}
availableSorts={availableSorts}
onSortsUpdate={updateSorts}
useUpdateField={useUpdatePeopleField}
useUpdateEntityMutation={useUpdateOnePersonMutation}
/>
</>
);

View File

@ -3,6 +3,8 @@ import { useRecoilCallback } from 'recoil';
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
const DEBUG_HOTKEY_SCOPE = false;
export function useScopedHotkeyCallback() {
return useRecoilCallback(
({ snapshot }) =>
@ -24,9 +26,31 @@ export function useScopedHotkeyCallback() {
.valueOrThrow();
if (!currentHotkeyScopes.includes(scope)) {
if (DEBUG_HOTKEY_SCOPE) {
console.debug(
`%cI can't call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: gray; ',
);
}
return;
}
if (DEBUG_HOTKEY_SCOPE) {
console.debug(
`%cI can call hotkey (${
hotkeysEvent.keys
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
', ',
)}]`,
'color: green;',
);
}
if (preventDefault) {
keyboardEvent.stopPropagation();
keyboardEvent.preventDefault();

View File

@ -1,7 +1,7 @@
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { GenericEditableCell } from '@/people/table/components/GenericEditableCell';
import { GenericEditableCell } from '@/ui/table/components/GenericEditableCell';
import { RecoilScope } from '../../recoil-scope/components/RecoilScope';
import { useCurrentRowSelected } from '../hooks/useCurrentRowSelected';

View File

@ -6,9 +6,8 @@ import { useListenClickOutside } from '@/ui/hooks/useListenClickOutside';
import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
import { EntityUpdateFieldHookContext } from '../states/EntityUpdateFieldHookContext';
import { EntityUpdateMutationHookContext } from '../states/EntityUpdateMutationHookContext';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityUpdateFieldHook } from '../types/CellUpdateFieldHook';
import { EntityTableBody } from './EntityTableBodyV2';
import { EntityTableHeader } from './EntityTableHeaderV2';
@ -90,7 +89,7 @@ type OwnProps<SortField> = {
availableSorts?: Array<SortType<SortField>>;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
useUpdateField: EntityUpdateFieldHook;
useUpdateEntityMutation: any;
};
export function EntityTable<SortField>({
@ -98,7 +97,7 @@ export function EntityTable<SortField>({
viewIcon,
availableSorts,
onSortsUpdate,
useUpdateField,
useUpdateEntityMutation,
}: OwnProps<SortField>) {
const tableBodyRef = React.useRef<HTMLDivElement>(null);
@ -114,7 +113,7 @@ export function EntityTable<SortField>({
});
return (
<EntityUpdateFieldHookContext.Provider value={useUpdateField}>
<EntityUpdateMutationHookContext.Provider value={useUpdateEntityMutation}>
<StyledTableWithHeader>
<StyledTableContainer ref={tableBodyRef}>
<TableHeader
@ -131,6 +130,6 @@ export function EntityTable<SortField>({
</StyledTableWrapper>
</StyledTableContainer>
</StyledTableWithHeader>
</EntityUpdateFieldHookContext.Provider>
</EntityUpdateMutationHookContext.Provider>
);
}

View File

@ -1,5 +1,6 @@
import { EntityFieldMetadata } from '@/ui/table/types/EntityFieldMetadata';
import { GenericEditableRelationCell } from './GenericEditableRelationCell';
import { GenericEditableTextCell } from './GenericEditableTextCell';
type OwnProps = {
@ -16,8 +17,15 @@ export function GenericEditableCell({ entityFieldMetadata }: OwnProps) {
editModeHorizontalAlign="left"
/>
);
case 'relation': {
return (
<GenericEditableRelationCell fieldMetadata={entityFieldMetadata} />
);
}
default:
console.warn(
`Unknown field type: ${entityFieldMetadata.type} in GenericEditableCell`,
);
return <></>;
}
}

View File

@ -0,0 +1,35 @@
import { RelationPickerHotkeyScope } from '@/ui/relation-picker/types/RelationPickerHotkeyScope';
import { EditableCell } from '@/ui/table/editable-cell/components/EditableCell';
import { EntityFieldMetadata } from '@/ui/table/types/EntityFieldMetadata';
import { GenericEditableRelationCellDisplayMode } from './GenericEditableRelationCellDisplayMode';
import { GenericEditableRelationCellEditMode } from './GenericEditableRelationCellEditMode';
type OwnProps = {
fieldMetadata: EntityFieldMetadata;
editModeHorizontalAlign?: 'left' | 'right';
placeholder?: string;
};
export function GenericEditableRelationCell({
fieldMetadata,
editModeHorizontalAlign,
placeholder,
}: OwnProps) {
return (
<EditableCell
editModeHorizontalAlign={editModeHorizontalAlign}
editHotkeyScope={{ scope: RelationPickerHotkeyScope.RelationPicker }}
editModeContent={
<GenericEditableRelationCellEditMode fieldMetadata={fieldMetadata} />
}
nonEditModeContent={
<GenericEditableRelationCellDisplayMode
fieldMetadata={fieldMetadata}
editModeHorizontalAlign={editModeHorizontalAlign}
placeholder={placeholder}
/>
}
></EditableCell>
);
}

View File

@ -0,0 +1,44 @@
import { useRecoilValue } from 'recoil';
import { CompanyChip } from '@/companies/components/CompanyChip';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
import { useCurrentRowEntityId } from '@/ui/table/hooks/useCurrentEntityId';
import { tableEntityFieldFamilySelector } from '@/ui/table/states/tableEntityFieldFamilySelector';
import { EntityFieldMetadata } from '@/ui/table/types/EntityFieldMetadata';
import { getLogoUrlFromDomainName } from '~/utils';
type OwnProps = {
fieldMetadata: EntityFieldMetadata;
editModeHorizontalAlign?: 'left' | 'right';
placeholder?: string;
};
export function GenericEditableRelationCellDisplayMode({
fieldMetadata,
}: OwnProps) {
const currentRowEntityId = useCurrentRowEntityId();
const fieldValue = useRecoilValue<any | null>(
tableEntityFieldFamilySelector({
entityId: currentRowEntityId ?? '',
fieldName: fieldMetadata.fieldName,
}),
);
switch (fieldMetadata.relationType) {
case Entity.Company: {
return (
<CompanyChip
id={fieldValue?.id ?? ''}
name={fieldValue?.name ?? ''}
pictureUrl={getLogoUrlFromDomainName(fieldValue?.domainName)}
/>
);
}
default:
console.warn(
`Unknown relation type: "${fieldMetadata.relationType}" in GenericEditableRelationCellEditMode`,
);
return <> </>;
}
}

View File

@ -0,0 +1,68 @@
import { useRecoilState } from 'recoil';
import { CompanyPickerCell } from '@/companies/components/CompanyPickerCell';
import { useUpdateEntityField } from '@/people/hooks/useUpdateEntityField';
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
import { useEditableCell } from '@/ui/table/editable-cell/hooks/useEditableCell';
import { useCurrentRowEntityId } from '@/ui/table/hooks/useCurrentEntityId';
import { tableEntityFieldFamilySelector } from '@/ui/table/states/tableEntityFieldFamilySelector';
import { EntityFieldMetadata } from '@/ui/table/types/EntityFieldMetadata';
type OwnProps = {
fieldMetadata: EntityFieldMetadata;
};
export function GenericEditableRelationCellEditMode({
fieldMetadata,
}: OwnProps) {
const currentRowEntityId = useCurrentRowEntityId();
const { closeEditableCell } = useEditableCell();
const [fieldValueEntity] = useRecoilState<any | null>(
tableEntityFieldFamilySelector({
entityId: currentRowEntityId ?? '',
fieldName: fieldMetadata.fieldName,
}),
);
const updateEntityField = useUpdateEntityField();
function handleEntitySubmit(newFieldEntity: EntityForSelect | null) {
if (
newFieldEntity?.id !== fieldValueEntity?.id &&
currentRowEntityId &&
updateEntityField
) {
updateEntityField(
currentRowEntityId,
fieldMetadata.fieldName,
newFieldEntity,
);
}
closeEditableCell();
}
function handleCancel() {
closeEditableCell();
}
switch (fieldMetadata.relationType) {
case Entity.Company: {
return (
<CompanyPickerCell
companyId={fieldValueEntity?.id ?? null}
onSubmit={handleEntitySubmit}
onCancel={handleCancel}
/>
);
}
default:
console.warn(
`Unknown relation type: "${fieldMetadata.relationType}" in GenericEditableRelationCellEditMode`,
);
return <></>;
}
}

View File

@ -1,7 +1,7 @@
import { useRecoilState } from 'recoil';
import { useUpdateEntityField } from '@/people/hooks/useUpdateEntityField';
import { InplaceInputTextEditMode } from '@/ui/inplace-input/components/InplaceInputTextEditMode';
import { useEntityUpdateFieldHook } from '@/ui/table/hooks/useCellUpdateFieldHook';
import { useCurrentRowEntityId } from '@/ui/table/hooks/useCurrentEntityId';
import { tableEntityFieldFamilySelector } from '@/ui/table/states/tableEntityFieldFamilySelector';
@ -23,8 +23,7 @@ export function GenericEditableTextCellEditMode({
}),
);
const useUpdateField = useEntityUpdateFieldHook();
const updateField = useUpdateField?.();
const updateField = useUpdateEntityField();
function handleSubmit(newText: string) {
if (newText === fieldValue) return;

View File

@ -1,7 +0,0 @@
import { useContext } from 'react';
import { EntityUpdateFieldHookContext } from '../states/EntityUpdateFieldHookContext';
export function useEntityUpdateFieldHook() {
return useContext(EntityUpdateFieldHookContext);
}

View File

@ -1,6 +0,0 @@
import { createContext } from 'react';
import { EntityUpdateFieldHook } from '../types/CellUpdateFieldHook';
export const EntityUpdateFieldHookContext =
createContext<EntityUpdateFieldHook | null>(null);

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const EntityUpdateMutationHookContext = createContext<any | null>(null);

View File

@ -1,10 +1,6 @@
export type EntityFieldType =
| 'text'
| 'number'
| 'date'
| 'select'
| 'checkbox'
| 'icon';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
export type EntityFieldType = 'text' | 'relation';
export type EntityFieldMetadata = {
fieldName: string;
@ -13,4 +9,5 @@ export type EntityFieldMetadata = {
icon: JSX.Element;
columnSize: number;
filterIcon?: JSX.Element;
relationType?: Entity; // TODO: condition this type with type === "relation"
};

View File

@ -0,0 +1,56 @@
import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
import { SingleEntitySelect } from '@/ui/relation-picker/components/SingleEntitySelect';
import { relationPickerSearchFilterScopedState } from '@/ui/relation-picker/states/relationPickerSearchFilterScopedState';
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
import { Entity } from '@/ui/relation-picker/types/EntityTypeForSelect';
import { useSearchUserQuery } from '~/generated/graphql';
export type OwnProps = {
userId: string;
onSubmit: (newUserId: string) => void;
onCancel?: () => void;
};
type UserForSelect = EntityForSelect & {
entityType: Entity.User;
};
export function UserPicker({ userId, onSubmit, onCancel }: OwnProps) {
const [searchFilter] = useRecoilScopedState(
relationPickerSearchFilterScopedState,
);
const users = useFilteredSearchEntityQuery({
queryHook: useSearchUserQuery,
selectedIds: [userId],
searchFilter: searchFilter,
mappingFunction: (user) => ({
entityType: Entity.User,
id: user.id,
name: user.displayName,
avatarType: 'rounded',
avatarUrl: user.avatarUrl ?? '',
}),
orderByField: 'firstName',
searchOnFields: ['firstName', 'lastName'],
});
async function handleEntitySelected(
selectedUser: UserForSelect | null | undefined,
) {
onSubmit(selectedUser?.id ?? '');
}
return (
<SingleEntitySelect
onEntitySelected={handleEntitySelected}
onCancel={onCancel}
entities={{
loading: users.loading,
entitiesToSelect: users.entitiesToSelect,
selectedEntity: users.selectedEntities[0],
}}
/>
);
}