From 1d496c7ac8a881aada872d3be62db83db8c94821 Mon Sep 17 00:00:00 2001 From: emile Date: Fri, 17 Jan 2025 13:53:17 +0100 Subject: [PATCH] feat(explorer): create and link a new record to link attribute --- libs/ui/src/_gqlTypes/index.ts | 10 +- .../src/components/Explorer/Explorer.test.tsx | 107 +++++++++++++++--- libs/ui/src/components/Explorer/Explorer.tsx | 3 + .../_queries/attributesDataQuery.graphql | 1 + libs/ui/src/components/Explorer/_types.ts | 1 + .../components/Explorer/useCreateAction.tsx | 46 +++++++- .../components/Explorer/usePrimaryActions.tsx | 52 ++++++--- .../Explorer/useViewSettingsReducer.ts | 7 +- .../RecordEdition/EditRecordContent/_types.ts | 2 +- 9 files changed, 187 insertions(+), 42 deletions(-) diff --git a/libs/ui/src/_gqlTypes/index.ts b/libs/ui/src/_gqlTypes/index.ts index 51bef81ac..291f53ce1 100644 --- a/libs/ui/src/_gqlTypes/index.ts +++ b/libs/ui/src/_gqlTypes/index.ts @@ -1,8 +1,8 @@ // Copyright LEAV Solutions 2017 until 2023/11/05, Copyright Aristid from 2023/11/06 // This file is released under LGPL V3 // License text available at https://www.gnu.org/licenses/lgpl-3.0.txt -import {IPreviewScalar} from '@leav/utils'; -import {gql} from '@apollo/client'; +import {IPreviewScalar} from '@leav/utils' +import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; @@ -284,7 +284,6 @@ export enum FormsSortableFields { system = 'system' } - export type GlobalSettingsFileInput = { library: Scalars['String']; recordId: Scalars['String']; @@ -1363,7 +1362,7 @@ export type ExplorerLinkAttributeQueryVariables = Exact<{ }>; -export type ExplorerLinkAttributeQuery = { attributes?: { list: Array<{ label?: any | null, id: string, linked_library?: { id: string } | null } | { id: string }> } | null }; +export type ExplorerLinkAttributeQuery = { attributes?: { list: Array<{ label?: any | null, id: string, multiple_values: boolean, linked_library?: { id: string } | null } | { id: string, multiple_values: boolean }> } | null }; export type ExplorerLibraryDataQueryVariables = Exact<{ libraryId: Scalars['ID']; @@ -4093,6 +4092,7 @@ export const ExplorerLinkAttributeDocument = gql` attributes(filters: {ids: [$id]}) { list { id + multiple_values ...LinkAttributeDetails } } @@ -4313,4 +4313,4 @@ export function useTreeDataQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti } export type TreeDataQueryQueryHookResult = ReturnType; export type TreeDataQueryLazyQueryHookResult = ReturnType; -export type TreeDataQueryQueryResult = Apollo.QueryResult; +export type TreeDataQueryQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/libs/ui/src/components/Explorer/Explorer.test.tsx b/libs/ui/src/components/Explorer/Explorer.test.tsx index 5c9d44355..0a51fb337 100644 --- a/libs/ui/src/components/Explorer/Explorer.test.tsx +++ b/libs/ui/src/components/Explorer/Explorer.test.tsx @@ -10,11 +10,18 @@ import * as gqlTypes from '_ui/_gqlTypes'; import {mockRecord} from '_ui/__mocks__/common/record'; import {Explorer} from '_ui/index'; import {IEntrypointLibrary, IEntrypointLink, IItemAction, IPrimaryAction} from './_types'; +import * as useExecuteSaveValueBatchMutation from '../RecordEdition/EditRecordContent/hooks/useExecuteSaveValueBatchMutation'; +import * as useExplorerData from './_queries/useExplorerData'; const EditRecordModalMock = 'EditRecordModal'; jest.mock('_ui/components/RecordEdition/EditRecordModal', () => ({ - EditRecordModal: () =>
{EditRecordModalMock}
+ EditRecordModal: ({onCreate}) => ( +
+ {EditRecordModalMock} + +
+ ) })); jest.mock('@uidotdev/usehooks', () => ({ @@ -513,6 +520,7 @@ describe('Explorer', () => { const explorerLinkAttribute = { id: 'link_attribute', + multiple_values: true, label: { en: 'Delivery Platforms', fr: 'Plateformes de diffusion' @@ -541,6 +549,23 @@ describe('Explorer', () => { } }; + const ExplorerLinkAttributeMonoValueQueryMock = { + request: { + query: gqlTypes.ExplorerLinkAttributeDocument, + variables: { + id: linkEntrypoint.linkAttributeId + } + }, + result: { + data: { + attributes: { + list: [explorerLinkAttribute], + __typename: 'AttributesList' + } + } + } + }; + beforeEach(() => { spyUseExplorerLibraryDataQuery = jest .spyOn(gqlTypes, 'useExplorerLibraryDataQuery') @@ -573,6 +598,11 @@ describe('Explorer', () => { user = userEvent.setup(); }); + afterEach(() => { + // restore the spy created with spyOn + jest.restoreAllMocks(); + }); + describe('props title', () => { test('Should display library label as title', () => { render(); @@ -788,12 +818,61 @@ describe('Explorer', () => { expect(screen.getByText(EditRecordModalMock)).toBeVisible(); }); + test('should not try to link created record if entrypoint is not a link', async () => { + const saveValues = jest.fn(); + jest.spyOn(useExecuteSaveValueBatchMutation, 'default').mockReturnValue({ + loading: false, + saveValues + }); + + jest.spyOn(useExplorerData, 'useExplorerData').mockReturnValue({ + refetch: jest.fn(), + loading: false, + data: null + }); + + render(); + + await user.click(screen.getByRole('button', {name: 'explorer.create-one'})); + + expect(screen.getByText(EditRecordModalMock)).toBeVisible(); + const createButtonLibrary = screen.getByRole('button', {name: 'create-record'}); + await user.click(createButtonLibrary); + + expect(saveValues).not.toHaveBeenCalled(); + }); + + test('Should be able to link a new record', async () => { + const saveValues = jest.fn(); + jest.spyOn(useExecuteSaveValueBatchMutation, 'default').mockReturnValue({ + loading: false, + saveValues + }); + + const spiedModule = jest.spyOn(useExplorerData, 'useExplorerData').mockReturnValue({ + refetch: jest.fn(), + loading: false, + data: null + }); + + render(, { + mocks: [ExplorerLinkAttributeQueryMock] + }); + + await user.click(screen.getByRole('button', {name: 'explorer.create-one'})); + + expect(screen.getByText(EditRecordModalMock)).toBeVisible(); + + const createButton = screen.getByRole('button', {name: 'create-record'}); + await user.click(createButton); + + expect(saveValues).toHaveBeenCalled(); + }); + test('Should be able to display custom primary actions', async () => { render(); - const createButton = screen.getByRole('button', {name: 'explorer.create-one'}); - const dropdownButton = createButton.nextElementSibling; // Not nice, but no way to get the dropdown button directly - + const dropdownButton = screen.getByTestId('actions-dropdown'); expect(screen.queryByText(customPrimaryAction1.label)).not.toBeInTheDocument(); expect(screen.queryByText(customPrimaryAction2.label)).not.toBeInTheDocument(); @@ -822,7 +901,7 @@ describe('Explorer', () => { await user.click(firstActionButton); expect(customPrimaryActions[0].callback).toHaveBeenCalled(); - const dropdownButton = firstActionButton.nextElementSibling; // Not nice, but no way to get the dropdown button directly + const dropdownButton = screen.getByTestId('actions-dropdown'); expect(screen.queryByText(customPrimaryAction2.label)).not.toBeInTheDocument(); await user.click(dropdownButton!); @@ -916,16 +995,16 @@ describe('Explorer', () => { } }; - const spy = jest - .spyOn(gqlTypes, 'useExplorerLibraryDataQuery') - .mockImplementation( - ({variables}) => - (Array.isArray(variables?.filters) && variables.filters.length - ? mockExplorerLibraryDataQueryResultWithFilters - : mockExplorerLibraryDataQueryResult) as gqlTypes.ExplorerLibraryDataQueryResult - ); - test('should handle filters for the request and for the display', async () => { + const spy = jest + .spyOn(gqlTypes, 'useExplorerLibraryDataQuery') + .mockImplementation( + ({variables}) => + (Array.isArray(variables?.filters) && variables.filters.length + ? mockExplorerLibraryDataQueryResultWithFilters + : mockExplorerLibraryDataQueryResult) as gqlTypes.ExplorerLibraryDataQueryResult + ); + render( = ({ const {createAction, createModal} = useCreateAction({ isEnabled: isNotEmpty(defaultPrimaryActions) && defaultPrimaryActions.includes('create'), library: view.libraryId, + entrypoint: view.entrypoint, + itemsNumber: data?.totalCount ?? 0, refetch }); diff --git a/libs/ui/src/components/Explorer/_queries/attributesDataQuery.graphql b/libs/ui/src/components/Explorer/_queries/attributesDataQuery.graphql index 3b591785f..773ef84a9 100644 --- a/libs/ui/src/components/Explorer/_queries/attributesDataQuery.graphql +++ b/libs/ui/src/components/Explorer/_queries/attributesDataQuery.graphql @@ -20,6 +20,7 @@ query ExplorerLinkAttribute($id: ID!) { attributes(filters: { ids: [$id] }) { list { id + multiple_values ...LinkAttributeDetails } } diff --git a/libs/ui/src/components/Explorer/_types.ts b/libs/ui/src/components/Explorer/_types.ts index d1f241fdb..66611534e 100644 --- a/libs/ui/src/components/Explorer/_types.ts +++ b/libs/ui/src/components/Explorer/_types.ts @@ -43,6 +43,7 @@ export interface IItemAction { export interface IPrimaryAction { callback: () => void; + disabled?: boolean; icon: ReactElement; label: string; } diff --git a/libs/ui/src/components/Explorer/useCreateAction.tsx b/libs/ui/src/components/Explorer/useCreateAction.tsx index 82e80181e..286d4f6ae 100644 --- a/libs/ui/src/components/Explorer/useCreateAction.tsx +++ b/libs/ui/src/components/Explorer/useCreateAction.tsx @@ -5,7 +5,9 @@ import {useState} from 'react'; import {FaPlus} from 'react-icons/fa'; import {EditRecordModal} from '_ui/components'; import {useSharedTranslation} from '_ui/hooks/useSharedTranslation'; -import {ActionHook, IPrimaryAction} from './_types'; +import {ActionHook, Entrypoint, IEntrypointLink, IPrimaryAction} from './_types'; +import useSaveValueBatchMutation from '../RecordEdition/EditRecordContent/hooks/useExecuteSaveValueBatchMutation'; +import {useExplorerLinkAttributeQuery} from '_ui/_gqlTypes'; /** * Hook used to get the action for `` component. @@ -21,20 +23,43 @@ import {ActionHook, IPrimaryAction} from './_types'; export const useCreateAction = ({ isEnabled, library, + entrypoint, + itemsNumber, refetch }: ActionHook<{ library: string; + entrypoint: Entrypoint; + itemsNumber: number; refetch: () => void; }>) => { const {t} = useSharedTranslation(); const [isRecordCreationVisible, setRecordCreationVisible] = useState(false); + const [multipleValues, setIsMultivalues] = useState(false); + const {saveValues} = useSaveValueBatchMutation(); + + useExplorerLinkAttributeQuery({ + skip: entrypoint.type !== 'link', + variables: { + id: (entrypoint as IEntrypointLink).linkAttributeId + }, + onCompleted: data => { + const attributeData = data?.attributes?.list?.[0]; + if (!attributeData) { + throw new Error('Unknown link attribute'); + } + setIsMultivalues(attributeData.multiple_values); + } + }); + + const canCreateRecord = entrypoint.type === 'library' ? true : multipleValues || itemsNumber === 0; const createAction: IPrimaryAction = { callback: () => { setRecordCreationVisible(true); }, icon: , + disabled: !canCreateRecord, label: t('explorer.create-one') }; @@ -48,9 +73,26 @@ export const useCreateAction = ({ onClose={() => { setRecordCreationVisible(false); }} - onCreate={() => { + onCreate={newRecord => { refetch(); setRecordCreationVisible(false); + if (entrypoint.type === 'link') { + saveValues( + { + id: entrypoint.parentRecordId, + library: { + id: entrypoint.parentLibraryId + } + }, + [ + { + attribute: entrypoint.linkAttributeId, + idValue: null, + value: newRecord.id + } + ] + ); + } }} submitButtons={['create']} /> diff --git a/libs/ui/src/components/Explorer/usePrimaryActions.tsx b/libs/ui/src/components/Explorer/usePrimaryActions.tsx index c732e8719..c0b62fe51 100644 --- a/libs/ui/src/components/Explorer/usePrimaryActions.tsx +++ b/libs/ui/src/components/Explorer/usePrimaryActions.tsx @@ -1,8 +1,9 @@ // Copyright LEAV Solutions 2017 until 2023/11/05, Copyright Aristid from 2023/11/06 // This file is released under LGPL V3 // License text available at https://www.gnu.org/licenses/lgpl-3.0.txt +import {FaEllipsisV} from 'react-icons/fa'; import {IPrimaryAction} from './_types'; -import {KitButton, KitSpace} from 'aristid-ds'; +import {KitButton, KitDropDown, KitSpace} from 'aristid-ds'; /** * Hook used to get the primary actions for `` component. @@ -18,22 +19,39 @@ export const usePrimaryActionsButton = (actions: IPrimaryAction[]) => { return { primaryButton: actions.length === 0 ? null : ( - ({ - key: index, - label: ( - - {action.icon} {action.label} - - ), - onClick: action.callback - }))} - > - {firstAction.label} - + <> + + {firstAction.label} + + {dropdownActions.length > 0 && ( + ({ + key: index, + label: ( + + {action.icon} {action.label} + + ), + disabled: action.disabled, + onClick: action.callback + })) + }} + > + } + > + + )} + ) }; }; diff --git a/libs/ui/src/components/Explorer/useViewSettingsReducer.ts b/libs/ui/src/components/Explorer/useViewSettingsReducer.ts index 220f7055c..369a37049 100644 --- a/libs/ui/src/components/Explorer/useViewSettingsReducer.ts +++ b/libs/ui/src/components/Explorer/useViewSettingsReducer.ts @@ -30,7 +30,8 @@ const _isValidFieldFilter = (filter: ViewDetailsFilterFragment): filter is Valid const _isLinkAttributeDetails = ( linkAttributeData: NonNullable['list'][number] -): linkAttributeData is LinkAttributeDetailsFragment & {id: string} => 'linked_library' in linkAttributeData; +): linkAttributeData is LinkAttributeDetailsFragment & {id: string; multiple_values: boolean} => + 'linked_library' in linkAttributeData; export const useViewSettingsReducer = (entrypoint: Entrypoint, defaultViewSettings: DefaultViewSettings = {}) => { const {lang} = useLang(); @@ -50,7 +51,6 @@ export const useViewSettingsReducer = (entrypoint: Entrypoint, defaultViewSettin if (!attributeData) { throw new Error('Unknown link attribute'); } - setLibraryId(_isLinkAttributeDetails(attributeData) ? (attributeData.linked_library?.id ?? '') : null); } }); @@ -144,7 +144,8 @@ export const useViewSettingsReducer = (entrypoint: Entrypoint, defaultViewSettin id: uuid(), attribute: { label: localizedTranslation(attributesDataById[filter.field].label, lang), - format: attributesDataById[filter.field].format + format: attributesDataById[filter.field].format, + multi_values: attributesDataById[filter.field].multi_values } } ]; diff --git a/libs/ui/src/components/RecordEdition/EditRecordContent/_types.ts b/libs/ui/src/components/RecordEdition/EditRecordContent/_types.ts index 23ac41dab..32255600e 100644 --- a/libs/ui/src/components/RecordEdition/EditRecordContent/_types.ts +++ b/libs/ui/src/components/RecordEdition/EditRecordContent/_types.ts @@ -24,7 +24,7 @@ import {GetRecordColumnsValuesRecord, RecordColumnValue} from '_ui/_queries/reco export interface IValueToSubmit { attribute: string; value: AnyPrimitive | null; - idValue: string; + idValue: string | null; metadata?: IKeyValue; }