Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Slots implementation #1563

Closed
wants to merge 10 commits into from
2 changes: 2 additions & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ nav:
- Anatomy: 'blocks/anatomy.md'
- Settings: 'blocks/settings.md'
- Edit components: 'blocks/editcomponent.md'
- Slots:
- Anatomy: 'slots/anatomy.md'
- Addons:
- Introduction: 'addons/index.md'
- Best practices: 'addons/best-practices.md'
Expand Down
58 changes: 58 additions & 0 deletions docs/source/slots/anatomy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Slots anatomy

The slots are insertion points in the Volto rendering tree structure. You can add a
component, along with its configuration, if any and it will be rendered in that
insertion point. You can control in which route do you want that element appear as well
as the order (position) of the items that you add in the in the slots. Slots are named,
so you can add in the configuration object:

```js
export const slots = {
aboveContentTitle: [
// List of components (might have config too, in `props` property)
{ path: '/', component: ExtraComponent, props: {}, exact: true },
],
};
```

Slots are inherited by default on all children routes, but you can block inheritance by
defining `exact` property to `true`.

## Slots

- aboveContentTitle
- belowContentTitle
- aboveContentBody
- belowContentBody
- footer

- asideLeftSlot
- asideRightSlot

- afterApp
- afterToolbar

- htmlHead
- htmlBeforeBody
- htmlAfterBody

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially identified these slots (and their insertion positions), did I miss something? We can add more later, for sure. @nzambello the ones referring to your "Macros" PR are there as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Sidebar is a good candidate, as well.

Though I can definitely see Martjin's point for the toolbar: if extra content is needed there, then maybe we need a system of definable actions. For the Toolbar, my wishlist include new buttons in the main toolbar, but also now components available for the "More" menu.

### Slots definition

You can define new slots anywhere in the tree, then define them in the configuraion
object. This is how you define them in JSX:

```jsx
import {SlotRenderer} from '@plone/volto/components';
...

<SlotRenderer name="aboveContentTitle" />

```

### Slots in addons

You can define slots also in addons:

```js
config.slots.aboveContentTitle.push({path:'/', component: ExtraComponent})
```
3 changes: 3 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export Icon from '@plone/volto/components/theme/Icon/Icon';
export ConditionalLink from '@plone/volto/components/manage/ConditionalLink/ConditionalLink';
export UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';

export SlotRenderer from '@plone/volto/components/theme/SlotRenderer/SlotRenderer';
export ContentContainer from '@plone/volto/components/theme/ContentContainer/ContentContainer';

// Lazy load them, since we want them and its deps to be in its own chunk
export ContactForm from '@plone/volto/components/theme/ContactForm/ContactForm';
export Login from '@plone/volto/components/theme/Login/Login';
Expand Down
56 changes: 56 additions & 0 deletions src/components/theme/ContentContainer/ContentContainer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { Container, Grid } from 'semantic-ui-react';
import { SlotRenderer } from '@plone/volto/components';
import { matchPath, useLocation } from 'react-router-dom';
import { isEmpty } from 'lodash';
import { slots } from '~/config';

const ContentContainer = ({ children, content }) => {
const pathname = useLocation().pathname;
const hasSlot = (name) => {
if (!slots[name]) {
return null;
}
return slots[name].filter((slot) =>
matchPath(pathname, { path: slot.path, exact: slot.exact }),
);
};
const hasLeftSlot = !isEmpty(hasSlot('asideLeftSlot'));
const hasRightSlot = !isEmpty(hasSlot('asideRightSlot'));

const contentWidth = () => {
if (hasLeftSlot && hasRightSlot) {
return 6;
} else if (hasLeftSlot || hasRightSlot) {
return 9;
} else {
return 12;
}
};

return (
<>
{hasLeftSlot || hasRightSlot ? (
<Grid stackable as={Container}>
{hasLeftSlot && (
<Grid.Column as="aside" className="aside-left-slot" width={3}>
<SlotRenderer name="asideLeftSlot" />
</Grid.Column>
)}
<Grid.Column className="content-body" width={contentWidth()}>
{children}
</Grid.Column>
{hasRightSlot && (
<Grid.Column as="aside" className="aside-right-slot" width={3}>
<SlotRenderer name="asideRightSlot" />
</Grid.Column>
)}
</Grid>
) : (
<>{children}</>
)}
</>
);
};

export default ContentContainer;
25 changes: 25 additions & 0 deletions src/components/theme/SlotRenderer/SlotRenderer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { v4 as uuid } from 'uuid';
import { slots } from '~/config';

const SlotRenderer = ({ name }) => {
const pathname = useLocation().pathname;

if (!slots[name]) {
return null;
}

const currentSlot = slots[name];
const active = currentSlot.filter((slot) =>
matchPath(pathname, { path: slot.path, exact: slot.exact }),
);

return active.map(({ component, props }) => {
const id = uuid();
const Slot = component;
return <Slot {...props} key={id} id={id} />;
});
};

export default SlotRenderer;
144 changes: 144 additions & 0 deletions src/components/theme/SlotRenderer/SlotRenderer.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import SlotRenderer from './SlotRenderer';
import { slots } from '~/config';

