Skip to content

Commit 5e8cddb

Browse files
committed
ENH Allow adding link(s) to unsaved DataObjects (closes #387)
1 parent a00396f commit 5e8cddb

10 files changed

Lines changed: 237 additions & 34 deletions

File tree

client/dist/js/bundle.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/components/LinkField/LinkField.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -545,14 +545,10 @@ const LinkField = ({
545545
});
546546
}
547547

548-
const saveRecordFirst = !loadingError && ownerID === 0;
549548
const renderLoadingError = loadingError;
550-
const renderPicker = !loadingError && !inHistoryViewer && !saveRecordFirst && (isMulti || linkIDs.length === 0);
551-
const renderModal = !loadingError && !saveRecordFirst && Boolean(editingID);
549+
const renderPicker = !loadingError && !inHistoryViewer && (isMulti || linkIDs.length === 0);
550+
const renderModal = !loadingError && Boolean(editingID);
552551
const loadingErrorText = i18n._t('LinkField.FAILED_TO_LOAD_LINKS', 'Failed to load link(s)');
553-
const saveRecordFirstText = isMulti
554-
? i18n._t('LinkField.SAVE_RECORD_FIRST', 'Cannot add links until the record has been saved')
555-
: i18n._t('LinkField.SAVE_RECORD_FIRST_SINGLE', 'Cannot add link until the record has been saved');
556552
const links = renderLinks();
557553

558554
return <LinkFieldContext.Provider value={{
@@ -566,8 +562,7 @@ const LinkField = ({
566562
}}>
567563
<div className="link-field__container">
568564
{ renderLoadingError && <div className="link-field__loading-error">{loadingErrorText}</div> }
569-
{ saveRecordFirst && <div className="link-field__save-record-first">{saveRecordFirstText}</div>}
570-
{ loading && !isSorting && !saveRecordFirst && <Loading containerClass="link-field__loading"/> }
565+
{ loading && !isSorting && <Loading containerClass="link-field__loading"/> }
571566
{ renderPicker && <LinkPicker
572567
onModalSuccess={handleModalSuccess}
573568
onModalClosed={handleModalClosed}

client/src/components/LinkField/tests/LinkField-test.js

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,12 @@ test('LinkField tab order', async () => {
219219
// e.g. el.getBoundingClientRect() will always return 0,0,0,0
220220
});
221221

222-
test('LinkField will render save-record-first div if ownerID is 0', async () => {
222+
test('LinkField will render loading indicator if ownerID is 0', async () => {
223223
const { container } = render(<LinkField {...makeProps({
224224
ownerID: 0
225225
})}
226226
/>);
227-
expect(container.querySelectorAll('.link-field__save-record-first')).toHaveLength(1);
228-
expect(container.querySelectorAll('.link-field__loading')).toHaveLength(0);
227+
expect(container.querySelectorAll('.link-field__loading')).toHaveLength(1);
229228
expect(container.querySelectorAll('.link-picker')).toHaveLength(0);
230229
});
231230

@@ -234,11 +233,28 @@ test('LinkField will render loading indicator if ownerID is not 0', async () =>
234233
ownerID: 1
235234
})}
236235
/>);
237-
expect(container.querySelectorAll('.link-field__save-record-first')).toHaveLength(0);
238236
expect(container.querySelectorAll('.link-field__loading')).toHaveLength(1);
239237
expect(container.querySelectorAll('.link-picker')).toHaveLength(0);
240238
});
241239

