diff --git a/src/adminjs.ts b/src/adminjs.ts index 3d0350dab..9fef940fd 100644 --- a/src/adminjs.ts +++ b/src/adminjs.ts @@ -16,7 +16,7 @@ import { ACTIONS } from './backend/actions/index.js' import loginTemplate from './frontend/login-template.js' import { ListActionResponse } from './backend/actions/list/list-action.js' -import { defaultLocale, Locale } from './locale/index.js' +import { Locale } from './locale/index.js' import { TranslateFunctions } from './utils/translate-functions.factory.js' import { relativeFilePathResolver } from './utils/file-resolver.js' import { Router } from './backend/utils/index.js' diff --git a/src/frontend/components/app/action-header/action-header.tsx b/src/frontend/components/app/action-header/action-header.tsx index 6c38b3506..1129a09e6 100644 --- a/src/frontend/components/app/action-header/action-header.tsx +++ b/src/frontend/components/app/action-header/action-header.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import { Badge, Box, ButtonGroup, cssClass, H2, H3 } from '@adminjs/design-system' import React from 'react' -import { useNavigate } from 'react-router' +import { useNavigate, useLocation } from 'react-router' import allowOverride from '../../../hoc/allow-override.js' import { useActionResponseHandler, useTranslation, useModal } from '../../../hooks/index.js' @@ -31,6 +31,7 @@ const ActionHeader: React.FC = (props) => { const translateFunctions = useTranslation() const { translateButton, translateAction } = translateFunctions const navigate = useNavigate() + const location = useLocation() const actionResponseHandler = useActionResponseHandler(actionPerformed) const modalFunctions = useModal() @@ -46,6 +47,7 @@ const ActionHeader: React.FC = (props) => { params, actionResponseHandler, navigate, + location, translateFunctions, modalFunctions, })(event) @@ -90,6 +92,7 @@ const ActionHeader: React.FC = (props) => { const cssActionsMB = action.showInDrawer ? 'xl' : 'default' const CssHComponent = action.showInDrawer ? H3 : H2 const contentTag = getActionElementCss(resourceId, action.name, 'action-header') + return ( {action.showInDrawer ? '' : ( diff --git a/src/frontend/components/app/action-header/styled-back-button.tsx b/src/frontend/components/app/action-header/styled-back-button.tsx index 360dca356..3ba6db66b 100644 --- a/src/frontend/components/app/action-header/styled-back-button.tsx +++ b/src/frontend/components/app/action-header/styled-back-button.tsx @@ -1,15 +1,14 @@ -import React, { useMemo } from 'react' +import React from 'react' import { Link as RouterLink } from 'react-router-dom' +import { useLocation } from 'react-router' import { ButtonCSS, ButtonProps, Icon, } from '@adminjs/design-system' import { styled } from '@adminjs/design-system/styled-components' -import { useSelector } from 'react-redux' import allowOverride from '../../../hoc/allow-override.js' -import type { DrawerInState, ReduxState, RouterInState } from '../../../store/index.js' // eslint-disable-next-line @typescript-eslint/no-unused-vars const StyledLink = styled(({ rounded, to, ...rest }) => )`${ButtonCSS}` @@ -19,27 +18,18 @@ export type StyledBackButtonProps = { } const StyledBackButton: React.FC = (props) => { + const location = useLocation() const { showInDrawer } = props - const { previousRoute } = useSelector((state) => state.drawer) - const { from = {} } = useSelector((state) => state.router) const cssCloseIcon = showInDrawer ? 'ChevronRight' : 'ChevronLeft' - const backLink = useMemo(() => { - if (!showInDrawer) { - return from?.pathname - } - - if (previousRoute?.pathname) { - return `${previousRoute?.pathname}${previousRoute?.search}` - } - - return from?.pathname - }, [previousRoute, from]) - return ( = (props) => { } = props const [record, setRecord] = useState(recordFromProps) const navigate = useNavigate() + const location = useLocation() const translateFunctions = useTranslation() const modalFunctions = useModal() @@ -65,6 +66,7 @@ const RecordInList: React.FC = (props) => { params: { resourceId: resource.id, recordId: record.id }, actionResponseHandler, navigate, + location, translateFunctions, modalFunctions, })(event) @@ -79,6 +81,7 @@ const RecordInList: React.FC = (props) => { params: actionParams, actionResponseHandler, navigate, + location, translateFunctions, modalFunctions, })(event) diff --git a/src/frontend/components/app/records-table/selected-records.tsx b/src/frontend/components/app/records-table/selected-records.tsx index 6df4c176f..15457719e 100644 --- a/src/frontend/components/app/records-table/selected-records.tsx +++ b/src/frontend/components/app/records-table/selected-records.tsx @@ -1,6 +1,6 @@ import React from 'react' import { TableCaption, Title, ButtonGroup, Box } from '@adminjs/design-system' -import { useNavigate } from 'react-router' +import { useNavigate, useLocation } from 'react-router' import { ActionJSON, buildActionClickHandler, RecordJSON, ResourceJSON } from '../../../interfaces/index.js' import getBulkActionsFromRecords from './utils/get-bulk-actions-from-records.js' @@ -19,6 +19,7 @@ const SelectedRecords: React.FC = (props) => { const translateFunctions = useTranslation() const { translateLabel } = translateFunctions const navigate = useNavigate() + const location = useLocation() const actionResponseHandler = useActionResponseHandler() const modalFunctions = useModal() @@ -37,6 +38,7 @@ const SelectedRecords: React.FC = (props) => { params, actionResponseHandler, navigate, + location, translateFunctions, modalFunctions, })(event) diff --git a/src/frontend/components/application.tsx b/src/frontend/components/application.tsx index ac9a7a725..a8ef24a13 100644 --- a/src/frontend/components/application.tsx +++ b/src/frontend/components/application.tsx @@ -11,7 +11,12 @@ import Notice from './app/notice.js' import allowOverride from '../hoc/allow-override.js' import { AdminModal as Modal } from './app/admin-modal.js' import { - DashboardRoute, ResourceActionRoute, RecordActionRoute, PageRoute, BulkActionRoute, ResourceRoute, + DashboardRoute, + ResourceActionRoute, + RecordActionRoute, + PageRoute, + BulkActionRoute, + ResourceRoute, } from './routes/index.js' import useHistoryListen from '../hooks/use-history-listen.js' @@ -24,7 +29,9 @@ const App: React.FC = () => { useHistoryListen() useEffect(() => { - if (sidebarVisible) { toggleSidebar(false) } + if (sidebarVisible) { + toggleSidebar(false) + } }, [location]) const resourceId = ':resourceId' @@ -32,29 +39,19 @@ const App: React.FC = () => { const recordId = ':recordId' const pageName = ':pageName' + // Note: replaces are required so that record/resource/bulk actions urls + // are relative to their parent route const dashboardUrl = h.dashboardUrl() - const recordActionUrl = h.recordActionUrl({ resourceId, recordId, actionName }) + const resourceUrl = h.resourceUrl({ resourceId }) + const recordActionUrl = h + .recordActionUrl({ resourceId, recordId, actionName }) + .replace(resourceUrl, '').substring(1) const resourceActionUrl = h.resourceActionUrl({ resourceId, actionName }) + .replace(resourceUrl, '').substring(1) const bulkActionUrl = h.bulkActionUrl({ resourceId, actionName }) - const resourceUrl = h.resourceUrl({ resourceId }) + .replace(resourceUrl, '').substring(1) const pageUrl = h.pageUrl(pageName) - /** - * When defining AdminJS routes, we use Routes component twice. - * This results in warnings appearing in console, for example about not being able to locate - * "/admin" route. They can be safely ignored though and should appear only - * in development environment. The warnings originate from the difference between - * "Switch" component that AdminJS had used in "react-router" v5 which was later replaced - * with "Routes" in "react-router" v6. "Switch" would use the first "Route" component - * that matched the provided path, while "Routes" searches for the best matching pattern. - * In AdminJS we use "DrawerPortal" to display actions in a drawer when - * "showInDrawer" option is set to true. The drawer should appear above the currently viewed - * page, but "Routes" broke this behavior because it instead showed a record action route with - * an empty background. - * The current flow is that first "Routes" component includes "Resource" route component - * for drawer-placed actions and the second "Routes" is entered for record actions - * on a separate page. - */ return ( {sidebarVisible ? ( @@ -70,14 +67,19 @@ const App: React.FC = () => { - } /> - } /> - } /> - - - } /> - } /> - } /> + + } /> + + + } /> + } /> + } /> + } /> + + + } /> + + } /> diff --git a/src/frontend/components/routes/bulk-action.tsx b/src/frontend/components/routes/bulk-action.tsx index 5cb840c7e..11b5044f9 100644 --- a/src/frontend/components/routes/bulk-action.tsx +++ b/src/frontend/components/routes/bulk-action.tsx @@ -26,34 +26,42 @@ const BulkAction: React.FC = () => { const params = useParams() const [records, setRecords] = useState>([]) const [loading, setLoading] = useState(false) + const [tag, setTag] = useState('') + const [filterVisible, setFilterVisible] = useState(false) const { translateMessage } = useTranslation() const addNotice = useNotice() const location = useLocation() const { resourceId, actionName } = params + const resource = useResource(resourceId!) + const listActionName = 'list' + const listAction = resource?.resourceActions.find((r) => r.name === listActionName) const fetchRecords = (): Promise => { const recordIdsString = new URLSearchParams(location.search).get('recordIds') const recordIds = recordIdsString ? recordIdsString.split(',') : [] setLoading(true) - return api.bulkAction({ - resourceId: resourceId!, - recordIds, - actionName: actionName!, - }).then((response) => { - setLoading(false) - setRecords(response.data.records) - }).catch((error) => { - setLoading(false) - addNotice({ - message: 'errorFetchingRecords', - type: 'error', - resourceId, + return api + .bulkAction({ + resourceId: resourceId!, + recordIds, + actionName: actionName!, + }) + .then((response) => { + setLoading(false) + setRecords(response.data.records) + }) + .catch((error) => { + setLoading(false) + addNotice({ + message: 'errorFetchingRecords', + type: 'error', + resourceId, + }) + throw error }) - throw error - }) } useEffect(() => { @@ -61,7 +69,7 @@ const BulkAction: React.FC = () => { }, [params.resourceId, params.actionName]) if (!resource) { - return () + return } if (!records && !loading) { @@ -76,38 +84,62 @@ const BulkAction: React.FC = () => { if (loading) { const actionFromResource = resource.actions.find((r) => r.name === actionName) - return actionFromResource?.showInDrawer ? () : + return actionFromResource?.showInDrawer ? ( + + + + ) : ( + + ) } if (!action) { - return () + return } if (action.showInDrawer) { + if (!listAction) { + return ( + + + + ) + } + + const toggleFilter = listAction.showFilter + ? (): void => setFilterVisible(!filterVisible) + : undefined + return ( - - - + <> + + + + + + + + ) } return ( - {!action?.showInDrawer ? ( - - ) : ''} - + {!action?.showInDrawer ? : ''} + ) } diff --git a/src/frontend/components/routes/record-action.tsx b/src/frontend/components/routes/record-action.tsx index 0bb27599e..f0f4f1996 100644 --- a/src/frontend/components/routes/record-action.tsx +++ b/src/frontend/components/routes/record-action.tsx @@ -21,6 +21,8 @@ const api = new ApiClient() const RecordAction: React.FC = () => { const [record, setRecord] = useState() const [loading, setLoading] = useState(true) + const [tag, setTag] = useState('') + const [filterVisible, setFilterVisible] = useState(false) const params = useParams() const addNotice = useNotice() @@ -30,6 +32,9 @@ const RecordAction: React.FC = () => { const action = record && record.recordActions.find((r) => r.name === actionName) const actionFromResource = resource?.actions.find((a) => a.name === actionName) + const listActionName = 'list' + const listAction = resource?.resourceActions.find((r) => r.name === listActionName) + const fetchRecord = (): void => { // Do not call API on route enter if the action doesn't have a component if (actionFromResource && actionHasDisabledComponent(actionFromResource)) { @@ -38,46 +43,51 @@ const RecordAction: React.FC = () => { } setLoading(true) - api.recordAction(params as RecordActionParams).then((response) => { - if (response.data.notice && response.data.notice.type === 'error') { - addNotice(response.data.notice) - } - if ( - !response.data.record?.baseError?.type - || ![ - ErrorTypeEnum.App, - ErrorTypeEnum.NotFound, - ErrorTypeEnum.Forbidden, - ].includes(response.data.record?.baseError?.type as ErrorTypeEnum) - ) { - setRecord(response.data.record) - } - }).catch((error) => { - addNotice({ - message: 'errorFetchingRecord', - type: 'error', - resourceId, + api + .recordAction(params as RecordActionParams) + .then((response) => { + if (response.data.notice && response.data.notice.type === 'error') { + addNotice(response.data.notice) + } + if ( + !response.data.record?.baseError?.type + || ![ErrorTypeEnum.App, ErrorTypeEnum.NotFound, ErrorTypeEnum.Forbidden].includes( + response.data.record?.baseError?.type as ErrorTypeEnum, + ) + ) { + setRecord(response.data.record) + } + }) + .catch((error) => { + addNotice({ + message: 'errorFetchingRecord', + type: 'error', + resourceId, + }) + throw error + }) + .finally(() => { + setLoading(false) }) - throw error - }).finally(() => { - setLoading(false) - }) } useEffect(() => { fetchRecord() }, [actionName, recordId, resourceId]) - const handleActionPerformed = useCallback((oldRecord: RecordJSON, response: ActionResponse) => { - if (response.record) { - setRecord(mergeRecordResponse(oldRecord, response as RecordActionResponse)) - } else { - fetchRecord() - } - }, [fetchRecord]) + const handleActionPerformed = useCallback( + (oldRecord: RecordJSON, response: ActionResponse) => { + if (response.record) { + setRecord(mergeRecordResponse(oldRecord, response as RecordActionResponse)) + } else { + fetchRecord() + } + }, + [fetchRecord], + ) if (!resource) { - return () + return } // When the user visits this route (record action) from a different, than the current one, record. @@ -89,26 +99,51 @@ const RecordAction: React.FC = () => { const hasDifferentRecord = record && record.id && record.id.toString() !== recordId if (loading || hasDifferentRecord) { - return actionFromResource?.showInDrawer ? () : + return actionFromResource?.showInDrawer ? ( + + + + ) : ( + + ) } if (!action || (actionFromResource && actionHasDisabledComponent(actionFromResource))) { - return () + return } if (!record) { - return () + return } if (action.showInDrawer) { + if (!listAction) { + return ( + + + + ) + } + + const toggleFilter = listAction.showFilter + ? (): void => setFilterVisible(!filterVisible) + : undefined + return ( - - - + <> + + + + + + + + ) } @@ -118,15 +153,9 @@ const RecordAction: React.FC = () => { resource={resource} action={action} record={record} - actionPerformed={(response: ActionResponse): void => ( - handleActionPerformed(record, response) - )} - /> - handleActionPerformed(record, response)} /> + ) } diff --git a/src/frontend/components/routes/resource-action.tsx b/src/frontend/components/routes/resource-action.tsx index 94d43cbc8..074168599 100644 --- a/src/frontend/components/routes/resource-action.tsx +++ b/src/frontend/components/routes/resource-action.tsx @@ -30,6 +30,7 @@ const ResourceAction: React.FC = (props) => { if (!resource) { return () } + const action = resource.resourceActions.find((r) => r.name === actionName) if (!action || actionHasDisabledComponent(action)) { return () diff --git a/src/frontend/hooks/use-action/use-action.ts b/src/frontend/hooks/use-action/use-action.ts index 48f45c5b2..0ab42a612 100644 --- a/src/frontend/hooks/use-action/use-action.ts +++ b/src/frontend/hooks/use-action/use-action.ts @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router' +import { useNavigate, useLocation } from 'react-router' import { ActionResponse } from '../../../backend/actions/action.interface.js' import { ActionJSON, buildActionCallApiTrigger, buildActionClickHandler } from '../../interfaces/index.js' @@ -25,6 +25,7 @@ export function useAction( onActionCall?: ActionCallCallback, ): UseActionResult { const navigate = useNavigate() + const location = useLocation() const translateFunctions = useTranslation() const modalFunctions = useModal() const actionResponseHandler = useActionResponseHandler(onActionCall) @@ -44,6 +45,7 @@ export function useAction( navigate, translateFunctions, modalFunctions, + location, }) return { diff --git a/src/frontend/interfaces/action/build-action-click-handler.ts b/src/frontend/interfaces/action/build-action-click-handler.ts index 4f5556122..52975578b 100644 --- a/src/frontend/interfaces/action/build-action-click-handler.ts +++ b/src/frontend/interfaces/action/build-action-click-handler.ts @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-globals */ /* eslint-disable no-undef */ /* eslint-disable no-alert */ -import { NavigateFunction } from 'react-router' +import { NavigateFunction, Location } from 'react-router' import { DifferentActionParams, useActionResponseHandler } from '../../hooks/index.js' import { actionHasDisabledComponent } from './action-has-component.js' @@ -12,12 +12,13 @@ import { TranslateFunctions } from '../../../utils/index.js' import { ModalData, ModalFunctions } from '../modal.interface.js' export type BuildActionClickOptions = { - action: ActionJSON; - params: DifferentActionParams; - actionResponseHandler: ReturnType; - navigate: NavigateFunction; - translateFunctions: TranslateFunctions; + action: ActionJSON + params: DifferentActionParams + actionResponseHandler: ReturnType + navigate: NavigateFunction + translateFunctions: TranslateFunctions modalFunctions: ModalFunctions + location?: Location } export type BuildActionClickReturn = (event: any) => any | Promise @@ -25,8 +26,7 @@ export type BuildActionClickReturn = (event: any) => any | Promise export const buildActionClickHandler = ( options: BuildActionClickOptions, ): BuildActionClickReturn => { - const { action, params, actionResponseHandler, navigate, - modalFunctions } = options + const { action, params, actionResponseHandler, navigate, modalFunctions, location } = options const { openModal } = modalFunctions const handleActionClick = (event: React.MouseEvent): Promise | any => { @@ -36,7 +36,9 @@ export const buildActionClickHandler = ( const href = actionHref(action, params) const callApi = buildActionCallApiTrigger({ - params, action, actionResponseHandler, + params, + action, + actionResponseHandler, }) // Action has "component" option set to "false" explicitly in it's configuration @@ -65,7 +67,17 @@ export const buildActionClickHandler = ( // Default behaviour - you're navigated to action URL and logic is performed on it's route if (href) { - navigate(href) + const url = new URL(`relative:${href}`) + const hrefParams = new URLSearchParams(url.search) + const currentParams = new URLSearchParams(action.showInDrawer ? location?.search ?? '' : '') + Object.entries(Object.fromEntries(currentParams.entries())).forEach(([key, value]) => { + hrefParams.append(key, value) + }) + + navigate({ + pathname: url.pathname, + search: hrefParams.toString(), + }) } }