diff --git a/_config/uploadfield.yml b/_config/uploadfield.yml index eab120d40..ce98a07a4 100644 --- a/_config/uploadfield.yml +++ b/_config/uploadfield.yml @@ -6,12 +6,9 @@ UploadField: allowedMaxFileNumber: canUpload: true canAttachExisting: 'CMS_ACCESS_AssetAdmin' - replaceExistingFile: false + canPreviewFolder: true previewMaxWidth: 80 previewMaxHeight: 60 uploadTemplateName: 'ss-uploadfield-uploadtemplate' downloadTemplateName: 'ss-uploadfield-downloadtemplate' - fileEditFields: - fileEditActions: - fileEditValidator: - overwriteWarning: true # Warning before overwriting existing file (only relevant when Upload: replaceFile is true) \ No newline at end of file + overwriteWarning: true # Warning before overwriting existing file (only relevant when Upload: replaceFile is true) diff --git a/css/GridField.css b/css/GridField.css index d25eb0b03..4e7d2b35c 100644 --- a/css/GridField.css +++ b/css/GridField.css @@ -44,11 +44,11 @@ Used in side panels and action tabs .cms table.ss-gridfield-table tbody td.col-listChildrenLink .list-children-link { background: transparent url(../images/sitetree_ss_default_icons.png) no-repeat 3px -4px; display: block; } .cms table.ss-gridfield-table tbody td.col-getTreeTitle span.item { color: #0073c1; } .cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge { clear: both; text-transform: uppercase; display: inline-block; padding: 0px 3px; font-size: 0.75em; line-height: 1em; margin-left: 10px; margin-right: 6px; margin-top: -1px; -webkit-border-radius: 2px 2px; -moz-border-radius: 2px / 2px; border-radius: 2px / 2px; } -.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.modified { color: #7E7470; border: 1px solid #C9B800; background-color: #FFF0BC; } -.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.addedtodraft { color: #7E7470; border: 1px solid #C9B800; background-color: #FFF0BC; } -.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.deletedonlive { color: #636363; border: 1px solid #E49393; background-color: #F2DADB; } -.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.removedfromdraft { color: #636363; border: 1px solid #E49393; background-color: #F2DADB; } -.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.workflow-approval { color: #56660C; border: 1px solid #7C8816; background-color: #DAE79A; } +.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.status-modified { color: #7E7470; border: 1px solid #C9B800; background-color: #FFF0BC; } +.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.status-addedtodraft { color: #7E7470; border: 1px solid #C9B800; background-color: #FFF0BC; } +.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.status-deletedonlive { color: #636363; border: 1px solid #E49393; background-color: #F2DADB; } +.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.status-removedfromdraft { color: #636363; border: 1px solid #E49393; background-color: #F2DADB; } +.cms table.ss-gridfield-table tbody td.col-getTreeTitle span.badge.status-workflow-approval { color: #56660C; border: 1px solid #7C8816; background-color: #DAE79A; } .cms table.ss-gridfield-table tbody td button { border: none; background: none; margin: 0 0 0 2px; padding: 1px 0; width: auto; text-shadow: none; } .cms table.ss-gridfield-table tbody td button.ui-state-hover { background: none; -moz-box-shadow: none; -webkit-box-shadow: none; box-shadow: none; } .cms table.ss-gridfield-table tbody td button.ui-state-active { border: none; -moz-box-shadow: none; -webkit-box-shadow: none; box-shadow: none; } diff --git a/css/UploadField.css b/css/UploadField.css index d083f0c6c..d0f2a2b40 100644 --- a/css/UploadField.css +++ b/css/UploadField.css @@ -18,26 +18,26 @@ Used in side panels and action tabs .ss-uploadfield .ss-uploadfield-item { margin: 0; padding: 15px; overflow: auto; } .ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-preview { height: 60px; line-height: 60px; width: 80px; text-align: center; font-weight: bold; float: left; overflow: hidden; } .ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-preview.ss-uploadfield-dropzone { -webkit-box-shadow: gray 0 0 4px 0 inset; -moz-box-shadow: gray 0 0 4px 0 inset; box-shadow: gray 0 0 4px 0 inset; border: 2px dashed gray; background: #d0d3d5; display: none; margin-right: 15px; } -.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info { float: left; margin-left: 15px; } +.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info { margin-left: 95px; } .ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name { display: block; line-height: 13px; height: 26px; margin: 0; text-align: left; } -.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name b { font-weight: bold; padding: 0 5px 0 0; } -.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .name { font-size: 11px; color: #848484; width: 290px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; -o-text-overflow: ellipsis; display: inline; float: left; } -.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status { float: right; padding: 0 0 0 5px; width: 100px; text-align: right; } -.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status.ui-state-error-text { color: red; font-weight: bold; } +.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .name { max-width: 240px; font-weight: bold; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; -o-text-overflow: ellipsis; display: inline; float: left; } +.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .size { color: #848484; padding: 0 0 0 5px; display: inline; float: left; } +.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status { float: right; padding: 0 0 0 5px; text-align: right; max-width: 75%; } +.ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status.ui-state-error-text { color: red; font-weight: bold; width: 150px; } .ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status.ui-state-warning-text { color: #b7a403; } .ss-uploadfield .ss-uploadfield-item .ss-uploadfield-item-info .ss-uploadfield-item-name .ss-uploadfield-item-status.ui-state-success-text { color: #1f9433; } -.ss-uploadfield .ss-ui-button { display: block; float: left; margin: 0 10px 0 0; } +.ss-uploadfield .ss-ui-button { display: block; float: left; margin: 0 10px 6px 0; } .ss-uploadfield .ss-ui-button.ss-uploadfield-fromcomputer { position: relative; overflow: hidden; } .ss-uploadfield .ss-uploadfield-files { margin: 0; padding: 0; overflow: auto; position: relative; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item.ui-state-error { border: 0; border-bottom: 1px solid #b3b3b3; background: none; color: #444444; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item:last-child, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item.ui-state-error:last-child { border-bottom: 0; } -.ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-actions { height: 28px; margin: 6px 0 0; position: relative; } +.ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-actions { min-height: 28px; overflow: hidden; margin: 6px 0 -6px 0; position: relative; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-progress { position: absolute; left: 0; right: 42px; width: auto; margin: 11px 0 0; height: 15px; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-progress div { -webkit-border-radius: 25px; -moz-border-radius: 25px; -ms-border-radius: 25px; -o-border-radius: 25px; border-radius: 25px; height: 13px; padding: 0; margin: 0; overflow: hidden; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-progressbar { border: 1px solid gray; background-color: #92a6b3; background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #92a6b3), color-stop(11%, #90aab8), color-stop(22%, #96b1bf), color-stop(33%, #9eb4c1), color-stop(44%, #a7bac7), color-stop(100%, #c1d5dc)); background-image: -webkit-linear-gradient(top, #92a6b3 0%, #90aab8 11%, #96b1bf 22%, #9eb4c1 33%, #a7bac7 44%, #c1d5dc 100%); background-image: -moz-linear-gradient(top, #92a6b3 0%, #90aab8 11%, #96b1bf 22%, #9eb4c1 33%, #a7bac7 44%, #c1d5dc 100%); background-image: -o-linear-gradient(top, #92a6b3 0%, #90aab8 11%, #96b1bf 22%, #9eb4c1 33%, #a7bac7 44%, #c1d5dc 100%); background-image: linear-gradient(top, #92a6b3 0%, #90aab8 11%, #96b1bf 22%, #9eb4c1 33%, #a7bac7 44%, #c1d5dc 100%); } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-progressbarvalue { border: 0; width: 0%; background: #60b3dd url(../images/progressbar_blue.gif) repeat-x left center; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-cancel, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start { position: absolute; top: 10px; right: 0; } -.ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-cancel button, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start button { display: block; overflow: hidden; text-indent: -9999px; padding: 0; margin: 0; border: 0; width: 16px; height: 16px; cursor: pointer; -webkit-box-shadow: none; -moz-box-shadow: none; box-shadow: none; background: none; position: relative; } +.ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-cancel button, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start button { display: block; overflow: hidden; text-indent: -9999px; padding: 0; margin: 0; border: 0; width: 16px; height: 16px; cursor: pointer; -webkit-box-shadow: none; -moz-box-shadow: none; box-shadow: none; position: relative; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-cancel button span, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start button span { position: absolute; left: 0; top: 0; margin: 0; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-cancel button span.ui-button-text, .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start button span.ui-button-text { display: none; } .ss-uploadfield .ss-uploadfield-files .ss-uploadfield-item-start { right: 20px; } diff --git a/docs/en/changelogs/3.1.0.md b/docs/en/changelogs/3.1.0.md index d8568d2d2..9ca6c6f00 100644 --- a/docs/en/changelogs/3.1.0.md +++ b/docs/en/changelogs/3.1.0.md @@ -415,6 +415,7 @@ you can enable those warnings and future-proof your code already. * Removed `SQLMap::map()`: Use DataList::("Member")->map() * Removed `SQLMap::mapInGroups()`: Use Member::map_in_groups() * Removed `PasswordEncryptor::register()/unregister()`: Use config system instead + * `UploadField::setConfig()` and `UploadField::getConfig()` are no longer public: Use set{Property} or get{Property} to configure an `UploadField` instance * Methods on DataList and ArrayList that used to both modify the existing list & return a new version now just return a new version. Make sure you change statements like `$list->filter(...)` to $`list = $list->filter(...)` for these methods: - `ArrayList#reverse` - `ArrayList#sort` diff --git a/docs/en/reference/uploadfield.md b/docs/en/reference/uploadfield.md index 4e07b9b77..497e45890 100644 --- a/docs/en/reference/uploadfield.md +++ b/docs/en/reference/uploadfield.md @@ -10,8 +10,19 @@ like for instance in creating and managing a simple gallery. ## Usage -The field can be used in two ways: To upload a single file into a `has_one` relationship, -or allow multiple files into a fixed folder (or relationship). +The field can be used in three ways: To upload a single file into a `has_one` relationship, +or allow multiple files into a `has_many` or `many_many` relationship, or to act as a stand +alone uploader into a folder with no underlying relation. + +## Validation + +Although images are uploaded and stored on the filesystem immediately after selection, +the value (or values) of this field will not be written to any related record until +the record is saved and successfully validated. However, any invalid records will still +persist across form submissions until explicitly removed or replaced by the user. + +Care should be taken as invalid files may remain within the filesystem until explicitly +removed. ### Single fileupload @@ -42,13 +53,13 @@ based on a has_one relation: The UploadField will autodetect the relation based on it's `name` property, and save it into the GalleyPages' `SingleImageID` field. Setting the -`allowedMaxFileNumber` to 1 will make sure that only one image can ever be +`setAllowedMaxFileNumber` to 1 will make sure that only one image can ever be uploaded and linked to the relation. ### Multiple fileupload -Enable multiple fileuploads by using a many_many relation. Again, the -UploadField will detect the relation based on its $name property value: +Enable multiple fileuploads by using a many_many (or has_many) relation. Again, +the `UploadField` will detect the relation based on its $name property value: :::php class GalleryPage extends Page { @@ -68,22 +79,33 @@ UploadField will detect the relation based on its $name property value: $title = 'Upload one or more images (max 10 in total)' ) ); - $uploadField->setConfig('allowedMaxFileNumber', 10); + $uploadField->setAllowedMaxFileNumber(10); return $fields; } } class GalleryPage_Controller extends Page_Controller { } - -WARNING: Currently the UploadField doesn't fully support has_many relations, so use a many_many relation instead! + + class GalleryImageExtension extends DataExtension { + private static $belongs_many_many = array('Galleries' => 'GalleryPage); + } + + Image::add_extension('GalleryImageExtension'); + +
+In order to link both ends of the relationship together it's usually advisable to +extend Image with the necessary $has_one, $belongs_to, $has_many or $belongs_many_many. +In particular, a DataObject with $has_many Images will not work without this specified explicitly. +
## Configuration ### Overview -The field can either be configured on an instance level through `setConfig(, )`, -or globally by overriding the YAML defaults. See the [Configuration Reference](uploadfield#configuration-reference) section for possible values. +The field can either be configured on an instance level with the various +getProperty and setProperty functions, or globally by overriding the YAML defaults. +See the [Configuration Reference](uploadfield#configuration-reference) section for possible values. Example: mysite/_config/uploadfield.yml @@ -93,7 +115,6 @@ Example: mysite/_config/uploadfield.yml defaultConfig: canUpload: false - ### Set a custom folder This example will save all uploads in the `/assets/customfolder/` folder. If @@ -108,13 +129,22 @@ the folder doesn't exist, it will be created. ); $uploadField->setFolderName('customfolder'); -## Limit the allowed filetypes +### Limit the allowed filetypes `AllowedExtensions` defaults to the `File.allowed_extensions` configuration setting, but can be overwritten for each UploadField: :::php - $uploadField->getValidator()->setAllowedExtensions(array('jpg', 'jpeg', 'png', 'gif')); + $uploadField->setAllowedExtensions(array('jpg', 'jpeg', 'png', 'gif')); + +Entire groups of file extensions can be specified in order to quickly limit types +to known file categories. + + :::php + // This will limit files to the following extensions: + // "bmp" ,"gif" ,"jpg" ,"jpeg" ,"pcx" ,"tif" ,"png" ,"alpha","als" ,"cel" ,"icon" ,"ico" ,"ps" + // 'doc','docx','txt','rtf','xls','xlsx','pages', 'ppt','pptx','pps','csv', 'html','htm','xhtml', 'xml','pdf' + $uploadField->setAllowedFileCategories('image', 'doc'); ### Limit the maximum file size @@ -135,8 +165,24 @@ Set the dimensions of the image preview. By default the max width is set to 80 and the max height is set to 60. :::php - $uploadField->setConfig('previewMaxWidth', 100); - $uploadField->setConfig('previewMaxHeight', 100); + $uploadField->setPreviewMaxWidth(100); + $uploadField->setPreviewMaxHeight(100); + +### Disable attachment of existing files + +This can force the user to upload a new file, rather than link to the already +existing file librarry + + :::php + $uploadField->setCanAttachExisting(false); + +### Disable uploading of new files + +Alternatively, you can force the user to only specify already existing files +in the file library + + :::php + $uploadField->setCanUpload(false); ### Automatic or manual upload @@ -145,8 +191,8 @@ Setting the `autoUpload` property to false, will present you with a list of selected files that you can then upload manually one by one: :::php - $uploadField->setConfig('autoUpload', false); - + $uploadField->setAutoUpload(false); + ### Build a simple gallery A gallery most times needs more then simple images. You might want to add a @@ -171,8 +217,14 @@ Now register the DataExtension for the Image class in your _config.php: :::php Image::add_extension('GalleryImage'); - -NOTE: although you can subclass the Image class instead of using a DataExtension, this is not advisable. For instance: when using a subclass, the 'From files' button will only return files that were uploaded for that subclass, it won't recognize any other images! + +
+Note: Although you can subclass the Image class instead of using a DataExtension, +this is not advisable. For instance: when using a subclass, the 'From files' +button will only return files that were uploaded for that subclass, it won't +recognize any other images! +
+ ### Edit uploaded images By default the UploadField will let you edit the following fields: *Title, @@ -195,31 +247,62 @@ you you alter these settings. One way to go about this is create a Then, in your GalleryPage, tell the UploadField to use this function: :::php - $uploadField->setConfig('fileEditFields', 'getCustomFields'); + $uploadField->setFileEditFields('getCustomFields'); -In a similar fashion you can use 'fileEditActions' to set the actions for the +In a similar fashion you can use 'setFileEditActions' to set the actions for the editform, or 'fileEditValidator' to determine the validator (eg RequiredFields). ### Configuration Reference - - `autoUpload`: (boolean) - - `allowedMaxFileNumber`: (int) php validation of allowedMaxFileNumber + - `setAllowedMaxFileNumber`: (int) php validation of allowedMaxFileNumber only works when a db relation is available, set to null to allow unlimited if record has a has_one and allowedMaxFileNumber is null, it will be set to 1 - - `canUpload`: (boolean) Can the user upload new files, or just select from existing files. + - `setAllowedFileExtensions`: (array) List of file extensions allowed + - `setAllowedFileCategories`: (array|string) List of types of files allowed. + May be any of 'image', 'audio', 'mov', 'zip', 'flash', or 'doc' + - `setAutoUpload`: (boolean) Should the field automatically trigger an upload once + a file is selected? + - `setCanAttachExisting`: (boolean|string) Can the user attach existing files from the library. String values are interpreted as permission codes. - - `previewMaxWidth`: (int) - - `previewMaxHeight`: (int) - - `uploadTemplateName`: (string) javascript template used to display uploading - files, see javascript/UploadField_uploadtemplate.js - - `downloadTemplateName`: (string) javascript template used to display already + - `setCanPreviewFolder`: (boolean|string) Can the user preview the folder files will be saved into? + String values are interpreted as permission codes. + - `setCanUpload`: (boolean|string) Can the user upload new files, or just select from existing files. + String values are interpreted as permission codes. + - `setDownloadTemplateName`: (string) javascript template used to display already uploaded files, see javascript/UploadField_downloadtemplate.js - - `fileEditFields`: (FieldList|string) FieldList $fields or string $name + - `setFileEditFields`: (FieldList|string) FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm (Example: 'getCMSFields') - - `fileEditActions`: (FieldList|string) FieldList $actions or string $name + - `setFileEditActions`: (FieldList|string) FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm (Example: 'getCMSActions') - - `fileEditValidator`: (string) Validator (eg RequiredFields) or string $name + - `setFileEditValidator`: (string) Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm (Example: 'getCMSValidator') + - `setOverwriteWarning`: (boolean) Show a warning when overwriting a file. + - `setPreviewMaxWidth`: (int) + - `setPreviewMaxHeight`: (int) + - `setTemplateFileButtons`: (string) Template name to use for the file buttons + - `setTemplateFileEdit`: (string) Template name to use for the file edit form + - `setUploadTemplateName`: (string) javascript template used to display uploading + files, see javascript/UploadField_uploadtemplate.js + - `setCanPreviewFolder`: (boolean|string) Is the upload folder visible to uploading users? + String values are interpreted as permission codes. + +Certain default values for the above can be configured using the YAML config system. + + :::yaml + UploadField: + defaultConfig: + autoUpload: true + allowedMaxFileNumber: + canUpload: true + canAttachExisting: 'CMS_ACCESS_AssetAdmin' + canPreviewFolder: true + previewMaxWidth: 80 + previewMaxHeight: 60 + uploadTemplateName: 'ss-uploadfield-uploadtemplate' + downloadTemplateName: 'ss-uploadfield-downloadtemplate' + overwriteWarning: true # Warning before overwriting existing file (only relevant when Upload: replaceFile is true) + +The above settings can also be set on a per-instance basis by using `setConfig` with the appropriate key. You can also configure the underlying `[api:Upload]` class, by using the YAML config system. @@ -227,8 +310,65 @@ You can also configure the underlying `[api:Upload]` class, by using the YAML co Upload: # Globally disables automatic renaming of files and displays a warning before overwriting an existing file replaceFile: true + uploads_folder: 'Uploads' + +## Using the UploadField in a frontend form -## TODO: Using the UploadField in a frontend form +The UploadField can be used in a frontend form, given that sufficient attention is given +to the permissions granted to non-authorised users. -*At this moment the UploadField not yet fully supports being used on a frontend -form.* +By default Image::canDelete and Image::canEdit do not require admin privileges, so +make sure you override the methods in your Image extension class. + +For instance, to generate an upload form suitable for saving images into a user-defined +gallery the below code could be used: + + :::php + + // In GalleryPage.php + class GalleryPage extends Page {} + class GalleryPage_Controller extends Page_Controller { + public function Form() { + $fields = new FieldList( + new TextField('Title', 'Title', null, 255), + $field = new UploadField('Images', 'Upload Images') + ); + $field->setCanAttachExisting(false); // Block access to Silverstripe assets library + $field->setCanPreviewFolder(false); // Don't show target filesystem folder on upload field + $field->relationAutoSetting = false; // Prevents the form thinking the GalleryPage is the underlying object + $actions = new FieldList(new FormAction('submit', 'Save Images')); + return new Form($this, 'Form', $fields, $actions, null); + } + + public function submit($data, Form $form) { + $gallery = new Gallery(); + $form->saveInto($gallery); + $gallery->write(); + return $this; + } + } + + // In Gallery.php + class Gallery extends DataObject { + private static $db = array( + 'Title' => 'Varchar(255)' + ); + + private static $many_many = array( + 'Images' => 'Image' + ); + } + + // In ImageExtension.php + class ImageExtension extends DataExtension { + + private static $belongs_many_many = array( + 'Gallery' => 'Gallery' + ); + + function canEdit($member) { + // This part is important! + return Permission::check('ADMIN'); + } + } + Image::add_extension('ImageExtension'); diff --git a/filesystem/File.php b/filesystem/File.php index 16db447bf..e110cdebb 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -340,10 +340,10 @@ class File extends DataObject { } // Upload - $uploadField = new UploadField('UploadField','Upload Field'); - $uploadField->setConfig('previewMaxWidth', 40); - $uploadField->setConfig('previewMaxHeight', 30); - $uploadField->setConfig('allowedMaxFileNumber', 1); + $uploadField = UploadField::create('UploadField','Upload Field') + ->setPreviewMaxWidth(40) + ->setPreviewMaxHeight(30) + ->setAllowedMaxFileNumber(1); //$uploadField->setTemplate('FileEditUploadField'); if ($this->ParentID) { $parent = $this->Parent(); diff --git a/filesystem/Upload.php b/filesystem/Upload.php index 909c57beb..2233bb214 100644 --- a/filesystem/Upload.php +++ b/filesystem/Upload.php @@ -28,12 +28,14 @@ class Upload extends Controller { /** * A File object + * * @var File */ protected $file; /** - * An instance of Upload_Validator + * Validator for this upload field + * * @var Upload_Validator */ protected $validator; @@ -48,7 +50,8 @@ class Upload extends Controller { /** * Replace an existing file rather than renaming the new one. - * @var Boolean + * + * @var boolean */ protected $replaceFile; @@ -72,13 +75,13 @@ class Upload extends Controller { public function __construct() { parent::__construct(); $this->validator = new Upload_Validator(); - $this->replaceFile = $this->config()->replaceFile; + $this->replaceFile = self::config()->replaceFile; } /** * Get current validator * - * @return object $validator + * @return Upload_Validator $validator */ public function getValidator() { return $this->validator; @@ -277,7 +280,7 @@ class Upload extends Controller { * @return array */ public function getErrors() { - return $this->errors; + return $this->errors; } } diff --git a/forms/FileField.php b/forms/FileField.php index 29cb4b283..7be5cee10 100644 --- a/forms/FileField.php +++ b/forms/FileField.php @@ -132,7 +132,7 @@ class FileField extends FormField { /** * Get custom validator for this field * - * @param object $validator + * @param Upload_Validator $validator */ public function getValidator() { return $this->upload->getValidator(); @@ -141,7 +141,8 @@ class FileField extends FormField { /** * Set custom validator for this field * - * @param object $validator + * @param Upload_Validator $validator + * @return FileField Self reference */ public function setValidator($validator) { $this->upload->setValidator($validator); @@ -149,7 +150,10 @@ class FileField extends FormField { } /** + * Sets the upload folder name + * * @param string $folderName + * @return FileField Self reference */ public function setFolderName($folderName) { $this->folderName = $folderName; @@ -157,6 +161,8 @@ class FileField extends FormField { } /** + * Gets the upload folder name + * * @return string */ public function getFolderName() { @@ -181,14 +187,23 @@ class FileField extends FormField { } /** + * Retrieves the Upload handler + * * @return Upload */ public function getUpload() { return $this->upload; } + /** + * Sets the upload handler + * + * @param Upload $upload + * @return FileField Self reference + */ public function setUpload(Upload $upload) { $this->upload = $upload; + return $this; } } diff --git a/forms/FormField.php b/forms/FormField.php index d682315e2..87b73e63c 100644 --- a/forms/FormField.php +++ b/forms/FormField.php @@ -208,6 +208,8 @@ class FormField extends RequestHandler { /** * Method to save this form field into the given data object. * By default, makes use of $this->dataValue() + * + * @param DataObjectInterface $record DataObject to save data into */ public function saveInto(DataObjectInterface $record) { if($this->name) { @@ -403,7 +405,9 @@ class FormField extends RequestHandler { /** * Set the field value. - * Returns $this. + * + * @param mixed $value + * @return FormField Self reference */ public function setValue($value) { $this->value = $value; diff --git a/forms/UploadField.php b/forms/UploadField.php index 3662a3d27..55e352137 100644 --- a/forms/UploadField.php +++ b/forms/UploadField.php @@ -2,14 +2,13 @@ /** * Field for uploading single or multiple files of all types, including images. - * NOTE: this Field will call write() on the supplied record * * Features (some might not be available to old browsers): * * - File Drag&Drop support * - Progressbar * - Image thumbnail/file icons even before upload finished - * - Saving into relations + * - Saving into relations on form submit * - Edit file * - allowedExtensions is by default File::$allowed_extensions
  • maxFileSize the value of min(upload_max_filesize, * post_max_size) from php.ini @@ -17,9 +16,9 @@ * <>Usage * * @example - * $UploadField = new UploadField('myFiles', 'Please upload some images (max. 5 files)'); - * $UploadField->getValidator()->setAllowedExtensions(array('jpg', 'jpeg', 'png', 'gif')); - * $UploadField->setConfig('allowedMaxFileNumber', 5); + * $UploadField = new UploadField('AttachedImages', 'Please upload some images (max. 5 files)'); + * $UploadField->setAllowedFileCategories('image'); + * $UploadField->setAllowedMaxFileNumber(5); * * * @author Zauberfisch @@ -36,6 +35,7 @@ class UploadField extends FileField { 'attach', 'handleItem', 'handleSelect', + 'fileexists' ); /** @@ -48,107 +48,141 @@ class UploadField extends FileField { ); /** - * @var String + * Template to use for the file button widget + * + * @var string */ protected $templateFileButtons = 'UploadField_FileButtons'; /** - * @var String + * Template to use for the edit form + * + * @var string */ protected $templateFileEdit = 'UploadField_FileEdit'; /** + * Parent data record. Will be infered from parent form or controller if blank. + * * @var DataObject */ protected $record; /** + * Items loaded into this field. May be a RelationList, or any other SS_List + * * @var SS_List */ protected $items; /** - * @var array Config for this field used in both, php and javascript + * Config for this field used in the front-end javascript * (will be merged into the config of the javascript file upload plugin). * See framework/_config/uploadfield.yml for configuration defaults and documentation. + * + * @var array */ protected $ufConfig = array( /** + * Automatically upload the file once selected + * * @var boolean */ 'autoUpload' => true, /** - * php validation of allowedMaxFileNumber only works when a db relation is available, set to null to allow - * unlimited if record has a has_one and allowedMaxFileNumber is null, it will be set to 1 - * @var int + * Restriction on number of files that may be set for this field. Set to null to allow + * unlimited. If record has a has_one and allowedMaxFileNumber is null, it will be set to 1. + * The resulting value will be set to maxNumberOfFiles + * + * @var integer */ 'allowedMaxFileNumber' => null, /** - * @var boolean|string Can the user upload new files, or just select from existing files. + * Can the user upload new files, or just select from existing files. * String values are interpreted as permission codes. + * + * @var boolean|string */ 'canUpload' => true, /** - * @var boolean|string Can the user attach files from the assets archive on the site? + * Can the user attach files from the assets archive on the site? * String values are interpreted as permission codes. + * + * @var boolean|string */ 'canAttachExisting' => "CMS_ACCESS_AssetAdmin", /** - * @var boolean Shows the target folder for new uploads in the field UI. + * Shows the target folder for new uploads in the field UI. * Disable to keep the internal filesystem structure hidden from users. + * + * @var boolean|string */ 'canPreviewFolder' => true, /** - * @var boolean If a second file is uploaded, should it replace the existing one rather than throwing an errror? - * This only applies for has_one relationships, and only replaces the association - * rather than the actual file database record or filesystem entry. - */ - 'replaceExistingFile' => false, - /** - * @var int + * Maximum width of the preview thumbnail + * + * @var integer */ 'previewMaxWidth' => 80, /** - * @var int + * Maximum height of the preview thumbnail + * + * @var integer */ 'previewMaxHeight' => 60, /** * javascript template used to display uploading files + * * @see javascript/UploadField_uploadtemplate.js * @var string */ 'uploadTemplateName' => 'ss-uploadfield-uploadtemplate', /** * javascript template used to display already uploaded files + * * @see javascript/UploadField_downloadtemplate.js * @var string */ 'downloadTemplateName' => 'ss-uploadfield-downloadtemplate', /** - * FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm - * @example 'getCMSFields' - * @var FieldList|string + * Show a warning when overwriting a file. + * This requires Upload->replaceFile config to be set to true, otherwise + * files will be renamed instead of overwritten (although the warning will + * still be displayed) + * + * @see Upload + * @var boolean */ - 'fileEditFields' => null, - /** - * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm - * @example 'getCMSActions' - * @var FieldList|string - */ - 'fileEditActions' => null, - /** - * Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm - * @example 'getCMSValidator' - * @var string - */ - 'fileEditValidator' => null, - /** - * Show a warning when overwriting a file - */ - 'overwriteWarning' => true, + 'overwriteWarning' => true ); /** + * FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm + * @example 'getCMSFields' + * + * @var FieldList|string + */ + protected $fileEditFields = null; + + /** + * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm + * @example 'getCMSActions' + * + * @var FieldList|string + */ + protected $fileEditActions = null; + + /** + * Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm + * @example 'getCMSValidator' + * + * @var RequiredFields|string + */ + protected $fileEditValidator = null; + + /** + * Construct a new UploadField instance + * * @param string $name The internal field name, passed to forms. * @param string $title The field label. * @param SS_List $items If no items are defined, the field will try to auto-detect an existing relation on @@ -156,11 +190,12 @@ class UploadField extends FileField { * @param Form $form Reference to the container form */ public function __construct($name, $title = null, SS_List $items = null) { + // TODO thats the first thing that came to my head, feel free to change it $this->addExtraClass('ss-upload'); // class, used by js $this->addExtraClass('ss-uploadfield'); // class, used by css for uploadfield only - $this->ufConfig = array_merge($this->ufConfig, Config::inst()->get('UploadField', 'defaultConfig')); + $this->ufConfig = array_merge($this->ufConfig, self::config()->defaultConfig); parent::__construct($name, $title); @@ -170,15 +205,17 @@ class UploadField extends FileField { $this->getValidator()->setAllowedExtensions( array_filter(Config::inst()->get('File', 'allowed_extensions')) ); + // get the lower max size - $this->getValidator()->setAllowedMaxFileSize(min(File::ini2bytes(ini_get('upload_max_filesize')), - File::ini2bytes(ini_get('post_max_size')))); + $maxUpload = File::ini2bytes(ini_get('upload_max_filesize')); + $maxPost = File::ini2bytes(ini_get('post_max_size')); + $this->getValidator()->setAllowedMaxFileSize(min($maxUpload, $maxPost)); } /** * Set name of template used for Buttons on each file (replace, edit, remove, delete) (without path or extension) * - * @param String + * @param string */ public function setTemplateFileButtons($template) { $this->templateFileButtons = $template; @@ -186,7 +223,7 @@ class UploadField extends FileField { } /** - * @return String + * @return string */ public function getTemplateFileButtons() { return $this->templateFileButtons; @@ -195,7 +232,7 @@ class UploadField extends FileField { /** * Set name of template used for the edit (inline & popup) of a file file (without path or extension) * - * @param String + * @param string */ public function setTemplateFileEdit($template) { $this->templateFileEdit = $template; @@ -203,12 +240,60 @@ class UploadField extends FileField { } /** - * @return String + * @return string */ public function getTemplateFileEdit() { return $this->templateFileEdit; } + /** + * Determine if the target folder for new uploads in is visible the field UI. + * + * @return boolean + */ + public function canPreviewFolder() { + if(!$this->isActive()) return false; + $can = $this->getConfig('canPreviewFolder'); + return (is_bool($can)) ? $can : Permission::check($can); + } + + /** + * Determine if the target folder for new uploads in is visible the field UI. + * Disable to keep the internal filesystem structure hidden from users. + * + * @param boolean|string $canPreviewFolder Either a boolean flag, or a + * required permission code + * @return UploadField Self reference + */ + public function setCanPreviewFolder($canPreviewFolder) { + return $this->setConfig('canPreviewFolder', $canPreviewFolder); + } + + /** + * Determine if the field should show a warning when overwriting a file. + * This requires Upload->replaceFile config to be set to true, otherwise + * files will be renamed instead of overwritten (although the warning will + * still be displayed) + * + * @return boolean + */ + public function getOverwriteWarning() { + return $this->getConfig('overwriteWarning'); + } + + /** + * Determine if the field should show a warning when overwriting a file. + * This requires Upload->replaceFile config to be set to true, otherwise + * files will be renamed instead of overwritten (although the warning will + * still be displayed) + * + * @param boolean $overwriteWarning + * @return UploadField Self reference + */ + public function setOverwriteWarning($overwriteWarning) { + return $this->setConfig('overwriteWarning', $overwriteWarning); + } + /** * Force a record to be used as "Parent" for uploaded Files (eg a Page with a has_one to File) * @param DataObject $record @@ -220,71 +305,190 @@ class UploadField extends FileField { /** * Get the record to use as "Parent" for uploaded Files (eg a Page with a has_one to File) If none is set, it will * use Form->getRecord() or Form->Controller()->data() + * * @return DataObject */ public function getRecord() { if (!$this->record && $this->form) { - if ($this->form->getRecord() && is_a($this->form->getRecord(), 'DataObject')) { - $this->record = $this->form->getRecord(); - } elseif ($this->form->Controller() && $this->form->Controller()->hasMethod('data') - && $this->form->Controller()->data() && is_a($this->form->Controller()->data(), 'DataObject')) { - $this->record = $this->form->Controller()->data(); + if (($record = $this->form->getRecord()) && ($record instanceof DataObject)) { + $this->record = $record; + } elseif (($controller = $this->form->Controller()) + && $controller->hasMethod('data') + && ($record = $controller->data()) + && ($record instanceof DataObject) + ) { + $this->record = $record; } } return $this->record; } + + /** + * Loads the related record values into this field. UploadField can be uploaded + * in one of three ways: + * + * - By passing in a list of file IDs in the $value parameter (an array with a single + * key 'Files', with the value being the actual array of IDs). + * - By passing in an explicit list of File objects in the $record parameter, and + * leaving $value blank. + * - By passing in a dataobject in the $record parameter, from which file objects + * will be extracting using the field name as the relation field. + * + * Each of these methods will update both the items (list of File objects) and the + * field value (list of file ID values). + * + * @param array $value Array of submitted form data, if submitting from a form + * @param array|DataObject|SS_List $record Full source record, either as a DataObject, + * SS_List of items, or an array of submitted form data + * @return UploadField Self reference + */ + public function setValue($value, $record = null) { + + // If we're not passed a value directly, we can attempt to infer the field + // value from the second parameter by inspecting its relations + $items = new ArrayList(); + + // Determine format of presented data + if(empty($value) && $record) { + // If a record is given as a second parameter, but no submitted values, + // then we should inspect this instead for the form values + + if(($record instanceof DataObject) && $record->hasMethod($this->getName())) { + // If given a dataobject use reflection to extract details + + $data = $record->{$this->getName()}(); + if($data instanceof DataObject) { + // If has_one, add sole item to default list + $items->push($data); + } elseif($data instanceof SS_List) { + // For many_many and has_many relations we can use the relation list directly + $items = $data; + } + } elseif($record instanceof SS_List) { + // If directly passing a list then save the items directly + $items = $record; + } + } elseif(!empty($value['Files'])) { + // If value is given as an array (such as a posted form), extract File IDs from this + $class = $this->getRelationAutosetClass(); + $items = DataObject::get($class)->byIDs($value['Files']); + } + + // If javascript is disabled, direct file upload (non-html5 style) can + // trigger a single or multiple file submission. Note that this may be + // included in addition to re-submitted File IDs as above, so these + // should be added to the list instead of operated on independently. + if($uploadedFiles = $this->extractUploadedFileData($value)) { + foreach($uploadedFiles as $tempFile) { + $file = $this->saveTemporaryFile($tempFile, $error); + if($file) { + $items->add($file); + } else { + throw new ValidationException($error); + } + } + } + + // Filter items by what's allowed to be viewed + $filteredItems = new ArrayList(); + $fileIDs = array(); + foreach($items as $file) { + if($file->exists() && $file->canView()) { + $filteredItems->push($file); + $fileIDs[] = $file->ID; + } + } + + // Filter and cache updated item list + $this->items = $filteredItems; + // Same format as posted form values for this field. Also ensures that + // $this->setValue($this->getValue()); is non-destructive + $value = $fileIDs ? array('Files' => $fileIDs) : null; + + // Set value using parent + return parent::setValue($value, $record); + } /** + * Sets the items assigned to this field as an SS_List of File objects. + * Calling setItems will also update the value of this field, as well as + * updating the internal list of File items. + * * @param SS_List $items + * @return UploadField self reference */ public function setItems(SS_List $items) { - $this->items = $items; + return $this->setValue(null, $items); + } + + /** + * Retrieves the current list of files + * + * @return SS_List + */ + public function getItems() { + return $this->items ? $this->items : new ArrayList(); + } + + /** + * Retrieves a customised list of all File records to ensure they are + * properly viewable when rendered in the field template. + * + * @return SS_List[ViewableData_Customised] + */ + public function getCustomisedItems() { + $customised = new ArrayList(); + foreach($this->getItems() as $file) { + $customised->push($this->customiseFile($file)); + } + return $customised; + } + + /** + * Retrieves the list of selected file IDs + * + * @return array + */ + public function getItemIDs() { + $value = $this->Value(); + return empty($value['Files']) ? array() : $value['Files']; + } + + public function Value() { + // Re-override FileField Value to use data value + return $this->dataValue(); + } + + public function saveInto(DataObjectInterface $record) { + // Check required relation details are available + $fieldname = $this->getName(); + if(!$fieldname) return $this; + + // Get details to save + $idList = $this->getItemIDs(); + + // Check type of relation + $relation = $record->hasMethod($fieldname) ? $record->$fieldname() : null; + if($relation && ($relation instanceof RelationList || $relation instanceof UnsavedRelationList)) { + // has_many or many_many + $relation->setByIDList($idList); + } elseif($record->has_one($fieldname)) { + // has_one + $record->{"{$fieldname}ID"} = $idList ? reset($idList) : 0; + } return $this; } /** - * @return SS_List - */ - public function getItems() { - $name = $this->getName(); - if (!$this->items || !$this->items->exists()) { - $record = $this->getRecord(); - $this->items = array(); - // Try to auto-detect relationship - if ($record && $record->exists()) { - if ($record->has_many($name) || $record->many_many($name)) { - // Ensure relationship is cast to an array, as we can't alter the items of a DataList/RelationList - // (see below) - $this->items = $record->{$name}()->toArray(); - } elseif($record->has_one($name)) { - $item = $record->{$name}(); - if ($item && $item->exists()) - $this->items = array($record->{$name}()); - } - } - $this->items = new ArrayList($this->items); - // hack to provide $UploadFieldThumbnailURL, $hasRelation and $UploadFieldEditLink in template for each - // file - if ($this->items->exists()) { - foreach ($this->items as $i=>$file) { - $this->items[$i] = $this->customiseFile($file); - if(!$file->canView()) unset($this->items[$i]); // Respect model permissions - } - } - } - return $this->items; - } - - /** - * Hack to add some Variables and a dynamic template to a File + * Customises a file with additional details suitable for rendering in the + * UploadField.ss template + * * @param File $file * @return ViewableData_Customised */ protected function customiseFile(File $file) { $file = $file->customise(array( - 'UploadFieldHasRelation' => $this->managesRelation(), 'UploadFieldThumbnailURL' => $this->getThumbnailURLForFile($file), - 'UploadFieldRemoveLink' => $this->getItemHandler($file->ID)->RemoveLink(), 'UploadFieldDeleteLink' => $this->getItemHandler($file->ID)->DeleteLink(), 'UploadFieldEditLink' => $this->getItemHandler($file->ID)->EditLink(), 'UploadField' => $this @@ -296,44 +500,409 @@ class UploadField extends FileField { } /** + * Assign a front-end config variable for the upload field + * * @param string $key * @param mixed $val + * @return UploadField self reference */ public function setConfig($key, $val) { + if(!array_key_exists($key, $this->ufConfig)) { + user_error("UploadField->setConfig called with invalid option: '$key'", E_USER_ERROR); + } $this->ufConfig[$key] = $val; return $this; } /** + * Gets a front-end config variable for the upload field + * * @param string $key * @return mixed */ public function getConfig($key) { + if(!array_key_exists($key, $this->ufConfig)) { + user_error("UploadField->getConfig called with invalid option: '$key'", E_USER_ERROR); + } return $this->ufConfig[$key]; } - + /** - * Used to get config in the template + * Determine if the field should automatically upload the file. + * + * @return boolean */ public function getAutoUpload() { return $this->getConfig('autoUpload'); } + /** + * Determine if the field should automatically upload the file + * + * @param boolean $autoUpload + * @return UploadField Self reference + */ + public function setAutoUpload($autoUpload) { + return $this->setConfig('autoUpload', $autoUpload); + } + + /** + * Determine maximum number of files allowed to be attached + * Defaults to 1 for has_one and null (unlimited) for + * many_many and has_many relations. + * + * @return integer|null Maximum limit, or null for no limit + */ + public function getAllowedMaxFileNumber() { + $allowedMaxFileNumber = $this->getConfig('allowedMaxFileNumber'); + + // if there is a has_one relation with that name on the record and + // allowedMaxFileNumber has not been set, it's wanted to be 1 + if(empty($allowedMaxFileNumber)) { + $record = $this->getRecord(); + $name = $this->getName(); + if($record && $record->has_one($name)) { + return 1; // Default for has_one + } else { + return null; // Default for has_many and many_many + } + } else { + return $allowedMaxFileNumber; + } + } + + + /** + * Limit allowed file extensions. Empty by default, allowing all extensions. + * To allow files without an extension, use an empty string. + * See {@link File::$allowed_extensions} to get a good standard set of + * extensions that are typically not harmful in a webserver context. + * See {@link setAllowedMaxFileSize()} to limit file size by extension. + * + * @param array $rules List of extensions + * @return UploadField Self reference + */ + public function setAllowedExtensions($rules) { + $this->getValidator()->setAllowedExtensions($rules); + return $this; + } + + /** + * Limit allowed file extensions by specifying categories of file types. + * These may be 'image', 'audio', 'mov', 'zip', 'flash', or 'doc' + * See {@link File::$allowed_extensions} for details of allowed extensions + * for each of these categories + * + * @param string $category Category name + * @param string,... $categories Additional category names + * @return UploadField Self reference + */ + public function setAllowedFileCategories($category) { + $extensions = array(); + $knownCategories = File::config()->app_categories; + + // Parse arguments + $categories = func_get_args(); + if(func_num_args() === 1 && is_array(reset($categories))) { + $categories = reset($categories); + } + + // Merge all categories into list of extensions + foreach(array_filter($categories) as $category) { + if(isset($knownCategories[$category])) { + $extensions = array_merge($extensions, $knownCategories[$category]); + } else { + user_error("Unknown file category: $category", E_USER_ERROR); + } + } + return $this->setAllowedExtensions($extensions); + } + + /** + * Returns list of extensions allowed by this field, or an empty array + * if there is no restriction + * + * @return array + */ + public function getAllowedExtensions() { + return $this->getValidator()->getAllowedExtensions(); + } + + /** + * Determine maximum number of files allowed to be attached. + * + * @param integer|null $allowedMaxFileNumber Maximum limit. 0 or null will be treated as unlimited + * @return UploadField Self reference + */ + public function setAllowedMaxFileNumber($allowedMaxFileNumber) { + return $this->setConfig('allowedMaxFileNumber', $allowedMaxFileNumber); + } + + /** + * Determine if the user has permission to upload. + * + * @return boolean + */ + public function canUpload() { + if(!$this->isActive()) return false; + $can = $this->getConfig('canUpload'); + return (is_bool($can)) ? $can : Permission::check($can); + } + + /** + * Specify whether the user can upload files. + * String values will be treated as required permission codes + * + * @param boolean|string $canUpload Either a boolean flag, or a required + * permission code + * @return UploadField Self reference + */ + public function setCanUpload($canUpload) { + return $this->setConfig('canUpload', $canUpload); + } + + /** + * Determine if the user has permission to attach existing files + * By default returns true if the user has the CMS_ACCESS_AssetAdmin permission + * + * @return boolean + */ + public function canAttachExisting() { + if(!$this->isActive()) return false; + $can = $this->getConfig('canAttachExisting'); + return (is_bool($can)) ? $can : Permission::check($can); + } + + /** + * Returns true if the field is neither readonly nor disabled + * + * @return boolean + */ + public function isActive() { + return !$this->isDisabled() && !$this->isReadonly(); + } + + /** + * Specify whether the user can attach existing files + * String values will be treated as required permission codes + * + * @param boolean|string $canAttachExisting Either a boolean flag, or a + * required permission code + * @return UploadField Self reference + */ + public function setCanAttachExisting($canAttachExisting) { + return $this->setConfig('canAttachExisting', $canAttachExisting); + } + + /** + * Gets thumbnail width. Defaults to 80 + * + * @return integer + */ + public function getPreviewMaxWidth() { + return $this->getConfig('previewMaxWidth'); + } + + /** + * @see UploadField::getPreviewMaxWidth() + * + * @param integer $previewMaxWidth + * @return UploadField Self reference + */ + public function setPreviewMaxWidth($previewMaxWidth) { + return $this->setConfig('previewMaxWidth', $previewMaxWidth); + } + + /** + * Gets thumbnail height. Defaults to 60 + * + * @return integer + */ + public function getPreviewMaxHeight() { + return $this->getConfig('previewMaxHeight'); + } + + /** + * @see UploadField::getPreviewMaxHeight() + * + * @param integer $previewMaxHeight + * @return UploadField Self reference + */ + public function setPreviewMaxHeight($previewMaxHeight) { + return $this->setConfig('previewMaxHeight', $previewMaxHeight); + } + + /** + * javascript template used to display uploading files + * Defaults to 'ss-uploadfield-uploadtemplate' + * + * @see javascript/UploadField_uploadtemplate.js + * @var string + */ + public function getUploadTemplateName() { + return $this->getConfig('uploadTemplateName'); + } + + /** + * @see UploadField::getUploadTemplateName() + * + * @param string $uploadTemplateName + * @return UploadField Self reference + */ + public function setUploadTemplateName($uploadTemplateName) { + return $this->setConfig('uploadTemplateName', $uploadTemplateName); + } + + /** + * javascript template used to display already uploaded files + * Defaults to 'ss-downloadfield-downloadtemplate' + * + * @see javascript/DownloadField_downloadtemplate.js + * @var string + */ + public function getDownloadTemplateName() { + return $this->getConfig('downloadTemplateName'); + } + + /** + * @see Uploadfield::getDownloadTemplateName() + * + * @param string $downloadTemplateName + * @return Uploadfield Self reference + */ + public function setDownloadTemplateName($downloadTemplateName) { + return $this->setConfig('downloadTemplateName', $downloadTemplateName); + } + + /** + * FieldList $fields for the EditForm + * @example 'getCMSFields' + * + * @param File $file File context to generate fields for + * @return FieldList List of form fields + */ + public function getFileEditFields(File $file) { + + // Empty actions, generate default + if(empty($this->fileEditFields)) { + $fields = $file->getCMSFields(); + // Only display main tab, to avoid overly complex interface + if($fields->hasTabSet() && ($mainTab = $fields->findOrMakeTab('Root.Main'))) { + $fields = $mainTab->Fields(); + } + return $fields; + } + + // Fields instance + if ($this->fileEditFields instanceof FieldList) return $this->fileEditFields; + + // Method to call on the given file + if($file->hasMethod($this->fileEditFields)) { + return $file->{$this->fileEditFields}(); + } + + user_error("Invalid value for UploadField::fileEditFields", E_USER_ERROR); + } + + /** + * FieldList $fields or string $name (of a method on File to provide a fields) for the EditForm + * @example 'getCMSFields' + * + * @param FieldList|string + * @return Uploadfield Self reference + */ + public function setFileEditFields($fileEditFields) { + $this->fileEditFields = $fileEditFields; + return $this; + } + + /** + * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm + * @example 'getCMSActions' + * + * @param File $file File context to generate form actions for + * @return FieldList Field list containing FormAction + */ + public function getFileEditActions(File $file) { + + // Empty actions, generate default + if(empty($this->fileEditActions)) { + $actions = new FieldList($saveAction = new FormAction('doEdit', _t('UploadField.DOEDIT', 'Save'))); + $saveAction->addExtraClass('ss-ui-action-constructive icon-accept'); + return $actions; + } + + // Actions instance + if ($this->fileEditActions instanceof FieldList) return $this->fileEditActions; + + // Method to call on the given file + if($file->hasMethod($this->fileEditActions)) { + return $file->{$this->fileEditActions}(); + } + + user_error("Invalid value for UploadField::fileEditActions", E_USER_ERROR); + } + + /** + * FieldList $actions or string $name (of a method on File to provide a actions) for the EditForm + * @example 'getCMSActions' + * + * @param FieldList|string + * @return Uploadfield Self reference + */ + public function setFileEditActions($fileEditActions) { + $this->fileEditActions = $fileEditActions; + return $this; + } + + /** + * Determines the validator to use for the edit form + * @example 'getCMSValidator' + * + * @param File $file File context to generate validator from + * @return Validator Validator object + */ + public function getFileEditValidator(File $file) { + // Empty validator + if(empty($this->fileEditValidator)) return null; + + // Validator instance + if($this->fileEditValidator instanceof Validator) return $this->fileEditValidator; + + // Method to call on the given file + if($file->hasMethod($this->fileEditValidator)) { + return $file->{$this->fileEditValidator}(); + } + + user_error("Invalid value for UploadField::fileEditValidator", E_USER_ERROR); + } + + /** + * Validator (eg RequiredFields) or string $name (of a method on File to provide a Validator) for the EditForm + * @example 'getCMSValidator' + * + * @param Validator|string + * @return Uploadfield Self reference + */ + public function setFileEditValidator($fileEditValidator) { + $this->fileEditValidator = $fileEditValidator; + return $this; + } + /** * @param File $file * @return string */ protected function getThumbnailURLForFile(File $file) { - if ($file && $file->exists() && file_exists(Director::baseFolder() . '/' . $file->getFilename())) { + if ($file->exists() && file_exists(Director::baseFolder() . '/' . $file->getFilename())) { + $width = $this->getPreviewMaxWidth(); + $height = $this->getPreviewMaxHeight(); if ($file->hasMethod('getThumbnail')) { - return $file->getThumbnail($this->getConfig('previewMaxWidth'), - $this->getConfig('previewMaxHeight'))->getURL(); + return $file->getThumbnail($width, $height)->getURL(); } elseif ($file->hasMethod('getThumbnailURL')) { - return $file->getThumbnailURL($this->getConfig('previewMaxWidth'), - $this->getConfig('previewMaxHeight')); + return $file->getThumbnailURL($width, $height); } elseif ($file->hasMethod('SetRatioSize')) { - return $file->SetRatioSize($this->getConfig('previewMaxWidth'), - $this->getConfig('previewMaxHeight'))->getURL(); + return $file->SetRatioSize($width, $height)->getURL(); } else { return $file->Icon(); } @@ -355,18 +924,6 @@ class UploadField extends FileField { } public function Field($properties = array()) { - $record = $this->getRecord(); - $name = $this->getName(); - - // if there is a has_one relation with that name on the record and - // allowedMaxFileNumber has not been set, it's wanted to be 1 - if( - $record && $record->exists() - && $record->has_one($name) && !$this->getConfig('allowedMaxFileNumber') - ) { - $this->setConfig('allowedMaxFileNumber', 1); - } - Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js'); Requirements::javascript(THIRDPARTY_DIR . '/jquery-ui/jquery-ui.js'); Requirements::javascript(THIRDPARTY_DIR . '/jquery-entwine/dist/jquery.entwine-dist.js'); @@ -374,7 +931,8 @@ class UploadField extends FileField { Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/ssui.core.js'); Requirements::combine_files('uploadfield.js', array( - THIRDPARTY_DIR . '/javascript-templates/tmpl.js', + // @todo jquery templates is a project no longer maintained and should be retired at some point. + THIRDPARTY_DIR . '/javascript-templates/tmpl.js', THIRDPARTY_DIR . '/javascript-loadimage/load-image.js', THIRDPARTY_DIR . '/jquery-fileupload/jquery.iframe-transport.js', THIRDPARTY_DIR . '/jquery-fileupload/cors/jquery.xdr-transport.js', @@ -387,69 +945,120 @@ class UploadField extends FileField { Requirements::css(THIRDPARTY_DIR . '/jquery-ui-themes/smoothness/jquery-ui.css'); // TODO hmmm, remove it? Requirements::css(FRAMEWORK_DIR . '/css/UploadField.css'); + // Calculated config as per jquery.fileupload-ui.js + $allowedMaxFileNumber = $this->getAllowedMaxFileNumber(); $config = array( 'url' => $this->Link('upload'), 'urlSelectDialog' => $this->Link('select'), 'urlAttach' => $this->Link('attach'), + 'urlFileExists' => $this->link('fileexists'), 'acceptFileTypes' => '.+$', - 'maxNumberOfFiles' => $this->getConfig('allowedMaxFileNumber') + // Fileupload treats maxNumberOfFiles as the max number of _additional_ items allowed + 'maxNumberOfFiles' => $allowedMaxFileNumber ? ($allowedMaxFileNumber - count($this->getItemIDs())) : null ); - if (count($this->getValidator()->getAllowedExtensions())) { - $allowedExtensions = $this->getValidator()->getAllowedExtensions(); + + // Validation: File extensions + if ($allowedExtensions = $this->getAllowedExtensions()) { $config['acceptFileTypes'] = '(\.|\/)(' . implode('|', $allowedExtensions) . ')$'; $config['errorMessages']['acceptFileTypes'] = _t( 'File.INVALIDEXTENSIONSHORT', 'Extension is not allowed' ); } - if ($this->getValidator()->getAllowedMaxFileSize()) { - $config['maxFileSize'] = $this->getValidator()->getAllowedMaxFileSize(); + + // Validation: File size + if ($allowedMaxFileSize = $this->getValidator()->getAllowedMaxFileSize()) { + $config['maxFileSize'] = $allowedMaxFileSize; $config['errorMessages']['maxFileSize'] = _t( 'File.TOOLARGESHORT', 'Filesize exceeds {size}', array('size' => File::format_size($config['maxFileSize'])) ); } - if ($config['maxNumberOfFiles'] > 1) { - $config['errorMessages']['maxNumberOfFiles'] = _t( - 'UploadField.MAXNUMBEROFFILESSHORT', - 'Can only upload {count} files', - array('count' => $config['maxNumberOfFiles']) - ); - } - $configOverwrite = array(); - if (is_numeric($config['maxNumberOfFiles']) && $this->getItems()->count()) { - $configOverwrite['maxNumberOfFiles'] = $config['maxNumberOfFiles'] - $this->getItems()->count(); + + // Validation: Number of files + if ($allowedMaxFileNumber) { + if($allowedMaxFileNumber > 1) { + $config['errorMessages']['maxNumberOfFiles'] = _t( + 'UploadField.MAXNUMBEROFFILESSHORT', + 'Can only upload {count} files', + array('count' => $allowedMaxFileNumber) + ); + } else { + $config['errorMessages']['maxNumberOfFiles'] = _t( + 'UploadField.MAXNUMBEROFFILESONE', + 'Can only upload one file' + ); + } } //get all the existing files in the current folder - if ($this->getConfig('overwriteWarning')) { - $folder = Folder::find_or_make($this->getFolderName()); - $files = glob( $folder->getFullPath() . '/*' ); - $config['existingFiles'] = array_map("basename", $files);; - + if ($this->getOverwriteWarning()) { //add overwrite warning error message to the config object sent to Javascript $config['errorMessages']['overwriteWarning'] = - _t('UploadField.OVERWRITEWARNING','File with the same name already exists'); + _t('UploadField.OVERWRITEWARNING', 'File with the same name already exists'); } - $config = array_merge($config, $this->ufConfig, $configOverwrite); - + $mergedConfig = array_merge($config, $this->ufConfig); return $this->customise(array( - 'configString' => str_replace('"', "'", Convert::raw2json($config)), - 'config' => new ArrayData($config), - 'multiple' => $config['maxNumberOfFiles'] !== 1, - 'displayInput' => (!isset($configOverwrite['maxNumberOfFiles']) || $configOverwrite['maxNumberOfFiles']) + 'configString' => str_replace('"', "'", Convert::raw2json($mergedConfig)), + 'config' => new ArrayData($mergedConfig), + 'multiple' => $allowedMaxFileNumber !== 1 ))->renderWith($this->getTemplates()); } /** * Validation method for this field, called when the entire form is validated * - * @param $validator - * @return Boolean + * @param Validator $validator + * @return boolean */ public function validate($validator) { + + // @todo Test compatibility with RequiredFields + $name = $this->getName(); + $files = $this->getItems(); + + // If there are no files then quit + if($files->count() == 0) return true; + + // Check max number of files + $maxFiles = $this->getAllowedMaxFileNumber(); + if($maxFiles && ($files->count() > $maxFiles)) { + $validator->validationError( + $name, + _t( + 'UploadField.MAXNUMBEROFFILES', + 'Max number of {count} file(s) exceeded.', + array('count' => $maxFiles) + ), + "validation" + ); + return false; + } + + // Revalidate each file against nested validator + $this->upload->clearErrors(); + foreach($files as $file) { + // Generate $_FILES style file attribute array for upload validator + $tmpFile = array( + 'name' => $file->Name, + 'type' => null, // Not used for type validation + 'size' => $file->AbsoluteSize, + 'tmp_name' => null, // Should bypass is_uploaded_file check + 'error' => UPLOAD_ERR_OK, + ); + $this->upload->validate($tmpFile); + } + + // Check all errors + if($errors = $this->upload->getErrors()) { + foreach($errors as $error) { + $validator->validationError($name, $error, "validation"); + } + return false; + } + return true; } @@ -476,12 +1085,117 @@ class UploadField extends FileField { public function handleSelect(SS_HTTPRequest $request) { return UploadField_SelectHandler::create($this, $this->getFolderName()); } + + /** + * Given an array of post variables, extract all temporary file data into an array + * + * @param array $postVars Array of posted form data + * @return array List of temporary file data + */ + protected function extractUploadedFileData($postVars) { + + // Note: Format of posted file parameters in php is a feature of using + // for multiple file uploads + $tmpFiles = array(); + if( !empty($postVars['tmp_name']) + && is_array($postVars['tmp_name']) + && !empty($postVars['tmp_name']['Uploads']) + ) { + for($i = 0; $i < count($postVars['tmp_name']['Uploads']); $i++) { + // Skip if "empty" file + if(empty($postVars['tmp_name']['Uploads'][$i])) continue; + $tmpFile = array(); + foreach(array('name', 'type', 'tmp_name', 'error', 'size') as $field) { + $tmpFile[$field] = $postVars[$field]['Uploads'][$i]; + } + $tmpFiles[] = $tmpFile; + } + } elseif(!empty($postVars['tmp_name'])) { + // Fallback to allow single file uploads (method used by AssetUploadField) + $tmpFiles[] = $postVars; + } + + return $tmpFiles; + } + + /** + * Loads the temporary file data into a File object + * + * @param array $tmpFile Temporary file data + * @param string $error Error message + * @return File File object, or null if error + */ + protected function saveTemporaryFile($tmpFile, &$error = null) { + + // Determine container object + $error = null; + $fileObject = null; + + if (empty($tmpFile)) { + $error = _t('UploadField.FIELDNOTSET', 'File information not found'); + return null; + } + + if($tmpFile['error']) { + $error = $tmpFile['error']; + return null; + } + + // Search for relations that can hold the uploaded files, but don't fallback + // to default if there is no automatic relation + if ($relationClass = $this->getRelationAutosetClass(null)) { + // Create new object explicitly. Otherwise rely on Upload::load to choose the class. + $fileObject = Object::create($relationClass); + } + + // Get the uploaded file into a new file object. + try { + $this->upload->loadIntoFile($tmpFile, $fileObject, $this->getFolderName()); + } catch (Exception $e) { + // we shouldn't get an error here, but just in case + $error = $e->getMessage(); + return null; + } + + // Check if upload field has an error + if ($this->upload->isError()) { + $error = implode(' ' . PHP_EOL, $this->upload->getErrors()); + return null; + } + + // return file + return $this->upload->getFile(); + } + + /** + * Safely encodes the File object with all standard fields required + * by the front end + * + * @param File $file + * @return array Array encoded list of file attributes + */ + protected function encodeFileAttributes(File $file) { + + // Collect all output data. + $file = $this->customiseFile($file); + return array( + 'id' => $file->ID, + 'name' => $file->Name, + 'url' => $file->URL, + 'thumbnail_url' => $file->UploadFieldThumbnailURL, + 'edit_url' => $file->UploadFieldEditLink, + 'size' => $file->AbsoluteSize, + 'type' => $file->FileType, + 'buttons' => $file->UploadFieldFileButtons, + 'fieldname' => $this->getName() + ); + } /** * Action to handle upload of a single file * * @param SS_HTTPRequest $request - * @return string json + * @return SS_HTTPResponse */ public function upload(SS_HTTPRequest $request) { if($this->isDisabled() || $this->isReadonly() || !$this->canUpload()) { @@ -491,148 +1205,75 @@ class UploadField extends FileField { // Protect against CSRF on destructive action $token = $this->getForm()->getSecurityToken(); if(!$token->checkRequest($request)) return $this->httpError(400); - - $name = $this->getName(); - $tmpfile = $request->postVar($name); - $record = $this->getRecord(); - // Check if the file has been uploaded into the temporary storage. - if (!$tmpfile) { - $return = array('error' => _t('UploadField.FIELDNOTSET', 'File information not found')); + // Get form details + $name = $this->getName(); + $postVars = $request->postVar($name); + + // Save the temporary file into a File object + $uploadedFiles = $this->extractUploadedFileData($postVars); + $firstFile = reset($uploadedFiles); + $file = $this->saveTemporaryFile($firstFile, $error); + if(empty($file)) { + $return = array('error' => $error); } else { - $return = array( - 'name' => $tmpfile['name'], - 'size' => $tmpfile['size'], - 'type' => $tmpfile['type'], - 'error' => $tmpfile['error'] - ); - } - - // Check for constraints on the record to which the file will be attached. - if (!$return['error'] && $this->relationAutoSetting && $record && $record->exists()) { - $tooManyFiles = false; - // Some relationships allow many files to be attached. - if ($this->getConfig('allowedMaxFileNumber') && ($record->has_many($name) || $record->many_many($name))) { - if(!$record->isInDB()) $record->write(); - $tooManyFiles = $record->{$name}()->count() >= $this->getConfig('allowedMaxFileNumber'); - // has_one only allows one file at any given time. - } elseif($record->has_one($name)) { - // If we're allowed to replace an existing file, clear out the old one - if($record->$name && $this->getConfig('replaceExistingFile')) { - $record->$name = null; - } - $tooManyFiles = $record->{$name}() && $record->{$name}()->exists(); - } - - // Report the constraint violation. - if ($tooManyFiles) { - if(!$this->getConfig('allowedMaxFileNumber')) $this->setConfig('allowedMaxFileNumber', 1); - $return['error'] = _t( - 'UploadField.MAXNUMBEROFFILES', - 'Max number of {count} file(s) exceeded.', - array('count' => $this->getConfig('allowedMaxFileNumber')) - ); - } - } - - // Process the uploaded file - if (!$return['error']) { - $fileObject = null; - - if ($this->relationAutoSetting) { - // Search for relations that can hold the uploaded files. - if ($relationClass = $this->getRelationAutosetClass()) { - // Create new object explicitly. Otherwise rely on Upload::load to choose the class. - $fileObject = Object::create($relationClass); - } - } - - // Get the uploaded file into a new file object. - try { - $this->upload->loadIntoFile($tmpfile, $fileObject, $this->getFolderName()); - } catch (Exception $e) { - // we shouldn't get an error here, but just in case - $return['error'] = $e->getMessage(); - } - - if (!$return['error']) { - if ($this->upload->isError()) { - $return['error'] = implode(' '.PHP_EOL, $this->upload->getErrors()); - } else { - $file = $this->upload->getFile(); - - // Attach the file to the related record. - if ($this->relationAutoSetting) { - $this->attachFile($file); - } - - // Collect all output data. - $file = $this->customiseFile($file); - $return = array_merge($return, array( - 'id' => $file->ID, - 'name' => $file->getTitle() . '.' . $file->getExtension(), - 'url' => $file->getURL(), - 'thumbnail_url' => $file->UploadFieldThumbnailURL, - 'edit_url' => $file->UploadFieldEditLink, - 'size' => $file->getAbsoluteSize(), - 'buttons' => $file->UploadFieldFileButtons - )); - } - } + $return = $this->encodeFileAttributes($file); } + + // Format response with json $response = new SS_HTTPResponse(Convert::raw2json(array($return))); $response->addHeader('Content-Type', 'text/plain'); return $response; } /** - * Add existing {@link File} records to the relationship. + * Retrieves details for files that this field wishes to attache to the + * client-side form + * + * @param SS_HTTPRequest $request + * @return SS_HTTPResponse */ - public function attach($request) { + public function attach(SS_HTTPRequest $request) { if(!$request->isPOST()) return $this->httpError(403); - if(!$this->managesRelation()) return $this->httpError(403); if(!$this->canAttachExisting()) return $this->httpError(403); - + + // Retrieve file attributes required by front end $return = array(); - $files = File::get()->byIDs($request->postVar('ids')); foreach($files as $file) { - $this->attachFile($file); - $file = $this->customiseFile($file); - $return[] = array( - 'id' => $file->ID, - 'name' => $file->getTitle() . '.' . $file->getExtension(), - 'url' => $file->getURL(), - 'thumbnail_url' => $file->UploadFieldThumbnailURL, - 'edit_url' => $file->UploadFieldEditLink, - 'size' => $file->getAbsoluteSize(), - 'buttons' => $file->UploadFieldFileButtons - ); + $return[] = $this->encodeFileAttributes($file); } $response = new SS_HTTPResponse(Convert::raw2json($return)); $response->addHeader('Content-Type', 'application/json'); return $response; } - + /** - * @param File + * Determines if a specified file exists + * + * @param SS_HTTPRequest $request */ - protected function attachFile($file) { - $replaceFileID = $this->getRequest()->requestVar('ReplaceFileID'); - $record = $this->getRecord(); - $name = $this->getName(); - if ($record && $record->exists()) { - if (($record->has_many($name) || $record->many_many($name))) { - if(!$record->isInDB()) $record->write(); - if ($replaceFileID){ - $record->{$name}()->removebyId($replaceFileID); - } - $record->{$name}()->add($file); - } elseif($record->has_one($name)) { - $record->{$name . 'ID'} = $file->ID; - $record->write(); + public function fileexists(SS_HTTPRequest $request) { + + // Check both original and safely filtered filename + $originalFile = $request->requestVar('filename'); + $nameFilter = FileNameFilter::create(); + $filteredFile = basename($nameFilter->filter($originalFile)); + + // check if either file exists + $folder = $this->getFolderName(); + $exists = false; + foreach(array($originalFile, $filteredFile) as $file) { + if(file_exists(ASSETS_PATH."/$folder/$file")) { + $exists = true; + break; } } + + // Encode and present response + $response = new SS_HTTPResponse(Convert::raw2json(array('exists' => $exists))); + $response->addHeader('Content-Type', 'application/json'); + return $response; } public function performReadonlyTransformation() { @@ -643,55 +1284,26 @@ class UploadField extends FileField { } /** - * Determines if the underlying record (if any) has a relationship - * matching the field name. Important for permission control. - * - * @return boolean - */ - public function managesRelation() { - $record = $this->getRecord(); - $fieldName = $this->getName(); - return ( - $record - && ($record->has_one($fieldName) || $record->has_many($fieldName) || $record->many_many($fieldName)) - ); - } - - /** - * Gets the foreign class that needs to be created. + * Gets the foreign class that needs to be created, or 'File' as default if there + * is no relationship, or it cannot be determined. * + * @param $default Default value to return if no value could be calculated * @return string Foreign class name. */ - public function getRelationAutosetClass() { + public function getRelationAutosetClass($default = 'File') { + + // Don't autodetermine relation if no relationship between parent record + if(!$this->relationAutoSetting) return $default; + + // Check record and name $name = $this->getName(); $record = $this->getRecord(); - - if (isset($name) && isset($record)) return $record->getRelationClass($name); - } - - public function isDisabled() { - return (parent::isDisabled() || !$this->isSaveable()); - } - - /** - * Determines if the field can be saved into a database record. - * - * @return boolean - */ - public function isSaveable() { - $record = $this->getRecord(); - // Don't allow upload or edit of a relation when the underlying record hasn't been persisted yet - return (!$record || !$this->managesRelation() || $record->exists()); - } - - public function canUpload() { - $can = $this->getConfig('canUpload'); - return (is_bool($can)) ? $can : Permission::check($can); - } - - public function canAttachExisting() { - $can = $this->getConfig('canAttachExisting'); - return (is_bool($can)) ? $can : Permission::check($can); + if(empty($name) || empty($record)) { + return $default; + } else { + $class = $record->getRelationClass($name); + return empty($class) ? $default : $class; + } } } @@ -746,14 +1358,6 @@ class UploadField_ItemHandler extends RequestHandler { return Controller::join_links($this->parent->Link(), '/item/', $this->itemID, $action); } - /** - * @return string - */ - public function RemoveLink() { - $token = $this->parent->getForm()->getSecurityToken(); - return $token->addToUrl($this->Link('remove')); - } - /** * @return string */ @@ -769,42 +1373,6 @@ class UploadField_ItemHandler extends RequestHandler { return $this->Link('edit'); } - /** - * Action to handle removing a single file from the db relation - * - * @param SS_HTTPRequest $request - * @return SS_HTTPResponse - */ - public function remove(SS_HTTPRequest $request) { - // Check form field state - if($this->parent->isDisabled() || $this->parent->isReadonly()) return $this->httpError(403); - - // Protect against CSRF on destructive action - $token = $this->parent->getForm()->getSecurityToken(); - if(!$token->checkRequest($request)) return $this->httpError(400); - - $response = new SS_HTTPResponse(); - $response->setStatusCode(500); - $fieldName = $this->parent->getName(); - $record = $this->parent->getRecord(); - $id = $this->getItem()->ID; - if ($id && $record && $record->exists()) { - if (($record->has_many($fieldName) || $record->many_many($fieldName)) - && $file = $record->{$fieldName}()->byID($id)) { - - $record->{$fieldName}()->remove($file); - $response->setStatusCode(200); - } elseif($record->has_one($fieldName) && $record->{$fieldName . 'ID'} == $id) { - $record->{$fieldName . 'ID'} = 0; - $record->write(); - $response->setStatusCode(200); - } - } - if ($response->getStatusCode() != 200) - $response->setStatusDescription(_t('UploadField.REMOVEERROR', 'Error removing file')); - return $response; - } - /** * Action to handle deleting of a single file * @@ -824,14 +1392,9 @@ class UploadField_ItemHandler extends RequestHandler { if(!$item) return $this->httpError(404); if(!$item->canDelete()) return $this->httpError(403); - // Only allow actions on files in the managed relation (if one exists) - $items = $this->parent->getItems(); - if($this->parent->managesRelation() && !$items->byID($item->ID)) return $this->httpError(403); - - // First remove the file from the current relationship - $this->remove($request); - - // Then delete the file from the filesystem + // Delete the file from the filesystem. The file will be removed + // from the relation on save + // @todo Investigate if references to deleted files (if unsaved) is dangerous $item->delete(); } @@ -850,10 +1413,6 @@ class UploadField_ItemHandler extends RequestHandler { if(!$item) return $this->httpError(404); if(!$item->canEdit()) return $this->httpError(403); - // Only allow actions on files in the managed relation (if one exists) - $items = $this->parent->getItems(); - if($this->parent->managesRelation() && !$items->byID($item->ID)) return $this->httpError(403); - Requirements::css(FRAMEWORK_DIR . '/css/UploadField.css'); return $this->customise(array( @@ -866,30 +1425,10 @@ class UploadField_ItemHandler extends RequestHandler { */ public function EditForm() { $file = $this->getItem(); - if (is_a($this->parent->getConfig('fileEditFields'), 'FieldList')) { - $fields = $this->parent->getConfig('fileEditFields'); - } elseif ($file->hasMethod($this->parent->getConfig('fileEditFields'))) { - $fields = $file->{$this->parent->getConfig('fileEditFields')}(); - } else { - $fields = $file->getCMSFields(); - // Only display main tab, to avoid overly complex interface - if($fields->hasTabSet() && $mainTab = $fields->findOrMakeTab('Root.Main')) $fields = $mainTab->Fields(); - } - if (is_a($this->parent->getConfig('fileEditActions'), 'FieldList')) { - $actions = $this->parent->getConfig('fileEditActions'); - } elseif ($file->hasMethod($this->parent->getConfig('fileEditActions'))) { - $actions = $file->{$this->parent->getConfig('fileEditActions')}(); - } else { - $actions = new FieldList($saveAction = new FormAction('doEdit', _t('UploadField.DOEDIT', 'Save'))); - $saveAction->addExtraClass('ss-ui-action-constructive icon-accept'); - } - if (is_a($this->parent->getConfig('fileEditValidator'), 'Validator')) { - $validator = $this->parent->getConfig('fileEditValidator'); - } elseif ($file->hasMethod($this->parent->getConfig('fileEditValidator'))) { - $validator = $file->{$this->parent->getConfig('fileEditValidator')}(); - } else { - $validator = null; - } + // Get form components + $fields = $this->parent->getFileEditFields($file); + $actions = $this->parent->getFileEditActions($file); + $validator = $this->parent->getFileEditValidator($file); $form = new Form( $this, __FUNCTION__, @@ -916,10 +1455,6 @@ class UploadField_ItemHandler extends RequestHandler { $item = $this->getItem(); if(!$item) return $this->httpError(404); - // Only allow actions on files in the managed relation (if one exists) - $items = $this->parent->getItems(); - if($this->parent->managesRelation() && !$items->byID($item->ID)) return $this->httpError(403); - $form->saveInto($item); $item->write(); @@ -941,7 +1476,7 @@ class UploadField_SelectHandler extends RequestHandler { protected $parent; /** - * @var String + * @var string */ protected $folderName; @@ -981,7 +1516,7 @@ class UploadField_SelectHandler extends RequestHandler { $folderID = $this->parent->getRequest()->requestVar('ParentID'); if (!isset($folderID)) { $folder = Folder::find_or_make($this->folderName); - $folderID = $folder->ID; + $folderID = $folder ? $folder->ID : 0; } // Construct the form @@ -1009,8 +1544,6 @@ class UploadField_SelectHandler extends RequestHandler { $folderField = new TreeDropdownField('ParentID', _t('HtmlEditorField.FOLDER', 'Folder'), 'Folder'); $folderField->setValue($folderID); - - // Generate the file list field. $config = GridFieldConfig::create(); $config->addComponent(new GridFieldSortableHeader()); @@ -1019,36 +1552,27 @@ class UploadField_SelectHandler extends RequestHandler { $config->addComponent(new GridFieldPaginator(10)); // If relation is to be autoset, we need to make sure we only list compatible objects. - $baseClass = null; - if ($this->parent->relationAutoSetting) { - $baseClass = $this->parent->getRelationAutosetClass(); - } - - // By default we can attach anything that is a file, or derives from file. - if (!$baseClass) $baseClass = 'File'; + $baseClass = $this->parent->getRelationAutosetClass(); // Create the data source for the list of files within the current directory. $files = DataList::create($baseClass)->filter('ParentID', $folderID); $fileField = new GridField('Files', false, $files, $config); $fileField->setAttribute('data-selectable', true); - if($this->parent->getConfig('allowedMaxFileNumber') > 1) $fileField->setAttribute('data-multiselect', true); + if($this->parent->getAllowedMaxFileNumber() !== 1) { + $fileField->setAttribute('data-multiselect', true); + } $selectComposite = new CompositeField( $folderField, $fileField ); - - //Existing file to replace - if ($replaceFileID = $this->parent->getRequest()->requestVar('ReplaceFileID')) { - $selectComposite->push(new HiddenField('ReplaceFileID','ReplaceFileID', $replaceFileID)); - } return $selectComposite; } public function doAttach($data, $form) { - // TODO Only implemented via JS for now + // Popup-window attach does not require server side action, as it is implemented via JS } } diff --git a/javascript/UploadField.js b/javascript/UploadField.js index 555d60edb..92362c3a6 100644 --- a/javascript/UploadField.js +++ b/javascript/UploadField.js @@ -1,12 +1,12 @@ (function($) { $.widget('blueimpUIX.fileupload', $.blueimpUI.fileupload, { _initTemplates: function() { - this.options.templateContainer = document.createElement( - this._files.prop('nodeName') - ); - this.options.uploadTemplate = window.tmpl(this.options.uploadTemplateName); - this.options.downloadTemplate = window.tmpl(this.options.downloadTemplateName); - }, + this.options.templateContainer = document.createElement( + this._files.prop('nodeName') + ); + this.options.uploadTemplate = window.tmpl(this.options.uploadTemplateName); + this.options.downloadTemplate = window.tmpl(this.options.downloadTemplateName); + }, _enableFileInputButton: function() { $.blueimpUI.fileupload.prototype._enableFileInputButton.call(this); this.element.find('.ss-uploadfield-addfile').show(); @@ -51,33 +51,33 @@ _onSend: function (e, data) { //check the array of existing files to see if we are trying to upload a file that already exists var that = this; - var config = $('div.ss-upload').entwine('ss').getConfig(); - var existingFiles = []; - if (typeof (config.existingFiles) !== "undefined") { - existingFiles = config.existingFiles; - } - - var fileExists = false; - jQuery.each(existingFiles,function(){ - if ($(this)[0].toLowerCase() === data.files[0].name.toLowerCase()) { - fileExists = true; - return; - } - }); - - if (fileExists) { - //display the dialogs with the question to overwrite or not - data.context.find('.ss-uploadfield-item-status').text(config.errorMessages.overwriteWarning).css('max-width','75%'); - data.context.find('.ss-uploadfield-item-progress').hide(); - data.context.find('.ss-uploadfield-item-overwrite').show(); - data.context.find('.ss-uploadfield-item-overwrite-warning').on('click', function(){ - data.context.find('.ss-uploadfield-item-progress').show(); - data.context.find('.ss-uploadfield-item-overwrite').hide(); - - //upload only if the "overwrite" button is clicked - $.blueimpUI.fileupload.prototype._onSend.call(that, e, data); - }); - } else { //regular file upload + var config = this.options; + if (config.overwriteWarning) { + $.get( + config['urlFileExists'], + {'filename': data.files[0].name}, + function(response, status, xhr) { + if(response.exists) { + //display the dialogs with the question to overwrite or not + data.context.find('.ss-uploadfield-item-status') + .text(config.errorMessages.overwriteWarning) + .addClass('ui-state-warning-text'); + data.context.find('.ss-uploadfield-item-progress').hide(); + data.context.find('.ss-uploadfield-item-overwrite').show(); + data.context.find('.ss-uploadfield-item-overwrite-warning').on('click', function(){ + data.context.find('.ss-uploadfield-item-progress').show(); + data.context.find('.ss-uploadfield-item-overwrite').hide(); + data.context.find('.ss-uploadfield-item-status') + .removeClass('ui-state-warning-text'); + //upload only if the "overwrite" button is clicked + $.blueimpUI.fileupload.prototype._onSend.call(that, e, data); + }); + } else { //regular file upload + return $.blueimpUI.fileupload.prototype._onSend.call(that, e, data); + } + } + ); + } else { return $.blueimpUI.fileupload.prototype._onSend.call(that, e, data); } }, @@ -88,7 +88,53 @@ $('.ss-uploadfield-item-edit-all').show(); $('.fileOverview .uploadStatus').addClass("good").removeClass("notice").removeClass("bad"); } - } + }, + _create: function() { + $.blueimpUI.fileupload.prototype._create.call(this); + // Ensures that the visibility of the fileupload dialog is set correctly at initialisation + this._adjustMaxNumberOfFiles(0); + }, + attach: function(data) { + + // Handles attachment of already uploaded files, similar to add + var self = this, + files = data.files, + replaceFileID = data.replaceFileID, + valid = true; + + // If replacing an element (and it exists), adjust max number of files at this point + var replacedElement = null; + if(replaceFileID) { + replacedElement = $(".ss-uploadfield-item[data-fileid='"+replaceFileID+"']"); + if(replacedElement.length === 0) { + replacedElement = null; + } else { + self._adjustMaxNumberOfFiles(1); + } + } + + // Validate each file + $.each(files, function (index, file) { + self._adjustMaxNumberOfFiles(-1); + error = self._validate([file]); + valid = error && valid; + }); + data.isAdjusted = true; + data.files.valid = data.isValidated = valid; + + // Generate new file HTMl, and either append or replace (if replacing + // an already uploaded file). + data.context = this._renderDownload(files); + if(replacedElement) { + replacedElement.replaceWith(data.context); + } else { + data.context.appendTo(this._files); + } + data.context.data('data', data); + // Force reflow: + this._reflow = this._transition && data.context[0].offsetWidth; + data.context.addClass('in'); + } }); @@ -102,7 +148,7 @@ if(this.is('.readonly,.disabled')) return; - var fileInput = this.find('input'); + var fileInput = this.find('input[type=file]'); var dropZone = this.find('.ss-uploadfield-dropzone'); var config = $.parseJSON(fileInput.data('config').replace(/'/g,'"')); @@ -148,7 +194,7 @@ ]; }, errorMessages: { - // errorMessages for all error codes suggested from the plugin author, some will be overwritten by the config comming from php + // errorMessages for all error codes suggested from the plugin author, some will be overwritten by the config coming from php 1: ss.i18n._t('UploadField.PHP_MAXFILESIZE'), 2: ss.i18n._t('UploadField.HTML_MAXFILESIZE'), 3: ss.i18n._t('UploadField.ONLYPARTIALUPLOADED'), @@ -212,7 +258,6 @@ var uploadedFileId = null; if (uploadedFile && uploadedFile.attr('data-fileid') > 0){ uploadedFileId = uploadedFile.attr('data-fileid'); - iframeUrl = iframeUrl + '?ReplaceFileID=' + uploadedFileId; } // Show dialog @@ -244,23 +289,13 @@ var self = this, config = this.getConfig(); $.post( config['urlAttach'], - {'ids': ids, 'ReplaceFileID': uploadedFileId}, + {'ids': ids}, function(data, status, xhr) { - var fn = self.fileupload('option', 'downloadTemplate'); - var container = self.find('.ss-uploadfield-files'); - if (config['allowedMaxFileNumber'] == 1){ - container.empty(); - } - container.append(fn({ + self.fileupload('attach', { files: data, - formatFileSize: function (bytes) { - if (typeof bytes !== 'number') return ''; - if (bytes >= 1000000000) return (bytes / 1000000000).toFixed(2) + ' GB'; - if (bytes >= 1000000) return (bytes / 1000000).toFixed(2) + ' MB'; - return (bytes / 1000).toFixed(2) + ' KB'; - }, - options: self.fileupload('option') - })); + options: self.fileupload('option'), + replaceFileID: uploadedFileId + }); } ); } @@ -300,14 +335,18 @@ var fileupload = this.closest('div.ss-upload').data('fileupload'), item = this.closest('.ss-uploadfield-item'), msg = ''; - if(this.is('.ss-uploadfield-item-delete')) msg = ss.i18n._t('UploadField.ConfirmDelete'); - if(!msg || confirm(msg)) { - fileupload._trigger('destroy', e, { - context: item, - url: this.data('href'), - type: 'get', - dataType: fileupload.options.dataType - }); + if(this.is('.ss-uploadfield-item-delete')) { + if(confirm(ss.i18n._t('UploadField.ConfirmDelete'))) { + fileupload._trigger('destroy', e, { + context: item, + url: this.data('href'), + type: 'get', + dataType: fileupload.options.dataType + }); + } + } else { + // Removed files will be applied to object on save + fileupload._trigger('destroy', e, {context: item}); } return false; @@ -334,7 +373,7 @@ e.preventDefault(); // Avoid a form submit } }); - $('div.ss-upload .ss-uploadfield-item-edit, div.ss-upload .ss-uploadfield-item-name').entwine({ + $( 'div.ss-upload:not(.disabled):not(.readonly) .ss-uploadfield-item-edit').entwine({ onclick: function(e) { var editform = this.closest('.ss-uploadfield-item').find('.ss-uploadfield-item-editform'); var disabled; diff --git a/javascript/UploadField_downloadtemplate.js b/javascript/UploadField_downloadtemplate.js index f145f1288..46d9b27dc 100644 --- a/javascript/UploadField_downloadtemplate.js +++ b/javascript/UploadField_downloadtemplate.js @@ -5,8 +5,12 @@ window.tmpl.cache['ss-uploadfield-downloadtemplate'] = tmpl( '' + '' + '
    ' + + '{% if (!file.error) { %}' + + '' + + '{% } %}' + '' + '{% if (file.error) { %}' + '
    ' + - '
    ' + + '
    ' + '
    ' + '{% } else { %}' + '
    {% print(file.buttons, true); %}
    ' + @@ -27,4 +31,4 @@ window.tmpl.cache['ss-uploadfield-downloadtemplate'] = tmpl( '{% } %}' + '
  • ' + '{% } %}' -); \ No newline at end of file +); diff --git a/javascript/UploadField_uploadtemplate.js b/javascript/UploadField_uploadtemplate.js index 821850416..3df123759 100644 --- a/javascript/UploadField_uploadtemplate.js +++ b/javascript/UploadField_uploadtemplate.js @@ -20,11 +20,14 @@ window.tmpl.cache['ss-uploadfield-uploadtemplate'] = tmpl( '
    ' + '{% } %}' + '{% } %}' + - '
    ' + + '
    ' + + '' + + '
    ' + '
    '+ - '' + + '
    ' + '' + '' + '' + '{% } %}' -); \ No newline at end of file +); diff --git a/lang/en.yml b/lang/en.yml index 40a64be71..4e89a6d7b 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -567,6 +567,7 @@ en: HOTLINKINFO: 'Info: This image will be hotlinked. Please ensure you have permissions from the original site creator to do so.' MAXNUMBEROFFILES: 'Max number of {count} file(s) exceeded.' MAXNUMBEROFFILESSHORT: 'Can only upload {count} files' + MAXNUMBEROFFILESONE: 'Can only upload one file' REMOVE: Remove REMOVEERROR: 'Error removing file' REMOVEINFO: 'Remove this file from here, but do not delete it from the file store' diff --git a/lang/en_GB.yml b/lang/en_GB.yml index fd6148ef3..223d83afa 100644 --- a/lang/en_GB.yml +++ b/lang/en_GB.yml @@ -566,6 +566,7 @@ en_GB: HOTLINKINFO: 'Info: This image will be hotlinked. Please ensure you have permissions from the original site creator to do so.' MAXNUMBEROFFILES: 'Max number of {count} file(s) exceeded.' MAXNUMBEROFFILESSHORT: 'Can only upload {count} files' + MAXNUMBEROFFILESONE: 'Can only upload one file' REMOVE: Remove REMOVEERROR: 'Error removing file' REMOVEINFO: 'Remove this file from here, but do not delete it from the file store' diff --git a/scss/UploadField.scss b/scss/UploadField.scss index 5966522b0..89ef603e1 100644 --- a/scss/UploadField.scss +++ b/scss/UploadField.scss @@ -56,39 +56,43 @@ } } .ss-uploadfield-item-info { - float: left; - margin-left: 15px; + margin-left: 95px; .ss-uploadfield-item-name { display: block; line-height: 13px; height: 26px; margin: 0; - text-align: left; - b { - font-weight: bold; - padding: 0 5px 0 0; - } + text-align: left; .name { - font-size: $font-base-size - 1; - color: lighten($color-text, 25%); - width:290px; //Ensures the title doesn't interfer with the status message + max-width: 240px; + font-weight: bold; @include hide-text-overflow; - display:inline; + display:inline; + float:left; + } + .size { + color: lighten($color-text, 25%); + padding: 0 0 0 5px; + display:inline; float:left; } .ss-uploadfield-item-status { float: right; padding: 0 0 0 5px; - width:100px; //Allocates the status message enough room to be useful. Will wrap if it is longer - text-align:right; + text-align:right; + max-width: 75%; + &.ui-state-error-text { color: $color-button-destructive; font-weight: bold; + width:150px; //Allocates the status message enough room to be useful. Will wrap if it is longer } + &.ui-state-warning-text { color: darken($color-warning, 10%); } + &.ui-state-success-text { color: $color-button-constructive; } @@ -99,7 +103,7 @@ .ss-ui-button { display: block; float: left; - margin: 0 10px 0 0; + margin: 0 10px 6px 0; &.ss-uploadfield-fromcomputer { position: relative; @@ -124,8 +128,9 @@ } } .ss-uploadfield-item-actions { - height: 28px; - margin: 6px 0 0; + min-height: 28px; + overflow: hidden; + margin: 6px 0 -6px 0; position: relative; } .ss-uploadfield-item-progress { @@ -171,7 +176,6 @@ height: 16px; cursor: pointer; @include single-box-shadow(none); - background: none; position: relative; // background: sprite($sprites16, cross-circle) no-repeat; diff --git a/templates/Includes/UploadField_FileButtons.ss b/templates/Includes/UploadField_FileButtons.ss index c8380f91f..1f0e920c7 100644 --- a/templates/Includes/UploadField_FileButtons.ss +++ b/templates/Includes/UploadField_FileButtons.ss @@ -6,15 +6,12 @@ <% end_if %> -<% if UploadFieldHasRelation %> - -<% end_if %> + <% if canDelete %> <% end_if %> -<% if UploadFieldHasRelation && UploadField.canAttachExisting %> +<% if UploadField.canAttachExisting %> <% end_if %> - diff --git a/templates/UploadField.ss b/templates/UploadField.ss index 21acf6a2d..9f9af1e87 100644 --- a/templates/UploadField.ss +++ b/templates/UploadField.ss @@ -1,19 +1,19 @@ -<% if isDisabled || isReadonly %> - <% if isSaveable %> - <% else %> -
    - <% _t('FileIFrameField.ATTACHONCESAVED2', 'Files can be attached once you have saved the record for the first time.') %> -
    - <% end_if %> -<% else %> -
    style="display: none;"<% end_if %>> +<% if canUpload || canAttachExisting %> +
    <% if canUpload %>
    <% if $multiple %> @@ -50,24 +43,24 @@ <% else %> <% _t('UploadField.ATTACHFILE', 'Attach a file') %> <% end_if %> - <% if getConfig('canPreviewFolder') %> + <% if canPreviewFolder %> (<%t UploadField.UPLOADSINTO 'saves into /{path}' path=$FolderName %>) <% end_if %> <% if canUpload %> - - <% else %> - multiple="multiple"<% end_if %> /> + <% end_if %> <% if canAttachExisting %> - + <% end_if %> - <% if not $autoUpload %> - + <% if canUpload %> + <% if not $autoUpload %> + + <% end_if %> <% end_if %>
    diff --git a/tests/forms/uploadfield/UploadFieldTest.php b/tests/forms/uploadfield/UploadFieldTest.php index 20f27ec38..927ab16fa 100644 --- a/tests/forms/uploadfield/UploadFieldTest.php +++ b/tests/forms/uploadfield/UploadFieldTest.php @@ -14,23 +14,25 @@ class UploadFieldTest extends FunctionalTest { 'File' => array('UploadFieldTest_FileExtension') ); + /** + * Test that files can be uploaded against an object with no relation + */ public function testUploadNoRelation() { $this->loginWithPermission('ADMIN'); $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $tmpFileName = 'testUploadBasic.txt'; - $_FILES = array('NoRelationField' => $this->getUploadFile($tmpFileName)); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/NoRelationField/upload', - array('NoRelationField' => $this->getUploadFile($tmpFileName)) - ); + $response = $this->mockFileUpload('NoRelationField', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); } + /** + * Test that an object can be uploaded against an object with a has_one relation + */ public function testUploadHasOneRelation() { $this->loginWithPermission('ADMIN'); @@ -39,22 +41,29 @@ class UploadFieldTest extends FunctionalTest { $record->HasOneFileID = null; $record->write(); + // Firstly, ensure the file can be uploaded $tmpFileName = 'testUploadHasOneRelation.txt'; - $_FILES = array('HasOneFile' => $this->getUploadFile($tmpFileName)); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasOneFile/upload', - array('HasOneFile' => $this->getUploadFile($tmpFileName)) - ); + $response = $this->mockFileUpload('HasOneFile', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); + // Secondly, ensure that simply uploading an object does not save the file against the relation + $record = DataObject::get_by_id($record->class, $record->ID, false); + $this->assertFalse($record->HasOneFile()->exists()); + + // Thirdly, test submitting the form with the encoded data + $response = $this->mockUploadFileIDs('HasOneFile', array($uploadedFile->ID)); + $this->assertEmpty($response['errors']); $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertTrue($record->HasOneFile()->exists()); $this->assertEquals($record->HasOneFile()->Name, $tmpFileName); } + /** + * Tests that has_one relations work with subclasses of File + */ public function testUploadHasOneRelationWithExtendedFile() { $this->loginWithPermission('ADMIN'); @@ -63,98 +72,123 @@ class UploadFieldTest extends FunctionalTest { $record->HasOneExtendedFileID = null; $record->write(); + // Test that the file can be safely uploaded $tmpFileName = 'testUploadHasOneRelationWithExtendedFile.txt'; - $_FILES = array('HasOneExtendedFile' => $this->getUploadFile($tmpFileName)); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasOneExtendedFile/upload', - array('HasOneExtendedFile' => $this->getUploadFile($tmpFileName)) - ); + $response = $this->mockFileUpload('HasOneExtendedFile', $tmpFileName); $this->assertFalse($response->isError()); - $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); $uploadedFile = DataObject::get_one('UploadFieldTest_ExtendedFile', sprintf('"Name" = \'%s\'', $tmpFileName)); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); + // Test that the record isn't written to automatically $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertTrue($record->HasOneExtendedFile()->exists(), 'The extended file is attached to the class'); - $this->assertEquals($record->HasOneExtendedFile()->Name, $tmpFileName, 'Proper file has been attached'); + $this->assertFalse($record->HasOneExtendedFile()->exists()); + + // Test that saving the form writes the record + $response = $this->mockUploadFileIDs('HasOneExtendedFile', array($uploadedFile->ID)); + $this->assertEmpty($response['errors']); + $record = DataObject::get_by_id($record->class, $record->ID, false); + $this->assertTrue($record->HasOneExtendedFile()->exists()); + $this->assertEquals($record->HasOneExtendedFile()->Name, $tmpFileName); } + + /** + * Test that has_many relations work with files + */ public function testUploadHasManyRelation() { $this->loginWithPermission('ADMIN'); $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); + // Test that uploaded files can be posted to a has_many relation $tmpFileName = 'testUploadHasManyRelation.txt'; - $_FILES = array('HasManyFiles' => $this->getUploadFile($tmpFileName)); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFiles/upload', - array('HasManyFiles' => $this->getUploadFile($tmpFileName)) - ); + $response = $this->mockFileUpload('HasManyFiles', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); + // Test that the record isn't written to automatically $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertEquals(3, $record->HasManyFiles()->Count()); - $this->assertEquals($record->HasManyFiles()->Last()->Name, $tmpFileName); + $this->assertEquals(2, $record->HasManyFiles()->Count()); // Existing two files should be retained + + // Test that saving the form writes the record + $ids = array_merge($record->HasManyFiles()->getIDList(), array($uploadedFile->ID)); + $response = $this->mockUploadFileIDs('HasManyFiles', $ids); + $this->assertEmpty($response['errors']); + $record = DataObject::get_by_id($record->class, $record->ID, false); + $this->assertEquals(3, $record->HasManyFiles()->Count()); // New record should appear here now } + /** + * Test that many_many relationships work with files + */ public function testUploadManyManyRelation() { $this->loginWithPermission('ADMIN'); $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $relationCount = $record->ManyManyFiles()->Count(); + // Test that uploaded files can be posted to a many_many relation $tmpFileName = 'testUploadManyManyRelation.txt'; - $_FILES = array('ManyManyFiles' => $this->getUploadFile($tmpFileName)); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/ManyManyFiles/upload', - array('ManyManyFiles' => $this->getUploadFile($tmpFileName)) - ); + $response = $this->mockFileUpload('ManyManyFiles', $tmpFileName); $this->assertFalse($response->isError()); $this->assertFileExists(ASSETS_PATH . "/UploadFieldTest/$tmpFileName"); $uploadedFile = DataObject::get_one('File', sprintf('"Name" = \'%s\'', $tmpFileName)); $this->assertTrue(is_object($uploadedFile), 'The file object is created'); + // Test that the record isn't written to automatically $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertEquals($relationCount+1, $record->ManyManyFiles()->Count()); - $this->assertEquals($record->ManyManyFiles()->Last()->Name, $tmpFileName); + $this->assertEquals($relationCount, $record->ManyManyFiles()->Count()); // Existing file count should be retained + + // Test that saving the form writes the record + $ids = array_merge($record->ManyManyFiles()->getIDList(), array($uploadedFile->ID)); + $response = $this->mockUploadFileIDs('ManyManyFiles', $ids); + $this->assertEmpty($response['errors']); + $record = DataObject::get_by_id($record->class, $record->ID, false); + $record->flushCache(); + $this->assertEquals($relationCount + 1, $record->ManyManyFiles()->Count()); // New record should appear here now } + /** + * Test that has_one relations do not support multiple files + */ public function testAllowedMaxFileNumberWithHasOne() { $this->loginWithPermission('ADMIN'); + // Get references for each file to upload + $file1 = $this->objFromFixture('File', 'file1'); + $file2 = $this->objFromFixture('File', 'file2'); + $fileIDs = array($file1->ID, $file2->ID); + // Test each of the three cases - has one with no max filel limit, has one with a limit of // one, has one with a limit of more than one (makes no sense, but should test it anyway). // Each of them should public function in the same way - attaching the first file should work, the // second should cause an error. foreach (array('HasOneFile', 'HasOneFileMaxOne', 'HasOneFileMaxTwo') as $recordName) { + // Unset existing has_one relation before re-uploading $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $record->{$recordName . 'ID'} = null; + $record->{"{$recordName}ID"} = null; $record->write(); - - $tmpFileName = 'testUploadHasOneRelation.txt'; - $_FILES = array($recordName => $this->getUploadFile($tmpFileName)); - $response = $this->post( - "UploadFieldTest_Controller/Form/field/$recordName/upload", - array($recordName => $this->getUploadFile($tmpFileName)) - ); - $body = json_decode($response->getBody()); - $this->assertEquals(0, $body[0]->error); - // Write to it again, should result in an error. - $response = $this->post( - "UploadFieldTest_Controller/Form/field/$recordName/upload", - array($recordName => $this->getUploadFile($tmpFileName)) - ); - $body = json_decode($response->getBody()); - $this->assertNotEquals(0, $body[0]->error); + // Post form with two files for this field, should result in an error + $response = $this->mockUploadFileIDs($recordName, $fileIDs); + $isError = !empty($response['errors']); + + // Strictly, a has_one should not allow two files, but this is overridden + // by the setAllowedMaxFileNumber(2) call + $maxFiles = ($recordName === 'HasOneFileMaxTwo') ? 2 : 1; + + // Assert that the form fails if the maximum number of files is exceeded + $this->assertTrue((count($fileIDs) > $maxFiles) == $isError); } } + /** + * Test that max number of items on has_many is validated + */ public function testAllowedMaxFileNumberWithHasMany() { $this->loginWithPermission('ADMIN'); @@ -164,100 +198,124 @@ class UploadFieldTest extends FunctionalTest { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $record->HasManyFilesMaxTwo()->removeAll(); - $tmpFileName = 'testUploadHasManyRelation.txt'; - $_FILES = array('HasManyFilesMaxTwo' => $this->getUploadFile($tmpFileName)); + // Get references for each file to upload + $file1 = $this->objFromFixture('File', 'file1'); + $file2 = $this->objFromFixture('File', 'file2'); + $file3 = $this->objFromFixture('File', 'file3'); // Write the first element, should be okay. - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFilesMaxTwo/upload', - array('HasManyFilesMaxTwo' => $this->getUploadFile($tmpFileName)) - ); - $body = json_decode($response->getBody()); - $this->assertEquals(0, $body[0]->error); - + $response = $this->mockUploadFileIDs('HasManyFilesMaxTwo', array($file1->ID)); + $this->assertEmpty($response['errors']); + // Write the second element, should be okay. - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFilesMaxTwo/upload', - array('HasManyFilesMaxTwo' => $this->getUploadFile($tmpFileName)) - ); - $body = json_decode($response->getBody()); - $this->assertEquals(0, $body[0]->error); - + $response = $this->mockUploadFileIDs('HasManyFilesMaxTwo', array($file1->ID, $file2->ID)); + $this->assertEmpty($response['errors']); + // Write the third element, should result in error. - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFilesMaxTwo/upload', - array('HasManyFilesMaxTwo' => $this->getUploadFile($tmpFileName)) - ); - $body = json_decode($response->getBody()); - $this->assertNotEquals(0, $body[0]->error); + $response = $this->mockUploadFileIDs('HasManyFilesMaxTwo', array($file1->ID, $file2->ID, $file3->ID)); + $this->assertNotEmpty($response['errors']); } + /** + * Test that files can be removed from has_one relations + */ public function testRemoveFromHasOne() { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $file1 = $this->objFromFixture('File', 'file1'); + // Check record exists $this->assertTrue($record->HasOneFile()->exists()); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasOneFile/item/' . $file1->ID . '/remove', - array() - ); - $this->assertFalse($response->isError()); + + // Remove from record + $response = $this->mockUploadFileIDs('HasOneFile', array()); + $this->assertEmpty($response['errors']); + + // Check file is removed $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertFalse($record->HasOneFile()->exists()); + + // Check file object itself exists $this->assertFileExists($file1->FullPath, 'File is only detached, not deleted from filesystem'); } + /** + * Test that items can be removed from has_many + */ public function testRemoveFromHasMany() { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $file2 = $this->objFromFixture('File', 'file2'); $file3 = $this->objFromFixture('File', 'file3'); + // Check record has two files attached $this->assertEquals(array('File2', 'File3'), $record->HasManyFiles()->column('Title')); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFiles/item/' . $file2->ID . '/remove', - array() - ); - $this->assertFalse($response->isError()); + + // Remove file 2 + $response = $this->mockUploadFileIDs('HasManyFiles', array($file3->ID)); + $this->assertEmpty($response['errors']); + + // check only file 3 is left $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertEquals(array('File3'), $record->HasManyFiles()->column('Title')); + + // Check file 2 object itself exists $this->assertFileExists($file3->FullPath, 'File is only detached, not deleted from filesystem'); } + /** + * Test that items can be removed from many_many + */ public function testRemoveFromManyMany() { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $file4 = $this->objFromFixture('File', 'file4'); $file5 = $this->objFromFixture('File', 'file5'); + // Check that both files are currently set $this->assertContains('File4', $record->ManyManyFiles()->column('Title')); $this->assertContains('File5', $record->ManyManyFiles()->column('Title')); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/ManyManyFiles/item/' . $file4->ID . '/remove', - array() - ); - $this->assertFalse($response->isError()); + + // Remove file 4 + $response = $this->mockUploadFileIDs('ManyManyFiles', array($file5->ID)); + $this->assertEmpty($response['errors']); + + // check only file 5 is left $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertNotContains('File4', $record->ManyManyFiles()->column('Title')); $this->assertContains('File5', $record->ManyManyFiles()->column('Title')); + + // check file 4 object exists $this->assertFileExists($file4->FullPath, 'File is only detached, not deleted from filesystem'); } + /** + * Test that files can be deleted from has_one and the filesystem + */ public function testDeleteFromHasOne() { $this->loginWithPermission('ADMIN'); $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $file1 = $this->objFromFixture('File', 'file1'); + // Check that file initially exists $this->assertTrue($record->HasOneFile()->exists()); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasOneFile/item/' . $file1->ID . '/delete', - array() - ); + $this->assertFileExists($file1->FullPath); + + // Delete physical file and update record + $response = $this->mockFileDelete('HasOneFile', $file1->ID); $this->assertFalse($response->isError()); + $response = $this->mockUploadFileIDs('HasOneFile', array()); + $this->assertEmpty($response['errors']); + + // Check that file is not set against record $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertFalse($record->HasOneFile()->exists()); + + // Check that the physical file is deleted $this->assertFileNotExists($file1->FullPath, 'File is also removed from filesystem'); } + /** + * Test that files can be deleted from has_many and the filesystem + */ public function testDeleteFromHasMany() { $this->loginWithPermission('ADMIN'); @@ -265,25 +323,28 @@ class UploadFieldTest extends FunctionalTest { $file2 = $this->objFromFixture('File', 'file2'); $file3 = $this->objFromFixture('File', 'file3'); + // Check that files initially exists $this->assertEquals(array('File2', 'File3'), $record->HasManyFiles()->column('Title')); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFiles/item/' . $file2->ID . '/delete', - array() - ); + $this->assertFileExists($file2->FullPath); + $this->assertFileExists($file3->FullPath); + + // Delete physical file and update record without file 2 + $response = $this->mockFileDelete('HasManyFiles', $file2->ID); $this->assertFalse($response->isError()); + $response = $this->mockUploadFileIDs('HasManyFiles', array($file3->ID)); + $this->assertEmpty($response['errors']); + + // Test that file is removed from record $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertEquals(array('File3'), $record->HasManyFiles()->column('Title')); + + // Test that physical file is removed $this->assertFileNotExists($file2->FullPath, 'File is also removed from filesystem'); - - $fileNotOnRelationship = $this->objFromFixture('File', 'file1'); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFiles/item/' . $fileNotOnRelationship->ID . '/delete', - array() - ); - $this->assertEquals(403, $response->getStatusCode(), - "Denies deleting files if they're not on the current relationship"); } + /** + * Test that files can be deleted from many_many and the filesystem + */ public function testDeleteFromManyMany() { $this->loginWithPermission('ADMIN'); @@ -292,26 +353,35 @@ class UploadFieldTest extends FunctionalTest { $file5 = $this->objFromFixture('File', 'file5'); $fileNoDelete = $this->objFromFixture('File', 'file-nodelete'); - $this->assertContains('File4', $record->ManyManyFiles()->column('Title')); - $this->assertContains('File5', $record->ManyManyFiles()->column('Title')); - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/ManyManyFiles/item/' . $file4->ID . '/delete', - array() - ); + // Test that files initially exist + $setFiles = $record->ManyManyFiles()->column('Title'); + $this->assertContains('File4', $setFiles); + $this->assertContains('File5', $setFiles); + $this->assertContains('nodelete.txt', $setFiles); + $this->assertFileExists($file4->FullPath); + $this->assertFileExists($file5->FullPath); + $this->assertFileExists($fileNoDelete->FullPath); + + // Delete physical file and update record without file 4 + $response = $this->mockFileDelete('ManyManyFiles', $file4->ID); $this->assertFalse($response->isError()); + + // Check file is removed from record $record = DataObject::get_by_id($record->class, $record->ID, false); $this->assertNotContains('File4', $record->ManyManyFiles()->column('Title')); $this->assertContains('File5', $record->ManyManyFiles()->column('Title')); + + // Check physical file is removed from filesystem $this->assertFileNotExists($file4->FullPath, 'File is also removed from filesystem'); // Test record-based permissions - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/ManyManyFiles/item/' . $fileNoDelete->ID . '/delete', - array() - ); + $response = $this->mockFileDelete('ManyManyFiles/', $fileNoDelete->ID); $this->assertEquals(403, $response->getStatusCode()); } + /** + * Test control output html + */ public function testView() { $this->loginWithPermission('ADMIN'); @@ -326,7 +396,7 @@ class UploadFieldTest extends FunctionalTest { $this->assertFalse($response->isError()); $parser = new CSSContentParser($response->getBody()); - $items = $parser->getBySelector('#ManyManyFiles .ss-uploadfield-files .ss-uploadfield-item'); + $items = $parser->getBySelector('#HasManyNoViewFiles .ss-uploadfield-files .ss-uploadfield-item'); $ids = array(); foreach($items as $item) $ids[] = (int)$item['data-fileid']; @@ -368,16 +438,16 @@ class UploadFieldTest extends FunctionalTest { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); $form = $this->getMockForm(); - $field = new UploadField('MyField'); + $field = UploadField::create('MyField'); $field->setForm($form); $this->assertNull($field->getRecord(), 'Returns no record by default'); - $field = new UploadField('MyField'); + $field = UploadField::create('MyField'); $field->setForm($form); $form->loadDataFrom($record); $this->assertEquals($record, $field->getRecord(), 'Returns record from form if available'); - $field = new UploadField('MyField'); + $field = UploadField::create('MyField'); $field->setForm($form); $field->setRecord($record); $this->assertEquals($record, $field->getRecord(), 'Returns record when set explicitly'); @@ -385,22 +455,24 @@ class UploadFieldTest extends FunctionalTest { public function testSetItems() { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $form = $this->getMockForm(); $items = new ArrayList(array( $this->objFromFixture('File', 'file1'), $this->objFromFixture('File', 'file2') )); + + // Field with no record attached + $field = UploadField::create('DummyField'); + $field->setItems($items); + $this->assertEquals(array('File1', 'File2'), $field->getItems()->column('Title')); // Anonymous field - $field = new UploadField('MyField'); - $field->setForm($form); + $field = UploadField::create('MyField'); $field->setRecord($record); $field->setItems($items); $this->assertEquals(array('File1', 'File2'), $field->getItems()->column('Title')); // Field with has_one auto-detected - $field = new UploadField('HasOneFile'); - $field->setForm($form); + $field = UploadField::create('HasOneFile'); $field->setRecord($record); $field->setItems($items); $this->assertEquals(array('File1', 'File2'), $field->getItems()->column('Title'), @@ -410,30 +482,25 @@ class UploadFieldTest extends FunctionalTest { public function testGetItems() { $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $form = $this->getMockForm(); // Anonymous field - $field = new UploadField('MyField'); - $field->setForm($form); - $field->setRecord($record); + $field = UploadField::create('MyField'); + $field->setValue(null, $record); $this->assertEquals(array(), $field->getItems()->column('Title')); // Field with has_one auto-detected - $field = new UploadField('HasOneFile'); - $field->setForm($form); - $field->setRecord($record); + $field = UploadField::create('HasOneFile'); + $field->setValue(null, $record); $this->assertEquals(array('File1'), $field->getItems()->column('Title')); // Field with has_many auto-detected - $field = new UploadField('HasManyFiles'); - $field->setForm($form); - $field->setRecord($record); + $field = UploadField::create('HasManyFiles'); + $field->setValue(null, $record); $this->assertEquals(array('File2', 'File3'), $field->getItems()->column('Title')); // Field with many_many auto-detected - $field = new UploadField('ManyManyFiles'); - $field->setForm($form); - $field->setRecord($record); + $field = UploadField::create('ManyManyFiles'); + $field->setValue(null, $record); $this->assertNotContains('File1',$field->getItems()->column('Title')); $this->assertNotContains('File2',$field->getItems()->column('Title')); $this->assertNotContains('File3',$field->getItems()->column('Title')); @@ -448,14 +515,17 @@ class UploadFieldTest extends FunctionalTest { $this->assertFalse($response->isError()); $parser = new CSSContentParser($response->getBody()); - $this->assertFalse((bool)$parser->getBySelector( - '#ReadonlyField .ss-uploadfield-files .ss-uploadfield-item .ss-ui-button'), + + $this->assertFalse( + (bool)$parser->getBySelector('#ReadonlyField .ss-uploadfield-files .ss-uploadfield-item .ss-ui-button'), 'Removes all buttons on items'); - $this->assertFalse((bool)$parser->getBySelector('#ReadonlyField .ss-uploadfield-dropzone'), - 'Removes dropzone'); - $this->assertFalse((bool)$parser->getBySelector( - '#ReadonlyField .ss-uploadfield-addfile .ss-ui-button'), - 'Removes all buttons from "add" area'); + $this->assertFalse( + (bool)$parser->getBySelector('#ReadonlyField .ss-uploadfield-dropzone'), + 'Removes dropzone' + ); + $this->assertFalse((bool)$parser->getBySelector('#ReadonlyField .ss-uploadfield-addfile'), + 'Entire "add" area' + ); } public function testDisabled() { @@ -471,9 +541,9 @@ class UploadFieldTest extends FunctionalTest { $this->assertFalse((bool)$parser->getBySelector('#DisabledField .ss-uploadfield-dropzone'), 'Removes dropzone'); $this->assertFalse( - (bool)$parser->getBySelector('#DisabledField .ss-uploadfield-addfile .ss-ui-button'), - 'Removes all buttons from "add" area'); - + (bool)$parser->getBySelector('#DisabledField .ss-uploadfield-addfile'), + 'Entire "add" area' + ); } public function testCanUpload() { @@ -491,20 +561,20 @@ class UploadFieldTest extends FunctionalTest { } public function testCanUploadWithPermissionCode() { - $field = new UploadField('MyField'); + $field = UploadField::create('MyField'); - $field->setConfig('canUpload', true); + $field->setCanUpload(true); $this->assertTrue($field->canUpload()); - $field->setConfig('canUpload', false); + $field->setCanUpload(false); $this->assertFalse($field->canUpload()); $this->loginWithPermission('ADMIN'); - $field->setConfig('canUpload', false); + $field->setCanUpload(false); $this->assertFalse($field->canUpload()); - $field->setConfig('canUpload', 'ADMIN'); + $field->setCanUpload('ADMIN'); $this->assertTrue($field->canUpload()); } @@ -522,28 +592,6 @@ class UploadFieldTest extends FunctionalTest { (bool)$parser->getBySelector('#CanAttachExistingFalseField .ss-uploadfield-fromfiles'), 'Removes "From files" button' ); - } - - public function testIsSaveable() { - $form = $this->getMockForm(); - - $field = new UploadField('MyField'); - $this->assertTrue($field->isSaveable(), 'Field without relation is always marked as saveable'); - - $field = new UploadField('HasOneFile'); - $this->assertTrue($field->isSaveable(), 'Field with has_one relation is saveable without record on form'); - - $field = new UploadField('HasOneFile'); - $newRecord = new UploadFieldTest_Record(); - $form->loadDataFrom($newRecord); - $field->setForm($form); - $this->assertFalse($field->isSaveable(), 'Field with has_one relation not saveable with new record on form'); - - $field = new UploadField('HasOneFile'); - $existingRecord = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $form->loadDataFrom($existingRecord); - $field->setForm($form); - $this->assertTrue($field->isSaveable(), 'Field with has_one relation saveable with saved record on form'); } public function testSelect() { @@ -566,93 +614,6 @@ class UploadFieldTest extends FunctionalTest { $this->assertNotContains($fileSubfolder->ID, $itemIDs, 'Does not contain file in subfolder'); } - public function testAttachHasOne() { - $this->loginWithPermission('ADMIN'); - - $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $file1 = $this->objFromFixture('File', 'file1'); - $file2 = $this->objFromFixture('File', 'file2'); - $file3AlreadyAttached = $this->objFromFixture('File', 'file3'); - - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasOneFile/attach', - array('ids' => array($file1->ID/* first file should be ignored */, $file2->ID)) - ); - $this->assertFalse($response->isError()); - - $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertEquals($file2->ID, $record->HasOneFileID, 'Attaches new relations'); - } - - public function testAttachHasMany() { - $this->loginWithPermission('ADMIN'); - - $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $file1 = $this->objFromFixture('File', 'file1'); - $file2 = $this->objFromFixture('File', 'file2'); - $file3AlreadyAttached = $this->objFromFixture('File', 'file3'); - - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/HasManyFiles/attach', - array('ids' => array($file1->ID, $file2->ID)) - ); - $this->assertFalse($response->isError()); - - $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertContains($file1->ID, $record->HasManyFiles()->column('ID'), - 'Attaches new relations'); - $this->assertContains($file2->ID, $record->HasManyFiles()->column('ID'), - 'Attaches new relations'); - $this->assertContains($file3AlreadyAttached->ID, $record->HasManyFiles()->column('ID'), - 'Does not detach existing relations'); - } - - public function testAttachManyMany() { - $this->loginWithPermission('ADMIN'); - - $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - $file1 = $this->objFromFixture('File', 'file1'); - $file2 = $this->objFromFixture('File', 'file2'); - $file5AlreadyAttached = $this->objFromFixture('File', 'file5'); - - $response = $this->post( - 'UploadFieldTest_Controller/Form/field/ManyManyFiles/attach', - array('ids' => array($file1->ID, $file2->ID)) - ); - $this->assertFalse($response->isError()); - - $record = DataObject::get_by_id($record->class, $record->ID, false); - $this->assertContains($file1->ID, $record->ManyManyFiles()->column('ID'), - 'Attaches new relations'); - $this->assertContains($file2->ID, $record->ManyManyFiles()->column('ID'), - 'Attaches new relations'); - $this->assertContains($file5AlreadyAttached->ID, $record->ManyManyFiles()->column('ID'), - 'Does not detach existing relations'); - } - - public function testManagesRelation() { - $record = $this->objFromFixture('UploadFieldTest_Record', 'record1'); - - $field = new UploadField('ManyManyFiles'); - $this->assertFalse($field->managesRelation(), 'False if no record is set'); - - $field = new UploadField('NoRelationField'); - $field->setRecord($record); - $this->assertFalse($field->managesRelation(), 'False if no relation found by name'); - - $field = new UploadField('HasOneFile'); - $field->setRecord($record); - $this->assertTrue($field->managesRelation(), 'True for has_one'); - - $field = new UploadField('HasManyFiles'); - $field->setRecord($record); - $this->assertTrue($field->managesRelation(), 'True for has_many'); - - $field = new UploadField('ManyManyFiles'); - $field->setRecord($record); - $this->assertTrue($field->managesRelation(), 'True for many_many'); - } - protected function getMockForm() { return new Form(new Controller(), 'Form', new FieldList(), new FieldList()); } @@ -668,12 +629,76 @@ class UploadFieldTest extends FunctionalTest { // emulates the $_FILES array return array( - 'name' => $tmpFileName, - 'type' => 'text/plaintext', - 'size' => filesize($tmpFilePath), - 'tmp_name' => $tmpFilePath, - 'extension' => 'txt', - 'error' => UPLOAD_ERR_OK, + 'name' => array('Uploads' => array($tmpFileName)), + 'type' => array('Uploads' => array('text/plaintext')), + 'size' => array('Uploads' => array(filesize($tmpFilePath))), + 'tmp_name' => array('Uploads' => array($tmpFilePath)), + 'error' => array('Uploads' => array(UPLOAD_ERR_OK)), + ); + } + + /** + * Simulates a form post to the test controller with the specified file IDs + * + * @param string $fileField Name of field to assign ids to + * @param array $ids list of file IDs + * @return boolean Array with key 'errors' + */ + protected function mockUploadFileIDs($fileField, $ids) { + + // collate file ids + $files = array(); + foreach($ids as $id) { + $files[$id] = $id; + } + + $data = array( + 'action_submit' => 1 + ); + if($files) { + // Normal post requests can't submit empty array values for fields + $data[$fileField] = array('Files' => $files); + } + + $form = new UploadFieldTestForm(); + $form->loadDataFrom($data, true); + if($form->validate()) { + $record = $form->getRecord(); + $form->saveInto($record); + $record->write(); + return array('errors' => null); + } else { + return array('errors' => $form->getValidator()->getErrors()); + } + } + + /** + * Simulates a file upload + * + * @param string $fileField Name of the field to mock upload for + * @param array $tmpFileName Name of temporary file to upload + * @return SS_HTTPResponse form response + */ + protected function mockFileUpload($fileField, $tmpFileName) { + $upload = $this->getUploadFile($tmpFileName); + $_FILES = array($fileField => $upload); + return $this->post( + "UploadFieldTest_Controller/Form/field/{$fileField}/upload", + array($fileField => $upload) + ); + } + + /** + * Simulates a physical file deletion + * + * @param string $fileField Name of the field + * @param integer $fileID ID of the file to delete + * @return SS_HTTPResponse form response + */ + protected function mockFileDelete($fileField, $fileID) { + return $this->post( + "UploadFieldTest_Controller/Form/field/HasOneFile/item/{$fileID}/delete", + array() ); } @@ -738,12 +763,14 @@ class UploadFieldTest_Record extends DataObject implements TestOnly { ); private static $has_many = array( - 'HasManyFiles' => 'File', - 'HasManyFilesMaxTwo' => 'File', + 'HasManyFiles' => 'File.HasManyRecord', + 'HasManyFilesMaxTwo' => 'File.HasManyMaxTwoRecord', + 'HasManyNoViewFiles' => 'File.HasManyNoViewRecord', + 'ReadonlyField' => 'File.ReadonlyRecord' ); private static $many_many = array( - 'ManyManyFiles' => 'File', + 'ManyManyFiles' => 'File' ); } @@ -751,7 +778,20 @@ class UploadFieldTest_Record extends DataObject implements TestOnly { class UploadFieldTest_FileExtension extends DataExtension implements TestOnly { private static $has_one = array( - 'Record' => 'UploadFieldTest_Record' + 'HasManyRecord' => 'UploadFieldTest_Record', + 'HasManyMaxTwoRecord' => 'UploadFieldTest_Record', + 'HasManyNoViewRecord' => 'UploadFieldTest_Record', + 'ReadonlyRecord' => 'UploadFieldTest_Record' + ); + + private static $has_many = array( + 'HasOneRecords' => 'UploadFieldTest_Record.HasOneFile', + 'HasOneMaxOneRecords' => 'UploadFieldTest_Record.HasOneFileMaxOne', + 'HasOneMaxTwoRecords' => 'UploadFieldTest_Record.HasOneFileMaxTwo', + ); + + private static $belongs_many_many = array( + 'ManyManyRecords' => 'UploadFieldTest_Record' ); public function canDelete($member = null) { @@ -767,119 +807,119 @@ class UploadFieldTest_FileExtension extends DataExtension implements TestOnly { } } -class UploadFieldTest_Controller extends Controller implements TestOnly { - - protected $template = 'BlankPage'; - - public function Form() { - $record = DataObject::get_one('UploadFieldTest_Record', '"Title" = \'Record 1\''); - - $fieldNoRelation = new UploadField('NoRelationField'); - $fieldNoRelation->setFolderName('UploadFieldTest'); - $fieldNoRelation->setRecord($record); - - $fieldHasOne = new UploadField('HasOneFile'); - $fieldHasOne->setFolderName('UploadFieldTest'); - $fieldHasOne->setRecord($record); - - $fieldHasOneExtendedFile = new UploadField('HasOneExtendedFile'); - $fieldHasOneExtendedFile->setFolderName('UploadFieldTest'); - $fieldHasOneExtendedFile->setRecord($record); - - $fieldHasOneMaxOne = new UploadField('HasOneFileMaxOne'); - $fieldHasOneMaxOne->setFolderName('UploadFieldTest'); - $fieldHasOneMaxOne->setConfig('allowedMaxFileNumber', 1); - $fieldHasOneMaxOne->setRecord($record); - - $fieldHasOneMaxTwo = new UploadField('HasOneFileMaxTwo'); - $fieldHasOneMaxTwo->setFolderName('UploadFieldTest'); - $fieldHasOneMaxTwo->setConfig('allowedMaxFileNumber', 2); - $fieldHasOneMaxTwo->setRecord($record); - - $fieldHasMany = new UploadField('HasManyFiles'); - $fieldHasMany->setFolderName('UploadFieldTest'); - $fieldHasMany->setRecord($record); - - $fieldHasManyMaxTwo = new UploadField('HasManyFilesMaxTwo'); - $fieldHasManyMaxTwo->setFolderName('UploadFieldTest'); - $fieldHasManyMaxTwo->setConfig('allowedMaxFileNumber', 2); - $fieldHasManyMaxTwo->setRecord($record); - - $fieldManyMany = new UploadField('ManyManyFiles'); - $fieldManyMany->setFolderName('UploadFieldTest'); - $fieldManyMany->setRecord($record); - - $fieldReadonly = new UploadField('ReadonlyField'); - $fieldReadonly->setFolderName('UploadFieldTest'); - $fieldReadonly->setRecord($record); - $fieldReadonly = $fieldReadonly->performReadonlyTransformation(); - - $fieldDisabled = new UploadField('DisabledField'); - $fieldDisabled->setFolderName('UploadFieldTest'); - $fieldDisabled->setRecord($record); - $fieldDisabled = $fieldDisabled->performDisabledTransformation(); - - $fieldSubfolder = new UploadField('SubfolderField'); - $fieldSubfolder->setFolderName('UploadFieldTest/subfolder1'); - $fieldSubfolder->setRecord($record); - - $fieldCanUploadFalse = new UploadField('CanUploadFalseField'); - $fieldCanUploadFalse->setConfig('canUpload', false); - $fieldCanUploadFalse->setRecord($record); - - $fieldCanAttachExisting = new UploadField('CanAttachExistingFalseField'); - $fieldCanAttachExisting->setConfig('canAttachExisting', false); - $fieldCanAttachExisting->setRecord($record); - - $form = new Form( - $this, - 'Form', - new FieldList( - $fieldNoRelation, - $fieldHasOne, - $fieldHasOneMaxOne, - $fieldHasOneMaxTwo, - $fieldHasOneExtendedFile, - $fieldHasMany, - $fieldHasManyMaxTwo, - $fieldManyMany, - $fieldReadonly, - $fieldDisabled, - $fieldSubfolder, - $fieldCanUploadFalse, - $fieldCanAttachExisting - ), - new FieldList( - new FormAction('submit') - ), - new RequiredFields( - 'NoRelationField', - 'HasOneFile', - 'HasOneFileMaxOne', - 'HasOneFileMaxTwo', - 'HasOneExtendedFile', - 'HasManyFiles', - 'HasManyFilesMaxTwo', - 'ManyManyFiles', - 'ReadonlyField', - 'DisabledField', - 'SubfolderField', - 'CanUploadFalseField', - 'CanAttachExistingField' - ) - ); - return $form; - } - - public function submit($data, $form) { - - } - -} - /** * Used for testing the create-on-upload */ class UploadFieldTest_ExtendedFile extends File implements TestOnly { - + + private static $has_many = array( + 'HasOneExtendedRecords' => 'UploadFieldTest_Record.HasOneExtendedFile' + ); } + +class UploadFieldTestForm extends Form implements TestOnly { + + public function getRecord() { + if(empty($this->record)) { + $this->record = DataObject::get_one('UploadFieldTest_Record', '"Title" = \'Record 1\''); + } + return $this->record; + } + + function __construct($controller = null, $name = 'Form') { + if(empty($controller)) { + $controller = new UploadFieldTest_Controller(); + } + + $fieldNoRelation = UploadField::create('NoRelationField') + ->setFolderName('UploadFieldTest'); + + $fieldHasOne = UploadField::create('HasOneFile') + ->setFolderName('UploadFieldTest'); + + $fieldHasOneExtendedFile = UploadField::create('HasOneExtendedFile') + ->setFolderName('UploadFieldTest'); + + $fieldHasOneMaxOne = UploadField::create('HasOneFileMaxOne') + ->setFolderName('UploadFieldTest') + ->setAllowedMaxFileNumber(1); + + $fieldHasOneMaxTwo = UploadField::create('HasOneFileMaxTwo') + ->setFolderName('UploadFieldTest') + ->setAllowedMaxFileNumber(2); + + $fieldHasMany = UploadField::create('HasManyFiles') + ->setFolderName('UploadFieldTest'); + + $fieldHasManyMaxTwo = UploadField::create('HasManyFilesMaxTwo') + ->setFolderName('UploadFieldTest') + ->setAllowedMaxFileNumber(2); + + $fieldManyMany = UploadField::create('ManyManyFiles') + ->setFolderName('UploadFieldTest'); + + $fieldHasManyNoView = UploadField::create('HasManyNoViewFiles') + ->setFolderName('UploadFieldTest'); + + $fieldReadonly = UploadField::create('ReadonlyField') + ->setFolderName('UploadFieldTest') + ->performReadonlyTransformation(); + + $fieldDisabled = UploadField::create('DisabledField') + ->setFolderName('UploadFieldTest') + ->performDisabledTransformation(); + + $fieldSubfolder = UploadField::create('SubfolderField') + ->setFolderName('UploadFieldTest/subfolder1'); + + $fieldCanUploadFalse = UploadField::create('CanUploadFalseField') + ->setCanUpload(false); + + $fieldCanAttachExisting = UploadField::create('CanAttachExistingFalseField') + ->setCanAttachExisting(false); + + $fields = new FieldList( + $fieldNoRelation, + $fieldHasOne, + $fieldHasOneMaxOne, + $fieldHasOneMaxTwo, + $fieldHasOneExtendedFile, + $fieldHasMany, + $fieldHasManyMaxTwo, + $fieldManyMany, + $fieldHasManyNoView, + $fieldReadonly, + $fieldDisabled, + $fieldSubfolder, + $fieldCanUploadFalse, + $fieldCanAttachExisting + ); + $actions = new FieldList( + new FormAction('submit') + ); + $validator = new RequiredFields(); + + parent::__construct($controller, $name, $fields, $actions, $validator); + + $this->loadDataFrom($this->getRecord()); + } + + public function submit($data, Form $form) { + $record = $this->getRecord(); + $form->saveInto($record); + $record->write(); + return json_encode($record->toMap()); + } +} + + +class UploadFieldTest_Controller extends Controller implements TestOnly { + + protected $template = 'BlankPage'; + + private static $allowed_actions = array('Form', 'index', 'submit'); + + public function Form() { + return new UploadFieldTestForm($this, 'Form'); + } +} \ No newline at end of file diff --git a/tests/forms/uploadfield/UploadFieldTest.yml b/tests/forms/uploadfield/UploadFieldTest.yml index c9a7f14a4..fa8112d77 100644 --- a/tests/forms/uploadfield/UploadFieldTest.yml +++ b/tests/forms/uploadfield/UploadFieldTest.yml @@ -1,53 +1,55 @@ Folder: - folder1: - Name: UploadFieldTest - folder1-subfolder1: - Name: subfolder1 - ParentID: =>Folder.folder1 + folder1: + Name: UploadFieldTest + folder1-subfolder1: + Name: subfolder1 + ParentID: =>Folder.folder1 File: - file1: - Title: File1 - Filename: assets/UploadFieldTest/file1.txt - ParentID: =>Folder.folder1 - file2: - Title: File2 - Filename: assets/UploadFieldTest/file2.txt - ParentID: =>Folder.folder1 - file3: - Title: File3 - Filename: assets/UploadFieldTest/file3.txt - ParentID: =>Folder.folder1 - file4: - Title: File4 - Filename: assets/UploadFieldTest/file4.txt - ParentID: =>Folder.folder1 - file5: - Title: File5 - Filename: assets/UploadFieldTest/file5.txt - ParentID: =>Folder.folder1 - file-noview: - Title: noview.txt - Name: noview.txt - Filename: assets/UploadFieldTest/noview.txt - ParentID: =>Folder.folder1 - file-noedit: - Title: noedit.txt - Name: noedit.txt - Filename: assets/UploadFieldTest/noedit.txt - ParentID: =>Folder.folder1 - file-nodelete: - Title: nodelete.txt - Name: nodelete.txt - Filename: assets/UploadFieldTest/nodelete.txt - ParentID: =>Folder.folder1 - file-subfolder: - Title: file-subfolder.txt - Name: file-subfolder.txt - Filename: assets/UploadFieldTest/subfolder1/file-subfolder.txt - ParentID: =>Folder.folder1-subfolder1 + file1: + Title: File1 + Filename: assets/UploadFieldTest/file1.txt + ParentID: =>Folder.folder1 + file2: + Title: File2 + Filename: assets/UploadFieldTest/file2.txt + ParentID: =>Folder.folder1 + file3: + Title: File3 + Filename: assets/UploadFieldTest/file3.txt + ParentID: =>Folder.folder1 + file4: + Title: File4 + Filename: assets/UploadFieldTest/file4.txt + ParentID: =>Folder.folder1 + file5: + Title: File5 + Filename: assets/UploadFieldTest/file5.txt + ParentID: =>Folder.folder1 + file-noview: + Title: noview.txt + Name: noview.txt + Filename: assets/UploadFieldTest/noview.txt + ParentID: =>Folder.folder1 + file-noedit: + Title: noedit.txt + Name: noedit.txt + Filename: assets/UploadFieldTest/noedit.txt + ParentID: =>Folder.folder1 + file-nodelete: + Title: nodelete.txt + Name: nodelete.txt + Filename: assets/UploadFieldTest/nodelete.txt + ParentID: =>Folder.folder1 + file-subfolder: + Title: file-subfolder.txt + Name: file-subfolder.txt + Filename: assets/UploadFieldTest/subfolder1/file-subfolder.txt + ParentID: =>Folder.folder1-subfolder1 UploadFieldTest_Record: - record1: - Title: Record 1 - HasOneFileID: =>File.file1 - HasManyFiles: =>File.file2,=>File.file3 - ManyManyFiles: =>File.file4,=>File.file5,=>File.file-noview,=>File.file-noedit,=>File.file-nodelete \ No newline at end of file + record1: + Title: Record 1 + HasOneFileID: =>File.file1 + HasManyFiles: =>File.file2,=>File.file3 + ManyManyFiles: =>File.file4,=>File.file5,=>File.file-noedit,=>File.file-nodelete + HasManyNoViewFiles: =>File.file4,=>File.file5,=>File.file-noedit,=>File.file-nodelete,=>File.file-noview + ReadonlyField: =>File.file4