240+
test('LinkField will render link-picker if ownerID is 0 and isMulti and has finished loading', async () => {
241+
const { container } = render(<LinkField {...makeProps({
242+
ownerID: 0,
243+
isMulti: true,
244+
})}
245+
/>);
246+
await doResolve({ json: () => ({
247+
123: {
248+
title: 'First title',
249+
typeKey: 'mylink',
250+
},
251+
}) });
252+
await screen.findByText('First title');
253+
254+
expect(container.querySelectorAll('.link-field__loading')).toHaveLength(0);
255+
expect(container.querySelectorAll('.link-picker')).toHaveLength(1);
256+
});
257+
242258
test('LinkField will render link-picker if ownerID is not 0 and isMulti and has finished loading', async () => {
243259
const { container } = render(<LinkField {...makeProps({
244260
ownerID: 1,
@@ -253,7 +269,6 @@ test('LinkField will render link-picker if ownerID is not 0 and isMulti and has
253269
}) });
254270
await screen.findByText('First title');
255271

256-
expect(container.querySelectorAll('.link-field__save-record-first')).toHaveLength(0);
257272
expect(container.querySelectorAll('.link-field__loading')).toHaveLength(0);
258273
expect(container.querySelectorAll('.link-picker')).toHaveLength(1);
259274
});

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"autoload": {
3333
"psr-4": {
3434
"SilverStripe\\LinkField\\": "src/",
35-
"SilverStripe\\LinkField\\Tests\\": "tests/php/"
35+
"SilverStripe\\LinkField\\Tests\\": "tests/php/",
36+
"SilverStripe\\LinkField\\Tests\\Behat\\Context\\": "tests/behat/src/"
3637
}
3738
},
3839
"scripts": {

src/Controllers/LinkFieldController.php

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,17 @@ public function save(array $data, Form $form): HTTPResponse
227227
// Get owner using getOwnerFromRequest() rather than $link->Owner() so that validation is run
228228
// on the owner params before updating the database
229229
$owner = $this->getOwnerFromRequest();
230+
if (!$owner) {
231+
$className = $this->getOwnerClassFromRequest();
232+
$owner = $className ? Injector::inst()->get($className) : null;
233+
}
230234
$ownerRelation = $this->getOwnerRelationFromRequest();
235+
if (!$owner || !$ownerRelation) {
236+
$this->jsonError(404);
237+
}
238+
231239
$ownerRelationID = "{$ownerRelation}ID";
232-
$hasOne = Injector::inst()->get($owner->ClassName)->hasOne();
240+
$hasOne = $owner->hasOne();
233241
if ($operation === 'create'
234242
&& array_key_exists($ownerRelation, $hasOne)
235243
&& $owner->$ownerRelationID !== $link->ID
@@ -322,9 +330,8 @@ private function createLinkForm(Link $link, string $operation, bool $excludeLink
322330
$name = sprintf(LinkFieldController::FORM_NAME_TEMPLATE, $id);
323331
/** @var Form $form */
324332
$form = $formFactory->getForm($this, $name, ['Record' => $link]);
325-
$owner = $this->getOwnerFromRequest();
326-
$ownerID = $owner->ID;
327-
$ownerClassName = $owner->ClassName;
333+
$ownerID = $this->getOwnerIDFromRequest();
334+
$ownerClassName = $this->getOwnerClassFromRequest();
328335
$ownerRelation = $this->getOwnerRelationFromRequest();
329336

330337
// Remove LinkText if appropriate
@@ -387,6 +394,9 @@ private function getFieldIsReadonlyOrDisabled(): bool
387394
{
388395
$ownerClass = $this->getOwnerClassFromRequest();
389396
$ownerRelation = $this->getOwnerRelationFromRequest();
397+
if (!$ownerClass || !$ownerRelation) {
398+
return false;
399+
}
390400

391401
/** @var LinkField|MultiLinkField $field */
392402
$field = Injector::inst()->get($ownerClass)->getCMSFields()->dataFieldByName($ownerRelation);
@@ -480,12 +490,12 @@ private function typeKeyFromRequest(): string
480490
/**
481491
* Get the owner class based on the query string param OwnerClass
482492
*/
483-
private function getOwnerClassFromRequest(): string
493+
private function getOwnerClassFromRequest(): string|null
484494
{
485495
$request = $this->getRequest();
486496
$ownerClass = $request->getVar('ownerClass') ?: $request->postVar('OwnerClass');
487497
if (!is_a($ownerClass, DataObject::class, true)) {
488-
$this->jsonError(404);
498+
return null;
489499
}
490500

491501
return $ownerClass;
@@ -498,22 +508,22 @@ private function getOwnerIDFromRequest(): int
498508
{
499509
$request = $this->getRequest();
500510
$ownerID = (int) ($request->getVar('ownerID') ?: $request->postVar('OwnerID'));
501-
if ($ownerID === 0) {
502-
$this->jsonError(404);
503-
}
504-
505511
return $ownerID;
506512
}
507513

508514
/**
509515
* Get the owner based on the query string params ownerID, ownerClass, ownerRelation
510516
* OR the POST vars OwnerID, OwnerClass, OwnerRelation
511517
*/
512-
private function getOwnerFromRequest(): DataObject
518+
private function getOwnerFromRequest(): DataObject|null
513519
{
514520
$ownerID = $this->getOwnerIDFromRequest();
515521
$ownerClass = $this->getOwnerClassFromRequest();
516522
$ownerRelation = $this->getOwnerRelationFromRequest();
523+
if (!$ownerID || !$ownerClass || !$ownerRelation) {
524+
return null;
525+
}
526+
517527
/** @var DataObject $obj */
518528
$obj = Injector::inst()->get($ownerClass);
519529
$hasOne = $obj->hasOne();
@@ -536,21 +546,16 @@ private function getOwnerFromRequest(): DataObject
536546
return $owner;
537547
}
538548
}
539-
$this->jsonError(404);
549+
return null;
540550
}
541551

542552
/**
543553
* Get the owner relation based on the query string param ownerRelation
544554
* OR the POST var OwnerRelation
545555
*/
546-
private function getOwnerRelationFromRequest(): string
556+
private function getOwnerRelationFromRequest(): string|null
547557
{
548558
$request = $this->getRequest();
549-
$ownerRelation = $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation');
550-
if (!$ownerRelation) {
551-
$this->jsonError(404);
552-
}
553-
554-
return $ownerRelation;
559+
return $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation');
555560
}
556561
}

src/Form/AbstractLinkField.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use SilverStripe\LinkField\Models\Link;
1717
use SilverStripe\LinkField\Services\LinkTypeService;
1818
use SilverStripe\ORM\DataObject;
19+
use SilverStripe\ORM\DataObjectInterface;
1920
use SilverStripe\VersionedAdmin\Controllers\HistoryViewerController;
2021

2122
/**
@@ -244,4 +245,53 @@ private function validateTypes(array $types): void
244245
}
245246
}
246247
}
248+
249+
public function saveInto(DataObjectInterface $record)
250+
{
251+
parent::saveInto($record);
252+
253+
// We only need to run additional checks on records which are yet to be saved
254+
if ($record->isInDB()) {
255+
return;
256+
}
257+
258+
$linkIDs = $this->value;
259+
if (!is_array($linkIDs)) {
260+
$linkIDs = explode(',', (string)$this->value);
261+
}
262+
263+
foreach (array_filter($linkIDs) as $linkID) {
264+
// Search for a matching link, ensuring that it’s yet to have its owner assigned
265+
$link = Link::get()->filter([
266+
'OwnerID' => 0,
267+
'OwnerClass' => $record->ClassName,
268+
'ID' => $linkID,
269+
])->first();
270+
if (!$link) {
271+
continue;
272+
}
273+
274+
// Re-check canCreate() against a blank record as an extra check that the user can definitely create new
275+
// links of this type, and they're not trying to hijack an orphaned record created by another user
276+
$className = $link->ClassName;
277+
$singletonLink = $className::create();
278+
if (!$singletonLink->canCreate()) {
279+
continue;
280+
}
281+
282+
if (array_key_exists($this->name, $record->hasOne())) {
283+
// As $record isn't written yet, we can't immediately write the OwnerID to the link. We also can't use
284+
// setComponent() as that would immediately trigger a write on the owner object, when the form handler
285+
// may opt not to write the record. There's also no UnsavedRelationList for has_one, so we have to use
286+
// an extension hook instead to ensure that if the owner is written, the ownership relation is saved
287+
$record->beforeExtending('onAfterWrite', function () use ($link, $record) {
288+
$link->OwnerID = $record->ID;
289+
$link->write();
290+
});
291+
} elseif (array_key_exists($this->name, $record->hasMany())) {
292+
// Has_many is much cleaner as we can just utilise UnsavedRelationList
293+
$record->{$this->name}()->add($link);
294+
}
295+
}
296+
}
247297
}

src/Models/Link.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace SilverStripe\LinkField\Models;
44

55
use SilverStripe\Core\ClassInfo;
6+
use SilverStripe\Core\Injector\Injector;
67
use SilverStripe\Forms\FieldList;
78
use SilverStripe\Forms\FormField;
89
use SilverStripe\LinkField\Services\LinkTypeService;
@@ -355,6 +356,10 @@ private function canPerformAction(string $canMethod, $member, $context = [])
355356
$canMethod = 'canEdit';
356357
}
357358
return $owner->$canMethod($member, $context);
359+
} else if ($this->OwnerClass) {
360+
// If owner doesn't exist yet, check the user can create new owner records
361+
$owner = Injector::inst()->create($this->OwnerClass);
362+
return $owner->canCreate($member, $context);
358363
}
359364

360365
// Default to DataObject's permission checks

tests/behat/features/create-edit-linkfield.feature

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,61 @@ I want to add links to pages, files, external URLs, email addresses and phone nu
211211
And I should see "File1" in the "[data-field-id='Form_EditForm_HasManyLinks'] .link-picker__link--is-first" element
212212
And I should see "folder1/file1.jpg" in the "[data-field-id='Form_EditForm_HasManyLinks'] .link-picker__link--is-first" element
213213
And I should see "Draft" in the "[data-field-id='Form_EditForm_HasManyLinks'] .link-picker__link--is-first" element
214+
215+
Scenario: Create links on an unsaved record
216+
Given I add an extension "SilverStripe\LinkField\Tests\Behat\Context\Extension\LinkCompanyExtension" to the "SilverStripe\FrameworkTest\Model\Company" class
217+
And the "group" "EDITOR" has permissions "Access to 'Test ModelAdmin' section" and "TEST_DATAOBJECT_EDIT"
218+
When I go to "/admin/test"
219+
Then I press the "Add new Company" button
220+
And I wait for 2 seconds
221+
222+
# Test that the single + multiple link fields are visible on unsaved records
223+
Then I should see the "[data-field-id='Form_ItemEditForm_CompanyWebSiteLink'] button" element
224+
And I should see the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] button" element
225+
226+
# Test saving - first add a link to the single link field
227+
Then I click on the "[data-field-id='Form_ItemEditForm_CompanyWebSiteLink'] button" element
228+
And I click on the "[data-field-id='Form_ItemEditForm_CompanyWebSiteLink'] .dropdown-item:nth-of-type(1)" element
229+
Then I fill in "LinkText" with "External URL"
230+
And I fill in "ExternalUrl" with "https://www.silverstripe.org"
231+
And I press the "Create link" button
232+
And I wait for 2 seconds
233+
234+
# Then add the first link (email link) to the multi link field
235+
Then I click on the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] button" element
236+
And I click on the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .dropdown-item:nth-of-type(4)" element
237+
And I wait for 5 seconds
238+
Then I fill in "LinkText" with "Email link"
239+
And I fill in "Email" with "email@example.com"
240+
And I press the "Create link" button
241+
And I wait for 2 seconds
242+
243+
# Then add the second link (phone link) to the multi link field
244+
Then I click on the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] button" element
245+
And I click on the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .dropdown-item:nth-of-type(5)" element
246+
And I wait for 5 seconds
247+
Then I fill in "LinkText" with "Phone"
248+
And I fill in "Phone" with "12345678"
249+
And I press the "Create link" button
250+
And I wait for 2 seconds
251+
252+
# Fill out required field on Company records
253+
And I fill in "Name" with "A company with links"
254+
And I press the "Create" button
255+
And I wait for 2 seconds
256+
Then I should see a "Saved company" message
257+
258+
# Reload the page to ensure links have saved, and haven't just been re-populated from POST data
259+
Then I reload the page
260+
261+
# Check links are present - single link
262+
And I should see "External URL" in the "[data-field-id='Form_ItemEditForm_CompanyWebSiteLink']" element
263+
And I should see "https://www.silverstripe.org" in the "[data-field-id='Form_ItemEditForm_CompanyWebSiteLink'] .link-picker__link" element
264+
265+
# Check links are present - multi link 1
266+
And I should see "Email link" in the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .link-picker__link:nth-of-type(1)" element
267+
And I should see "email@example.com" in the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .link-picker__link:nth-of-type(1)" element
268+
269+
# Check links are present - multi link 2
270+
And I should see "Phone number" in the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .link-picker__link:nth-of-type(2)" element
271+
And I should see "12345678" in the "[data-field-id='Form_ItemEditForm_ManyCompanyWebSiteLink'] .link-picker__link:nth-of-type(2)" element
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace SilverStripe\LinkField\Tests\Behat\Context\Extension;
4+
5+
use SilverStripe\Core\Extension;
6+
use SilverStripe\Dev\TestOnly;
7+
use SilverStripe\Forms\FieldList;
8+
use SilverStripe\LinkField\Form\LinkField;
9+
use SilverStripe\LinkField\Form\MultiLinkField;
10+
11+
class LinkCompanyExtension extends Extension implements TestOnly
12+
{
13+
protected function updateCMSFields(FieldList $fields): void
14+
{
15+
// Re-create these fields so that has_many field is displayed on unsaved records (it would be skipped during
16+
// automatic scaffolding) and to ensure they're at the top of the form so are visible in any screenshots
17+
$fields->removeByName(['CompanyWebSiteLink', 'ManyCompanyWebSiteLink']);
18+
$fields->addFieldsToTab(
19+
'Root.Main',
20+
[
21+
LinkField::create('CompanyWebSiteLink', 'Company web site link'),
22+
MultiLinkField::create('ManyCompanyWebSiteLink', 'Many company web site links'),
23+
],
24+
'Category',
25+
);
26+
}
27+
}

0 commit comments

Comments
 (0)