diff --git a/.eslintrc.js b/.eslintrc.js index bf91b82ec..b51f24f24 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,7 @@ module.exports = { 'func-names': 'off', 'prefer-arrow-callback': 'off', 'import/no-extraneous-dependencies': 'off', + 'mocha/no-mocha-arrows': 'off', '@typescript-eslint/explicit-function-return-type': 'off', }, }, diff --git a/example-app/src/admin.options.js b/example-app/src/admin.options.js index 6c0260acd..d576e225f 100644 --- a/example-app/src/admin.options.js +++ b/example-app/src/admin.options.js @@ -14,8 +14,8 @@ const AdminTool = require('./tools/tool.admin') const AdminPage = require('./pages/page.admin') const AdminNested = require('./nested/nested.admin') -AdminBro.bundle('./components/sidebar-footer', 'SidebarFooter') -AdminBro.bundle('./components/no-records', 'NoRecords') +// AdminBro.bundle('./components/sidebar-footer', 'SidebarFooter') +// AdminBro.bundle('./components/no-records', 'NoRecords') /** @type {import('admin-bro').AdminBroOptions} */ const options = { @@ -34,8 +34,16 @@ const options = { app: process.env.npm_package_version, }, branding: currentUser => ({ + logo: false, companyName: currentUser ? currentUser.email : 'something', }), + pages: { + aboutUs: { + handler: async () => { console.log('clicked') }, + component: AdminBro.bundle('./components/no-records'), + icon: 'Add', + }, + }, locale: { language: 'en', translations: { diff --git a/src/admin-bro-options.interface.ts b/src/admin-bro-options.interface.ts index ae9d5ddad..9bb6d151f 100644 --- a/src/admin-bro-options.interface.ts +++ b/src/admin-bro-options.interface.ts @@ -362,6 +362,11 @@ export type AdminPage = { * Component defined by using {@link AdminBro.bundle} */ component: string; + + /** + * Page icon + */ + icon?: string; } /** diff --git a/src/backend/decorators/page-json.interface.ts b/src/backend/decorators/page-json.interface.ts index e9a2fb194..10ce31378 100644 --- a/src/backend/decorators/page-json.interface.ts +++ b/src/backend/decorators/page-json.interface.ts @@ -11,4 +11,9 @@ export default interface PageJSON { * Page component. Bundled with {@link AdminBro.bundle} */ component: string; + + /** + * Page icon + */ + icon?: string; } diff --git a/src/backend/utils/options-parser.ts b/src/backend/utils/options-parser.ts index 75eb49895..1d428c115 100644 --- a/src/backend/utils/options-parser.ts +++ b/src/backend/utils/options-parser.ts @@ -34,7 +34,7 @@ export const getBranding = async ( const { branding } = admin.options const defaultLogo = slash(path.join( admin.options.rootPath, - '/frontend/assets/logo-mini.svg', + '/frontend/assets/logo.svg', )) const computed = typeof branding === 'function' diff --git a/src/frontend/assets/images/logo.svg b/src/frontend/assets/images/logo.svg index 7a94ce318..5d4f77fba 100644 --- a/src/frontend/assets/images/logo.svg +++ b/src/frontend/assets/images/logo.svg @@ -1,13 +1,17 @@ - - - - - - - - - - - - - + + + + Atoms/Logotype/AdminBro + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/components/app/sidebar/index.ts b/src/frontend/components/app/sidebar/index.ts index 530d5dc60..1642cdb60 100644 --- a/src/frontend/components/app/sidebar/index.ts +++ b/src/frontend/components/app/sidebar/index.ts @@ -1,7 +1,5 @@ import Sidebar from './sidebar' -import SidebarParent from './sidebar-parent' import groupResources from './utils/group-resources' -import SidebarResource from './sidebar-resource' export * from './sidebar-resource-section' -export { SidebarParent, Sidebar, groupResources, SidebarResource } +export { Sidebar, groupResources } diff --git a/src/frontend/components/app/sidebar/sidebar-branding.tsx b/src/frontend/components/app/sidebar/sidebar-branding.tsx index 5334ae248..b09b0e4b5 100644 --- a/src/frontend/components/app/sidebar/sidebar-branding.tsx +++ b/src/frontend/components/app/sidebar/sidebar-branding.tsx @@ -1,44 +1,57 @@ import React from 'react' import { Link } from 'react-router-dom' import styled from 'styled-components' -import { H5 } from '@admin-bro/design-system' +import { cssClass, themeGet, Text } from '@admin-bro/design-system' import ViewHelpers from '../../../../backend/utils/view-helpers' import { BrandingOptions } from '../../../../admin-bro-options.interface' +import allowOverride from '../../../hoc/allow-override' -const LogoLink = styled(Link)` +type Props = { + branding: BrandingOptions; +} + +const StyledLogo = styled(Link)` + text-align: center; display: flex; - align-items: center; + align-content: center; + justify-content: center; + flex-shrink: 0; + padding: ${themeGet('space', 'lg')} ${themeGet('space', 'xxl')} ${themeGet('space', 'xxl')}; text-decoration: none; - color: ${({ theme }): string => theme.colors.grey80}; - & > img { - margin-right: 12px; + & > h1 { + text-decoration: none; + font-weight: ${themeGet('fontWeights', 'bolder')}; + font-size: ${themeGet('fontWeights', 'bolder')}; + color: ${themeGet('colors', 'grey80')}; + font-size: ${themeGet('fontSizes', 'xl')}; + line-height: ${themeGet('lineHeights', 'xl')}; + } + + &:hover h1 { + color: ${themeGet('colors', 'primary100')}; } ` -type Props = { - branding: BrandingOptions; -} +const h = new ViewHelpers() const SidebarBranding: React.FC = (props) => { const { branding } = props const { logo, companyName } = branding - const h = new ViewHelpers() return ( -
- - {logo && ( - {companyName} - )} - {companyName} - -
+ + {logo ? ( + {companyName} + ) :

companyName

} +
) } -export default SidebarBranding +export default allowOverride(SidebarBranding, 'SidebarBranding') diff --git a/src/frontend/components/app/sidebar/sidebar-footer.tsx b/src/frontend/components/app/sidebar/sidebar-footer.tsx index c17b20e9a..2029300bb 100644 --- a/src/frontend/components/app/sidebar/sidebar-footer.tsx +++ b/src/frontend/components/app/sidebar/sidebar-footer.tsx @@ -1,23 +1,11 @@ import React from 'react' -import { Box, Text, Icon, Link } from '@admin-bro/design-system' +import { Box, SoftwareBrothers } from '@admin-bro/design-system' import allowOverride from '../../../hoc/allow-override' const SidebarFooter: React.FC = () => ( - - With - - by - - SoftwareBrothers - - + ) diff --git a/src/frontend/components/app/sidebar/sidebar-pages.tsx b/src/frontend/components/app/sidebar/sidebar-pages.tsx index 04df4859f..a277328ed 100644 --- a/src/frontend/components/app/sidebar/sidebar-pages.tsx +++ b/src/frontend/components/app/sidebar/sidebar-pages.tsx @@ -1,44 +1,51 @@ import React from 'react' -import { Box, Label, Text } from '@admin-bro/design-system' +import { Navigation, NavigationElementProps } from '@admin-bro/design-system' -import { ReduxState } from '../../../store/store' -import SidebarLink from './styled/sidebar-link.styled' +import { useHistory, useLocation } from 'react-router' import ViewHelpers from '../../../../backend/utils/view-helpers' import { useTranslation } from '../../../hooks/use-translation' +import { ReduxState } from '../../../store/store' type Props = { pages?: ReduxState['pages']; } +const h = new ViewHelpers() + const SidebarPages: React.FC = (props) => { const { pages } = props const { translateLabel } = useTranslation() - - const h = new ViewHelpers() + const location = useLocation() + const history = useHistory() if (!pages || !pages.length) { return (<>) } - const isActive = (page, location): boolean => ( + const isActive = (page): boolean => ( !!location.pathname.match(`/pages/${page.name}`) ) + const elements: Array = pages.map(page => ({ + id: page.name, + label: page.name, + isSelected: isActive(page), + icon: page.icon, + href: h.pageUrl(page.name), + onClick: (event, element): void => { + event.preventDefault() + if (element.href) { + history.push(element.href) + } + }, + })) + return ( - - - {pages.map(page => ( - isActive(page, location)} - data-testid="sidebar-page-link" - > - {translateLabel(page.name)} - - ))} - + ) } diff --git a/src/frontend/components/app/sidebar/sidebar-parent.tsx b/src/frontend/components/app/sidebar/sidebar-parent.tsx deleted file mode 100644 index 3ce9cf06b..000000000 --- a/src/frontend/components/app/sidebar/sidebar-parent.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react' -import { NavGroup, Box, cssClass } from '@admin-bro/design-system' - -import SidebarResource from './sidebar-resource' -import ResourceJSON from '../../../../backend/decorators/resource-json.interface' -import { useTranslation } from '../../../hooks' - - -type Props = { - parent: { - icon: string; - name: string; - resources: Array; - }; -} - -const SidebarParent: React.FC = (props) => { - const { parent } = props - const { icon, name, resources } = parent - const { translateLabel } = useTranslation() - - if (!parent.name) { - return ( - - {resources.map(resource => ( - - ))} - - ) - } - - return ( - - {resources.map(resource => ( - - ))} - - ) -} -export default SidebarParent diff --git a/src/frontend/components/app/sidebar/sidebar-resource-section.tsx b/src/frontend/components/app/sidebar/sidebar-resource-section.tsx index 60742d07d..33749dcaa 100644 --- a/src/frontend/components/app/sidebar/sidebar-resource-section.tsx +++ b/src/frontend/components/app/sidebar/sidebar-resource-section.tsx @@ -1,8 +1,11 @@ import React, { FC } from 'react' +import { Box, cssClass, Navigation } from '@admin-bro/design-system' +import { useHistory, useLocation } from 'react-router' +import { useTranslation } from '../../../hooks/use-translation' import groupResources from './utils/group-resources' -import SidebarParent from './sidebar-parent' import ResourceJSON from '../../../../backend/decorators/resource-json.interface' import allowOverride from '../../../hoc/allow-override' +import { useLocalStorage } from '../../../hooks' /** * @alias SidebarResourceSectionProps @@ -13,6 +16,11 @@ export type SidebarResourceSectionProps = { resources: Array; } +const isSelected = (href, location): boolean => { + const regExp = new RegExp(`${href}($|/)`) + return !!location.pathname.match(regExp) +} + /** * Groups resources by sections and renders the list in {@link Sidebar} * @@ -27,17 +35,37 @@ export type SidebarResourceSectionProps = { * @name SidebarResourceSection */ const SidebarResourceSectionOriginal: FC = ({ resources }) => { - const groupedResources = groupResources(resources) + const [openElements, setOpenElements] = useLocalStorage>( + 'sidebarElements', {}, + ) + const history = useHistory() + const location = useLocation() + + const elements = groupResources(resources).map((element, index) => ({ + ...element, + onClick: (): void => setOpenElements({ + ...openElements, + [index]: !openElements[index], + }), + isOpen: !!openElements[index], + elements: element.elements?.map(subElement => ({ + ...subElement, + onClick: (event): void => { + if (subElement.href) { + event.preventDefault() + history.push(subElement.href) + } + }, + isSelected: isSelected(subElement.href, location), + })), + })) + const { translateLabel } = useTranslation() return ( - <> - { - groupedResources - .map(parent => ( - - )) - } - + ) } diff --git a/src/frontend/components/app/sidebar/sidebar-resource.tsx b/src/frontend/components/app/sidebar/sidebar-resource.tsx deleted file mode 100644 index 6e9fce27c..000000000 --- a/src/frontend/components/app/sidebar/sidebar-resource.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react' -import { withRouter } from 'react-router-dom' -import { RouteComponentProps } from 'react-router' -import { Text } from '@admin-bro/design-system' - -import ResourceJSON from '../../../../backend/decorators/resource-json.interface' -import SidebarLink from './styled/sidebar-link.styled' - - -type Props = { - resource: ResourceJSON; -} - -const SidebarResource: React.FC = (props) => { - const { resource } = props - const regExp = new RegExp(`/resources/${resource.id}($|/)`) - const isActive = (match, location): boolean => !!location.pathname.match(regExp) - if (!resource.href) { - return null - } - return ( - - {resource.name} - - ) -} - -export default withRouter(SidebarResource) diff --git a/src/frontend/components/app/sidebar/sidebar.tsx b/src/frontend/components/app/sidebar/sidebar.tsx index 740a60d6b..c14ce1b83 100644 --- a/src/frontend/components/app/sidebar/sidebar.tsx +++ b/src/frontend/components/app/sidebar/sidebar.tsx @@ -1,13 +1,14 @@ import React from 'react' import { useSelector } from 'react-redux' -import { Navigation, Box, Label, cssClass } from '@admin-bro/design-system' +import { Box, cssClass } from '@admin-bro/design-system' import { BrandingOptions } from 'src/admin-bro-options.interface' +import ResourceJSON from 'src/backend/decorators/resource-json.interface' +import PageJSON from 'src/backend/decorators/page-json.interface' import SidebarBranding from './sidebar-branding' import SidebarPages from './sidebar-pages' import { ReduxState } from '../../../store/store' import SidebarFooter from './sidebar-footer' -import { useTranslation } from '../../../hooks/use-translation' import SidebarResourceSection from './sidebar-resource-section' @@ -15,35 +16,29 @@ type Props = { isVisible: boolean; } -// - -// -// -// -// -// -// {branding.softwareBrothers && } -// - const Sidebar: React.FC = (props) => { const { isVisible } = props - const [branding, resources, pages] = useSelector((state: ReduxState) => [ - state.branding, state.resources, state.pages, - ]) - - const { translateLabel } = useTranslation() - - // console.log(resources, pages) + const [branding, resources, pages]: [BrandingOptions, ResourceJSON[], PageJSON[]] = useSelector( + (state: ReduxState) => [ + state.branding, state.resources, state.pages, + ], + ) return ( - - - + + + + - Hello + + {branding?.softwareBrothers && } ) } diff --git a/src/frontend/components/app/sidebar/utils/group-resources.spec.ts b/src/frontend/components/app/sidebar/utils/group-resources.spec.ts new file mode 100644 index 000000000..8c546cdec --- /dev/null +++ b/src/frontend/components/app/sidebar/utils/group-resources.spec.ts @@ -0,0 +1,38 @@ +import { NavigationProps } from '@admin-bro/design-system' +import { expect } from 'chai' +import ResourceJSON from '../../../../../backend/decorators/resource-json.interface' +import factory from '../../../spec/factory' + +import groupResources from './group-resources' + +describe.only('groupResources', () => { + let resources: Array + let grouped: NavigationProps['elements'] + let parent1: ResourceJSON['parent'] + let parent2: ResourceJSON['parent'] + + beforeEach(async () => { + parent1 = { name: 'Volvo', icon: 'volvo' } + parent2 = { name: 'Audi', icon: 'audi' } + + resources = [ + await factory.build('ResourceJSON', { parent: parent1 }), + await factory.build('ResourceJSON', { parent: parent1 }), + await factory.build('ResourceJSON', { parent: parent1 }), + await factory.build('ResourceJSON', { parent: parent2 }), + ] + + grouped = groupResources(resources) + }) + + it('groups nested resources into parents', async () => { + expect(grouped.length).to.equal(2) + expect(grouped[0].label).to.eq(parent1?.name) + expect(grouped[1].label).to.eq(parent2?.name) + }) + + it('moves all nested elements to given parent', () => { + expect(grouped[0].elements?.length).to.eq(3) + expect(grouped[0].elements?.[0].label).to.eq(resources[0].name) + }) +}) diff --git a/src/frontend/components/app/sidebar/utils/group-resources.ts b/src/frontend/components/app/sidebar/utils/group-resources.ts index bf20f76a3..bef622c01 100644 --- a/src/frontend/components/app/sidebar/utils/group-resources.ts +++ b/src/frontend/components/app/sidebar/utils/group-resources.ts @@ -1,11 +1,15 @@ +import { NavigationElementProps, NavigationProps } from '@admin-bro/design-system' import ResourceJSON from '../../../../../backend/decorators/resource-json.interface' +const resourceToNavigationElement = ( + resource: ResourceJSON, +): NavigationElementProps => ({ + href: resource.href || undefined, + label: resource.name, +}) + /* eslint-disable no-param-reassign */ -export default (resources: Array): Array<{ - name: string; - icon: string; - resources: Array; -}> => { +export default (resources: Array): NavigationProps['elements'] => { const visibleResources = resources.filter(res => res.href) const map = visibleResources.reduce((memo, resource) => { const key = resource.parent?.name || '' @@ -18,8 +22,8 @@ export default (resources: Array): Array<{ return memo }, {}) return Object.keys(map).map(parentName => ({ - name: parentName, + label: parentName, icon: map[parentName].icon, - resources: map[parentName], + elements: map[parentName].map(resourceToNavigationElement), })) } diff --git a/src/frontend/components/application.tsx b/src/frontend/components/application.tsx index 9da38ef59..0e7b7258f 100644 --- a/src/frontend/components/application.tsx +++ b/src/frontend/components/application.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { Switch, Route } from 'react-router-dom' import { createGlobalStyle } from 'styled-components' -import { Box, Overlay } from '@admin-bro/design-system' +import { Box, Overlay, Reset } from '@admin-bro/design-system' import ViewHelpers from '../../backend/utils/view-helpers' import Sidebar from './app/sidebar/sidebar' @@ -40,6 +40,7 @@ const App: React.FC = () => { return ( + {sidebarVisible ? ( diff --git a/src/frontend/components/property-type/reference/reference-value.tsx b/src/frontend/components/property-type/reference/reference-value.tsx index 1303c0898..62c2b9a55 100644 --- a/src/frontend/components/property-type/reference/reference-value.tsx +++ b/src/frontend/components/property-type/reference/reference-value.tsx @@ -1,5 +1,5 @@ import React from 'react' -import styled, { DefaultTheme, ThemedStyledProps } from 'styled-components' +import styled from 'styled-components' import { Link } from 'react-router-dom' import { ButtonCSS, ButtonProps } from '@admin-bro/design-system' @@ -12,7 +12,7 @@ interface Props { record: RecordJSON; } -const StyledLink = styled(Link)` +const StyledLink = styled(Link)` ${ButtonCSS}; padding-left: ${({ theme }): string => theme.space.xs}; padding-right: ${({ theme }): string => theme.space.xs}; diff --git a/src/frontend/hooks/index.ts b/src/frontend/hooks/index.ts index b7581c51b..00217ddab 100644 --- a/src/frontend/hooks/index.ts +++ b/src/frontend/hooks/index.ts @@ -4,5 +4,6 @@ export * from './use-translation' export * from './use-record/use-record' export * from './use-records' export * from './use-current-admin' +export * from './use-local-storage' export { default as updateRecord } from './use-record/update-record' diff --git a/src/frontend/hooks/use-local-storage.ts b/src/frontend/hooks/use-local-storage.ts new file mode 100644 index 000000000..545911f38 --- /dev/null +++ b/src/frontend/hooks/use-local-storage.ts @@ -0,0 +1,44 @@ +/* eslint-disable no-console */ +import React, { useState } from 'react' + +export type UseLocalStorageResult = [ + T, + React.Dispatch> +] + +// Hook +// https://usehooks.com/useLocalStorage/ +export function useLocalStorage(key: string, initialValue: T): UseLocalStorageResult { + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue] = useState(() => { + try { + // Get from local storage by key + const item = window.localStorage.getItem(key) + // Parse stored json or if none return initialValue + return item ? JSON.parse(item) : initialValue + } catch (error) { + // If error also return initialValue + console.log(error) + return initialValue + } + }) + + // Return a wrapped version of useState's setter function that ... + // ... persists the new value to localStorage. + const setValue: React.Dispatch> = (value) => { + try { + // Allow value to be a function so we have same API as useState + const valueToStore = value instanceof Function ? value(storedValue) : value + // Save state + setStoredValue(valueToStore as any) + // Save to local storage + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } catch (error) { + // A more advanced implementation would handle the error case + console.log(error) + } + } + + return [storedValue, setValue] +} diff --git a/src/frontend/store/pages-to-store.ts b/src/frontend/store/pages-to-store.ts index ce8c10df0..f39866890 100644 --- a/src/frontend/store/pages-to-store.ts +++ b/src/frontend/store/pages-to-store.ts @@ -5,6 +5,7 @@ const pagesToStore = (pages: Record): Array => { const pagesArray = Object.entries(pages).map(([key, adminPage]) => ({ name: key, component: adminPage.component, + icon: adminPage.icon, })) return pagesArray } diff --git a/src/frontend/utils/overridable-component.ts b/src/frontend/utils/overridable-component.ts index 6674e2d8a..075d060bb 100644 --- a/src/frontend/utils/overridable-component.ts +++ b/src/frontend/utils/overridable-component.ts @@ -1,4 +1,8 @@ /** * Name of the components which can be overridden by AdminBro.bundle */ -export type OverridableComponent = 'LoggedIn' | 'NoRecords' | 'SidebarResourceSection' | 'SidebarFooter' +export type OverridableComponent = 'LoggedIn' + | 'NoRecords' + | 'SidebarResourceSection' + | 'SidebarFooter' + | 'SidebarBranding'