describe('SlotRenderer Component', () => {
test('renders a SlotRenderer component for the aboveContentTitle with two slots in the root', () => {
slots.aboveContentTitle = [
{
path: '/',
component: (props) => <div {...props} />,
props: { className: 'slot-component' },
},
{
path: '/',
component: (props) => <aside {...props} />,
props: { className: 'slot-component' },
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toHaveClass('slot-component');
const asideSlot = container.querySelector('aside');
expect(asideSlot).toHaveClass('slot-component');
});
test('renders a SlotRenderer component for the aboveContentTitle with one slots in the root and other in other place', () => {
slots.aboveContentTitle = [
{
path: '/',
component: (props) => <div {...props} />,
props: { className: 'slot-component' },
},
{
path: '/other-place',
component: (props) => <aside {...props} />,
props: { className: 'slot-component' },
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toHaveClass('slot-component');
const asideSlot = container.querySelector('aside');
expect(asideSlot).toBe(null);
});
test('renders a SlotRenderer component for the aboveContentTitle and belowContentTitle, only renders the appropiate one', () => {
slots.aboveContentTitle = [
{
path: '/',
component: (props) => <div {...props} />,
props: { className: 'slot-component-aboveContentTitle' },
},
];
slots.belowContentTitle = [
{
path: '/',
component: (props) => <aside {...props} />,
props: { className: 'slot-component-belowContentTitle' },
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toHaveClass('slot-component-aboveContentTitle');
const asideSlot = container.querySelector('aside');
expect(asideSlot).toBe(null);
});
test('renders a SlotRenderer component for the aboveContentTitle and belowContentTitle with different paths, only renders the appropiate one', () => {
slots.aboveContentTitle = [
{
path: '/other-place',
component: (props) => <div {...props} />,
props: { className: 'slot-component-aboveContentTitle' },
},
];
slots.belowContentTitle = [
{
path: '/',
component: (props) => <aside {...props} />,
props: { className: 'slot-component-belowContentTitle' },
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/other-place' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toHaveClass('slot-component-aboveContentTitle');
const asideSlot = container.querySelector('aside');
expect(asideSlot).toBe(null);
});
test('renders a SlotRenderer component for the aboveContentTitle with inheritance', () => {
slots.aboveContentTitle = [
{
path: '/other-place',
component: (props) => <div {...props} />,
props: { className: 'slot-component-aboveContentTitle' },
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/other-place/other-dir' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toHaveClass('slot-component-aboveContentTitle');
});
test('renders a SlotRenderer component for the aboveContentTitle disable inheritance', () => {
slots.aboveContentTitle = [
{
path: '/other-place',
component: (props) => <div {...props} />,
props: { className: 'slot-component-aboveContentTitle' },
exact: true,
},
];

const { container } = render(
<MemoryRouter initialEntries={[{ pathname: '/other-place/other-dir' }]}>
<SlotRenderer name="aboveContentTitle" />
</MemoryRouter>,
);
const divSlot = container.querySelector('div');
expect(divSlot).toBe(null);
});
});
51 changes: 29 additions & 22 deletions src/components/theme/View/View.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { Helmet } from '@plone/volto/helpers';
import qs from 'query-string';
import { views } from '~/config';

import { Comments, Tags, Toolbar } from '@plone/volto/components';
import {
ContentContainer,
Comments,
Tags,
Toolbar,
} from '@plone/volto/components';
import { listActions, getContent } from '@plone/volto/actions';
import {
BodyClass,
Expand Down Expand Up @@ -238,31 +243,33 @@ class View extends Component {
: null
}
/>
<RenderedView
content={this.props.content}
location={this.props.location}
token={this.props.token}
history={this.props.history}
/>
{this.props.content.subjects &&
this.props.content.subjects.length > 0 && (
<Tags tags={this.props.content.subjects} />
)}
{/* Add opt-in social sharing if required, disabled by default */}
{/* In the future this might be parameterized from the app config */}
{/* <SocialSharing
<ContentContainer content={this.props.content}>
<RenderedView
content={this.props.content}
location={this.props.location}
token={this.props.token}
history={this.props.history}
/>
{this.props.content.subjects &&
this.props.content.subjects.length > 0 && (
<Tags tags={this.props.content.subjects} />
)}
{/* Add opt-in social sharing if required, disabled by default */}
{/* In the future this might be parameterized from the app config */}
{/* <SocialSharing
url={typeof window === 'undefined' ? '' : window.location.href}
title={this.props.content.title}
description={this.props.content.description || ''}
/> */}
{this.props.content.allow_discussion && (
<Comments pathname={this.props.pathname} />
)}
{this.state.isClient && (
<Portal node={document.getElementById('toolbar')}>
<Toolbar pathname={this.props.pathname} inner={<span />} />
</Portal>
)}
{this.props.content.allow_discussion && (
<Comments pathname={this.props.pathname} />
)}
{this.state.isClient && (
<Portal node={document.getElementById('toolbar')}>
<Toolbar pathname={this.props.pathname} inner={<span />} />
</Portal>
)}
</ContentContainer>
</div>
);
}
Expand Down
Loading