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

NEW Link ownership (alternative to #101) #102

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ on:

jobs:
ci:
name: CI
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1
7 changes: 7 additions & 0 deletions _config/config.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
---
Name: linkfield
---
# Adding this here for simplicity for testing the PR, but
# we'd have to make a determination as to whether this is
# automatically applied, or is documented as necessary
# for has_many links only.
SilverStripe\ORM\DataObject:
extensions:
- SilverStripe\LinkField\Extensions\DataObjectWithLinksExtension

SilverStripe\Admin\LeftAndMain:
extensions:
Expand Down
2 changes: 1 addition & 1 deletion _graphql/queries.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'readLinkDescription(dataStr: String!)':
type: LinkDescription
type: '[LinkDescription]'
resolver: ['SilverStripe\LinkField\GraphQL\LinkDescriptionResolver', 'resolve']
'readLinkTypes(keys: [ID])':
type: '[LinkType]'
Expand Down
3 changes: 3 additions & 0 deletions _graphql/types.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
LinkDescription:
description: Given some Link data, computes the matching description
fields:
id: ID
title: String
description: String

LinkType:
Expand All @@ -9,3 +11,4 @@ LinkType:
key: ID
handlerName: String!
title: String!
icon: String!
36 changes: 36 additions & 0 deletions behat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Run linkfield behat tests with this command (installed with silverstripe/installer)
# Note that linkfield behat tests require CMS module
# ========================================================================= #
# vendor/bin/selenium-server-standalone -Dwebdriver.firefox.bin="/Applications/Firefox31.app/Contents/MacOS/firefox-bin"
# vendor/bin/serve --bootstrap-file vendor/silverstripe/cms/tests/behat/serve-bootstrap.php
# vendor/bin/behat @linkfield
# ========================================================================= #
default:
suites:
linkfield:
paths:
- '%paths.modules.linkfield%/tests/behat/features'
contexts:
- SilverStripe\LinkField\Tests\Behat\Context\FeatureContext
- SilverStripe\Framework\Tests\Behaviour\CmsFormsContext
- SilverStripe\Framework\Tests\Behaviour\CmsUiContext
- SilverStripe\BehatExtension\Context\BasicContext
- SilverStripe\BehatExtension\Context\LoginContext
-
SilverStripe\LinkField\Tests\Behat\Context\FixtureContext:
- '%paths.modules.linkfield%/tests/behat/files/'
-
SilverStripe\Framework\Tests\Behaviour\ConfigContext:
- '%paths.modules.linkfield%/tests/behat/files/'
extensions:
SilverStripe\BehatExtension\Extension:
bootstrap_file: vendor/silverstripe/cms/tests/behat/serve-bootstrap.php
screenshot_path: '%paths.base%/artifacts/screenshots'
retry_seconds: 4 # default is 2
SilverStripe\BehatExtension\MinkExtension:
default_session: facebook_web_driver
javascript_session: facebook_web_driver
facebook_web_driver:
browser: chrome
wd_host: "http://127.0.0.1:9515" #chromedriver port
browser_name: chrome
2 changes: 1 addition & 1 deletion client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions client/src/boot/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
/* global document */
/* eslint-disable */
import Config from 'lib/Config';
import registerReducers from './registerReducers';
import registerComponents from './registerComponents';
import registerQueries from './registerQueries';
Expand Down
4 changes: 4 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* eslint-disable */
import Injector from 'lib/Injector';
import LinkPicker from 'components/LinkPicker/LinkPicker';
import MultiLinkPicker from 'components/MultiLinkPicker/MultiLinkPicker';
import LinkField from 'components/LinkField/LinkField';
import MultiLinkField from 'components/MultiLinkField/MultiLinkField';
import LinkModal from 'components/LinkModal/LinkModal';
import FileLinkModal from 'components/LinkModal/FileLinkModal';

Expand All @@ -10,6 +12,8 @@ const registerComponents = () => {
Injector.component.registerMany({
LinkPicker,
LinkField,
MultiLinkPicker,
MultiLinkField,
'LinkModal.FormBuilderModal': LinkModal,
'LinkModal.InsertMediaModal': FileLinkModal
});
Expand Down
22 changes: 0 additions & 22 deletions client/src/boot/registerReducers.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,4 @@
/* eslint-disable */
import Injector from 'lib/Injector';
import { combineReducers } from 'redux';
// import gallery from 'state/gallery/GalleryReducer';
// import queuedFiles from 'state/queuedFiles/QueuedFilesReducer';
// import uploadField from 'state/uploadField/UploadFieldReducer';
// import previewField from 'state/previewField/PreviewFieldReducer';
// import imageLoad from 'state/imageLoad/ImageLoadReducer';
// import displaySearch from 'state/displaySearch/DisplaySearchReducer';
// import confirmDeletion from 'state/confirmDeletion/ConfirmDeletionReducer';
// import modal from 'state/modal/ModalReducer';

const registerReducers = () => {
// Injector.reducer.register('assetAdmin', combineReducers({
// gallery,
// queuedFiles,
// uploadField,
// previewField,
// imageLoad,
// displaySearch,
// confirmDeletion,
// modal
// }));
};

export default registerReducers;
107 changes: 107 additions & 0 deletions client/src/components/AbstractLinkField/AbstractLinkField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React, { Fragment, useState } from 'react';
import { loadComponent } from 'lib/Injector';
import PropTypes from 'prop-types';
import LinkType from '../../types/LinkType';
import LinkSummary from '../../types/LinkSummary';

/**
* Underlying implementation of the LinkField. This is used for both the Single LinkField
* and MultiLinkField. It should not be used directly.
*/
const AbstractLinkField = ({
id, loading, Loading, Picker, onChange, types,
clearLinkData, buildLinkProps, updateLinkData, selectLinkData
}) => {
// Render a loading indicator if we're still fetching some data from the server
if (loading) {
return <Loading />;
}

// When editing is true, we display a modal to let the user edit the link data
const [editingId, setEditingId] = useState(false);
// newTypeKey define what link type we are using for brand new links
const [newTypeKey, setNewTypeKey] = useState('');

const selectedLinkData = selectLinkData(editingId);
const modalType = types[(selectedLinkData && selectedLinkData.typeKey) || newTypeKey];

// When the use clears the link data, we call onchange with an empty object
const onClear = (event, linkId) => {
if (typeof onChange === 'function') {
onChange(event, { id, value: clearLinkData(linkId) });
}
};

const linkProps = {
...buildLinkProps(),
id,
onEdit: (linkId) => { setEditingId(linkId); },
onClear,
onSelect: (key) => {
setNewTypeKey(key);
setEditingId(true);
},
types: Object.values(types)
};

const onModalSubmit = (submittedData) => {
// Remove unneeded keys from submitted data
// eslint-disable-next-line camelcase
const { SecurityID, action_insert, ...newLinkData } = submittedData;
if (typeof onChange === 'function') {
// onChange expect an event object which we don't have
onChange(undefined, { id, value: updateLinkData(newLinkData) });
}
// Close the modal
setEditingId(false);
setNewTypeKey('');
return Promise.resolve();
};

const modalProps = {
type: modalType,
editing: editingId !== false,
onSubmit: onModalSubmit,
onClosed: () => {
setEditingId(false);
return Promise.resolve();
},
data: selectedLinkData
};

// Different link types might have different Link modal
const handlerName = modalType ? modalType.handlerName : 'FormBuilderModal';
const LinkModal = loadComponent(`LinkModal.${handlerName}`);

return (
<Fragment>
<Picker {...linkProps} />
<LinkModal {...modalProps} />
</Fragment>
);
};

/**
* These props are expected to be passthrough from tho parent component.
*/
export const linkFieldPropTypes = {
id: PropTypes.string.isRequired,
loading: PropTypes.bool,
Loading: PropTypes.elementType,
data: PropTypes.any,
Picker: PropTypes.elementType,
onChange: PropTypes.func,
types: PropTypes.objectOf(LinkType),
linkDescriptions: PropTypes.arrayOf(LinkSummary),
};

AbstractLinkField.propTypes = {
...linkFieldPropTypes,
// These props need to be provided by the specific implementation
clearLinkData: PropTypes.func.isRequired,
buildLinkProps: PropTypes.func.isRequired,
updateLinkData: PropTypes.func.isRequired,
selectLinkData: PropTypes.func.isRequired,
};

export default AbstractLinkField;
31 changes: 31 additions & 0 deletions client/src/components/AbstractLinkField/linkFieldHOC.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { compose } from 'redux';
import { withApollo } from '@apollo/client/react/hoc';
import { injectGraphql } from 'lib/Injector';
import fieldHolder from 'components/FieldHolder/FieldHolder';

/**
* When getting data from entwine, we might get it in a plain JSON string.
* This method rewrites the data to a normalise format.
*/
export const stringifyData = (Component) => (({ data, value, ...props }) => {
let dataValue = value || data;
if (typeof dataValue === 'string') {
dataValue = JSON.parse(dataValue);
}
return <Component dataStr={JSON.stringify(dataValue)} {...props} data={dataValue} />;
});


/**
* Wires a Link field into GraphQL normalise the initial data to a proper objects
*/
const linkFieldHOC = compose(
stringifyData,
injectGraphql('readLinkTypes'),
injectGraphql('readLinkDescription'),
withApollo,
fieldHolder
);

export default linkFieldHOC;
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* global jest, test, expect */
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LinkField from '../AbstractLinkField';
import { loadComponent } from 'lib/Injector';

const props = {
id: 'my-link-field',
loading: false,
Loading: () => <div>Loading...</div>,
data: {
Root: null,
Main: null,
Title: '',
OpenInNew: 0,
ExternalUrl: 'http://google.com',
ID: null,
typeKey: 'external'
},
Picker: ({ id, onEdit, types, onClear, onSelect }) => <div>
<span>Picker</span>
<span>fieldid:{id}</span>
<span>types:{types[0].key}-{types[0].icon}-{types[0].title}</span>
<button onClick={() => onEdit(123)}>onEdit</button>
<button onClick={(event) => onClear(event, 123)}>onClear</button>
<button onClick={(event) => onSelect('sitetree')}>onSelect</button>
</div>,
onChange: jest.fn(),
types: {
sitetree: {
key: 'sitetree',
icon: 'page',
title: 'Site tree'
}
},
linkDescriptions: [
{ title: 'link title', description: 'link description' }
],
clearLinkData: jest.fn(),
buildLinkProps: jest.fn(),
updateLinkData: jest.fn(),
selectLinkData: jest.fn(),
};


const LinkModal = () => <div>LinkModal</div>;
jest.mock('lib/Injector', () => ({
loadComponent: () => LinkModal
})
);


describe('AbstractLinkField', () => {
test('Loading component', () => {
render(<LinkField {...props} loading />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});

test('Empty field', () => {
render(<LinkField {...props} data={{ }} />);
expect(screen.getByText('Picker')).toBeInTheDocument();
expect(screen.getByText('fieldid:my-link-field')).toBeInTheDocument();
expect(screen.getByText('types:sitetree-page-Site tree')).toBeInTheDocument();
});
});


49 changes: 49 additions & 0 deletions client/src/components/AbstractLinkField/tests/linkFieldHOC-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/* global jest, test, expect */
import React from 'react';
import { render } from '@testing-library/react';
import { stringifyData } from '../linkFieldHOC';

describe('stringifyData', () => {
test('Entwine form field bootstrap', () => {
const mock = jest.fn();
const FakeComponent = (props) => {
mock(props);
return <div />;
};
const FakeHOC = stringifyData(FakeComponent);
const props = {
value: { foo: 'bar' },
otherProp: 'baz'
};

render(<FakeHOC {...props} />);

expect(mock).toHaveBeenCalledWith({
dataStr: '{"foo":"bar"}',
data: props.value,
otherProp: props.otherProp
});
});

test('Redux form bootstrap', () => {
const mock = jest.fn();
const FakeComponent = (props) => {
mock(props);
return <div />;
};
const FakeHOC = stringifyData(FakeComponent);
const props = {
data: [],
value: JSON.stringify({ foo: 'bar' }),
otherProp: 'baz'
};

render(<FakeHOC {...props} />);

expect(mock).toHaveBeenCalledWith({
dataStr: '{"foo":"bar"}',
data: { foo: 'bar' },
otherProp: props.otherProp
});
});
});
Loading
Loading