Skip to content

Commit

Permalink
feat(explorer): can define custom primary actions
Browse files Browse the repository at this point in the history
  • Loading branch information
TdyP authored Nov 19, 2024
1 parent 3d13092 commit 89067e7
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 49 deletions.
41 changes: 23 additions & 18 deletions apps/data-studio/src/components/LibraryHome/LibraryHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {useAppDispatch, useAppSelector} from 'reduxStore/store';
import {explorerQueryParamName, isLibraryInApp, localizedTranslation} from 'utils';
import {IBaseInfo, InfoType, SharedStateSelectionType, WorkspacePanels} from '_types/types';
import {useSearchParams} from 'react-router-dom';
import {FaAccessibleIcon, FaBeer, FaJs, FaXbox} from 'react-icons/all';
import {FaAccessibleIcon, FaBeer, FaBirthdayCake, FaCheese, FaJs, FaXbox} from 'react-icons/all';
import styled from 'styled-components';

interface ILibraryHomeProps {
Expand Down Expand Up @@ -145,39 +145,44 @@ const LibraryHome: FunctionComponent<ILibraryHomeProps> = ({library}) => {
<Explorer
library={library}
defaultActionsForItem={['edit', 'deactivate']}
defaultMainActions={['create']}
defaultPrimaryActions={['create']}
itemActions={[
{
label: 'Test 1',
icon: <FaBeer />,
callback: item => {
// eslint-disable-next-line no-restricted-syntax
console.log(1, item);
}
callback: item => console.info(1, item)
},
{
label: 'Test 2',
icon: <FaAccessibleIcon />,
callback: item => {
// eslint-disable-next-line no-restricted-syntax
console.log(2, item);
}
callback: item => console.info(2, item)
},
{
label: 'Test 3',
icon: <FaXbox />,
callback: item => {
// eslint-disable-next-line no-restricted-syntax
console.log(3, item);
}
callback: item => console.info(3, item)
},
{
label: 'Test 4',
icon: <FaJs />,
callback: item => {
// eslint-disable-next-line no-restricted-syntax
console.log(4, item);
}
callback: item => console.info(4, item)
}
]}
primaryActions={[
{
icon: <FaBeer />,
label: 'Additional action 1',
callback: () => console.info('Clicked action 1')
},
{
icon: <FaCheese />,
label: 'Additional action 2',
callback: () => console.info('Clicked action 2')
},
{
icon: <FaBirthdayCake />,
label: 'Additional action 3',
callback: () => console.info('Clicked action 3')
}
]}
/>
Expand Down
54 changes: 53 additions & 1 deletion libs/ui/src/components/Explorer/Explorer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {Mockify} from '@leav/utils';
import userEvent from '@testing-library/user-event';
import {Fa500Px, FaAccessibleIcon, FaBeer, FaJs, FaXbox} from 'react-icons/fa';
import {mockRecord} from '_ui/__mocks__/common/record';
import {IItemAction} from './_types';
import {IItemAction, IPrimaryAction} from './_types';

jest.mock('_ui/components/RecordEdition/EditRecordModal', () => ({
EditRecordModal: () => <div>EditRecordModal</div>
Expand Down Expand Up @@ -104,6 +104,19 @@ describe('Explorer', () => {
}
};

const customPrimaryActions: IPrimaryAction[] = [
{
label: 'Additional action 1',
icon: <FaBeer />,
callback: jest.fn()
},
{
label: 'Additional action 2',
icon: <FaAccessibleIcon />,
callback: jest.fn()
}
];

beforeAll(() => {
jest.spyOn(gqlTypes, 'useExplorerQuery').mockImplementation(
() => mockExplorerQueryResult as gqlTypes.ExplorerQueryResult
Expand Down Expand Up @@ -246,4 +259,43 @@ describe('Explorer', () => {

expect(await screen.findByText('EditRecordModal')).toBeVisible();
});

test('Should be able to display custom primary actions', async () => {
render(<Explorer library="campaigns" primaryActions={customPrimaryActions} />);

const createButton = screen.getByRole('button', {name: /create/});
const dropdownButton = createButton.nextElementSibling; // Not nice, but no way to get the dropdown button directly

expect(screen.queryByText(/Additional action 1/)).not.toBeInTheDocument();
expect(screen.queryByText(/Additional action 2/)).not.toBeInTheDocument();

await user.click(dropdownButton);

expect(await screen.findByRole('menuitem', {name: /Additional action 1/})).toBeVisible();
expect(await screen.findByRole('menuitem', {name: /Additional action 2/})).toBeVisible();

await user.click(screen.getByRole('menuitem', {name: /Additional action 1/}));
expect(customPrimaryActions[0].callback).toHaveBeenCalled();
});

test('Should be able to display custom primary actions without create button', async () => {
render(<Explorer library="campaigns" primaryActions={customPrimaryActions} defaultPrimaryActions={[]} />);

expect(screen.queryByRole('button', {name: /create/})).not.toBeInTheDocument();
const firstActionButton = screen.getByRole('button', {name: /Additional action 1/});
expect(firstActionButton).toBeVisible();

await user.click(firstActionButton);
expect(customPrimaryActions[0].callback).toHaveBeenCalled();

const dropdownButton = firstActionButton.nextElementSibling; // Not nice, but no way to get the dropdown button directly
expect(screen.queryByText(/Additional action 2/)).not.toBeInTheDocument();

await user.click(dropdownButton);

expect(await screen.findByRole('menuitem', {name: /Additional action 2/})).toBeVisible();

await user.click(screen.getByRole('menuitem', {name: /Additional action 2/}));
expect(customPrimaryActions[1].callback).toHaveBeenCalled();
});
});
33 changes: 18 additions & 15 deletions libs/ui/src/components/Explorer/Explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,23 @@
import {FunctionComponent} from 'react';
import {KitSpace, KitTypography} from 'aristid-ds';
import styled from 'styled-components';
import {IItemAction} from './_types';
import {IItemAction, IPrimaryAction} from './_types';
import {DataView} from './DataView';
import {useOpenSettings} from './edit-settings/useOpenSettings';
import {useExplorerData} from './_queries/useExplorerData';
import {useDeactivateAction} from './useDeactivateAction';
import {useEditAction} from './useEditAction';
import {useCreateMainAction} from './useCreateMainAction';
import {usePrimaryActionsButton as usePrimaryActionsButton} from './usePrimaryActions';
import {ExplorerTitle} from './ExplorerTitle';
import {useCreateAction} from './useCreateAction';

interface IExplorerProps {
library: string;
itemActions?: IItemAction[];
primaryActions?: IPrimaryAction[];
title?: string;
defaultActionsForItem?:
| []
| ['deactivate']
| ['edit']
| ['edit', 'deactivate']
| ['deactivate', 'edit']
| undefined;
defaultMainActions?: [] | ['create'];
defaultActionsForItem?: Array<'edit' | 'deactivate'>;
defaultPrimaryActions?: Array<'create'>;
}

const isNotEmpty = <T extends unknown[]>(union: T): union is Exclude<T, []> => union.length > 0;
Expand All @@ -39,9 +35,10 @@ const ExplorerHeaderDivStyled = styled.div`
export const Explorer: FunctionComponent<IExplorerProps> = ({
library,
itemActions,
primaryActions,
title,
defaultActionsForItem = ['edit', 'deactivate'],
defaultMainActions = ['create']
defaultPrimaryActions = ['create']
}) => {
const currentAttribute = 'id';

Expand All @@ -55,14 +52,20 @@ export const Explorer: FunctionComponent<IExplorerProps> = ({
isEnabled: isNotEmpty(defaultActionsForItem) && defaultActionsForItem.includes('edit')
});

const {createButton, createModal} = useCreateMainAction({
isEnabled: isNotEmpty(defaultMainActions) && defaultMainActions.includes('create'),
const {createAction, createModal} = useCreateAction({
isEnabled: isNotEmpty(defaultPrimaryActions) && defaultPrimaryActions.includes('create'),
library,
refetch
});

const enabledDefaultActions = createAction ? [createAction] : [];

const {primaryButton} = usePrimaryActionsButton([...enabledDefaultActions, ...(primaryActions ?? [])]);

const settingsButton = useOpenSettings(library);

const dedupItemActions = [...new Set([editAction, deactivateAction, ...(itemActions ?? [])].filter(Boolean))];

return (
<>
{loading ? (
Expand All @@ -75,13 +78,13 @@ export const Explorer: FunctionComponent<IExplorerProps> = ({
</KitTypography.Title>
<KitSpace size="xs">
{settingsButton}
{createButton}
{primaryButton}
</KitSpace>
</ExplorerHeaderDivStyled>
<DataView
dataGroupedFilteredSorted={data ?? []}
attributesToDisplay={[currentAttribute, 'whoAmI']}
itemActions={[editAction, deactivateAction, ...(itemActions ?? [])].filter(Boolean)}
itemActions={dedupItemActions}
/>
</>
)}
Expand Down
6 changes: 6 additions & 0 deletions libs/ui/src/components/Explorer/_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ export interface IItemAction {
isDanger?: boolean;
}

export interface IPrimaryAction {
callback: () => void;
icon: ReactElement;
label: string;
}

export type ActionHook<T = {}> = {isEnabled: boolean} & T;
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
// This file is released under LGPL V3
// License text available at https://www.gnu.org/licenses/lgpl-3.0.txt
import {useState} from 'react';
import {KitButton} from 'aristid-ds';
import {FaPlus} from 'react-icons/fa';
import {EditRecordModal} from '_ui/components';
import {useSharedTranslation} from '_ui/hooks/useSharedTranslation';
import {ActionHook} from './_types';
import {ActionHook, IPrimaryAction} from './_types';

/**
* Hook used to get the action for `<DataView />` component.
Expand All @@ -19,7 +18,7 @@ import {ActionHook} from './_types';
* @param library - the library's id to add new item
* @param refetch - method to call to refresh the list. New item will be visible if it matches filters and sorts
*/
export const useCreateMainAction = ({
export const useCreateAction = ({
isEnabled,
library,
refetch
Expand All @@ -31,18 +30,16 @@ export const useCreateMainAction = ({

const [isRecordCreationVisible, setRecordCreationVisible] = useState(false);

const createAction: IPrimaryAction = {
callback: () => {
setRecordCreationVisible(true);
},
icon: <FaPlus />,
label: t('explorer.create-one')
};

return {
createButton: isEnabled ? (
<KitButton
type="primary"
icon={<FaPlus /> /* TODO: move to font-awesome 6 icons */}
onClick={() => {
setRecordCreationVisible(true);
}}
>
{t('explorer.create-one')}
</KitButton>
) : null,
createAction: isEnabled ? createAction : null,
createModal: isRecordCreationVisible ? (
<EditRecordModal
open
Expand All @@ -51,7 +48,7 @@ export const useCreateMainAction = ({
onClose={() => {
setRecordCreationVisible(false);
}}
onCreate={ignoreNewItem => {
onCreate={() => {
refetch();
setRecordCreationVisible(false);
}}
Expand Down
45 changes: 45 additions & 0 deletions libs/ui/src/components/Explorer/usePrimaryActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 {IPrimaryAction} from './_types';
import {KitButton, KitSpace} from 'aristid-ds';

/**
* Hook used to get the primary actions for `<DataView />` component.
*
* Based on default actions and custom actions, it returns a primary with first action as "main" action and the others
* in the dropdown accessible via a split button.
*
* When the creation is done, we refresh all data even if the new record will not be visible due to some filters.
*
* It returns also two parts : one for the call action button - one for displayed the modal required by the action.
* It also returns the modal required for default actions (like create record).
*
* @param isEnabled - whether the action is present
* @param library - the library's id to add new item
* @param refetch - method to call to refresh the list. New item will be visible if it matches filters and sorts
*/
export const usePrimaryActionsButton = (actions: IPrimaryAction[]) => {
const [mainAction, ...dropdownActions] = actions;

return {
primaryButton: mainAction ? (
<KitButton
type="primary"
icon={mainAction.icon}
onClick={mainAction.callback}
items={dropdownActions.map((action, index) => ({
key: index,
label: (
<KitSpace size={8}>
{action.icon} {action.label}
</KitSpace>
),
onClick: action.callback
}))}
>
{mainAction.label}
</KitButton>
) : null
};
};

0 comments on commit 89067e7

Please sign in to comment.