Skip to content

Commit 6760b76

Browse files
Merge pull request #425 from bigfork/link-before-save
ENH: Allow adding link(s) to unsaved DataObjects (closes #387)
2 parents 3071a91 + 3acb1ae commit 6760b76

11 files changed

Lines changed: 247 additions & 36 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/dist/styles/bundle.css

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: 25 additions & 18 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
@@ -323,10 +331,14 @@ private function createLinkForm(Link $link, string $operation, bool $excludeLink
323331
/** @var Form $form */
324332
$form = $formFactory->getForm($this, $name, ['Record' => $link]);
325333
$owner = $this->getOwnerFromRequest();
326-
$ownerID = $owner->ID;
327-
$ownerClassName = $owner->ClassName;
334+
$ownerID = $this->getOwnerIDFromRequest();
335+
$ownerClassName = $this->getOwnerClassFromRequest();
328336
$ownerRelation = $this->getOwnerRelationFromRequest();
329337

338+
if (!$owner) {
339+
$owner = Injector::inst()->create($ownerClassName);
340+
}
341+
330342
// Remove LinkText if appropriate
331343
if ($excludeLinkTextField) {
332344
$form->Fields()->removeByName('LinkText');
@@ -477,12 +489,12 @@ private function typeKeyFromRequest(): string
477489
/**
478490
* Get the owner class based on the query string param OwnerClass
479491
*/
480-
private function getOwnerClassFromRequest(): string
492+
private function getOwnerClassFromRequest(): string|null
481493
{
482494
$request = $this->getRequest();
483495
$ownerClass = $request->getVar('ownerClass') ?: $request->postVar('OwnerClass');
484496
if (!is_a($ownerClass, DataObject::class, true)) {
485-
$this->jsonError(404);
497+
return null;
486498
}
487499

488500
return $ownerClass;
@@ -495,22 +507,22 @@ private function getOwnerIDFromRequest(): int
495507
{
496508
$request = $this->getRequest();
497509
$ownerID = (int) ($request->getVar('ownerID') ?: $request->postVar('OwnerID'));
498-
if ($ownerID === 0) {
499-
$this->jsonError(404);
500-
}
501-
502510
return $ownerID;
503511
}
504512

505513
/**
506514
* Get the owner based on the query string params ownerID, ownerClass, ownerRelation
507515
* OR the POST vars OwnerID, OwnerClass, OwnerRelation
508516
*/
509-
private function getOwnerFromRequest(): DataObject
517+
private function getOwnerFromRequest(): DataObject|null
510518
{
511519
$ownerID = $this->getOwnerIDFromRequest();
512520
$ownerClass = $this->getOwnerClassFromRequest();
513521
$ownerRelation = $this->getOwnerRelationFromRequest();
522+
if (!$ownerID || !$ownerClass || !$ownerRelation) {
523+
return null;
524+
}
525+
514526
/** @var DataObject $obj */
515527
$obj = Injector::inst()->get($ownerClass);
516528
$hasOne = $obj->hasOne();
@@ -533,21 +545,16 @@ private function getOwnerFromRequest(): DataObject
533545
return $owner;
534546
}
535547
}
536-
$this->jsonError(404);
548+
return null;
537549
}
538550

539551
/**
540552
* Get the owner relation based on the query string param ownerRelation
541553
* OR the POST var OwnerRelation
542554
*/
543-
private function getOwnerRelationFromRequest(): string
555+
private function getOwnerRelationFromRequest(): string|null
544556
{
545557
$request = $this->getRequest();
546-
$ownerRelation = $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation');
547-
if (!$ownerRelation) {
548-
$this->jsonError(404);
549-
}
550-
551-
return $ownerRelation;
558+
return $request->getVar('ownerRelation') ?: $request->postVar('OwnerRelation');
552559
}
553560
}

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+
} elseif ($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

0 commit comments

Comments
 (0)