features, cleanup and bug fixes #26

Merged
ben merged 30 commits from ben/Jobboerse:main into main 2022-06-09 17:36:55 +02:00
13 changed files with 301 additions and 111 deletions
Showing only changes of commit 62c6b8170d - Show all commits

more edit dialog allow addition/deletion of attachments

* form now also shows emty slots for links/attachments when the default form count is not already filled
Bennet Bleßmann 2022-06-02 02:54:18 +02:00 committed by Bennet Bleßmann
Signed by: ben
GPG key ID: 3BE1A1A3CBC3CF99

View file

@ -114,7 +114,7 @@ pub(crate) async fn tmpfile_from_field(
limit: usize,
tmp_dir: &Path,
) -> Result<(String, Option<NamedTempFile>), MultipartFieldError> {
// while it is technically valid to not get a filename here, we expect it to be present
// while it is technically valid to not get a filename, we expect it to be present
let file_name = field
.content_disposition()
.get_filename()

View file

@ -4,7 +4,7 @@ use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::io::ErrorKind;
use std::ops::{Add, Deref, DerefMut};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use actix_web::error::UrlGenerationError;
use actix_web::{HttpRequest, Result};
@ -13,7 +13,7 @@ use error::DeleteError;
use lettre::Address;
use log::{debug, error, info, warn};
use rand::distributions::DistString;
use tempfile::NamedTempFile;
use tempfile::{NamedTempFile, PersistError};
use tokio::sync::{RwLock, RwLockMappedWriteGuard, RwLockReadGuard, RwLockWriteGuard};
use url::Url;
use view::{JobOfferActions, JobOfferViewData};
@ -63,6 +63,26 @@ pub(crate) struct Attachment<Location> {
pub(crate) attachment_location: Location,
}
impl Attachment<NamedTempFile> {
pub(crate) fn persist(
self,
idx: usize,
folder_path: &Path,
) -> Result<Attachment<PathBuf>, PersistError> {
let attachment_location = Self::attachment_filename(idx);
let file_path = folder_path.join(&attachment_location);
self.attachment_location.persist(&file_path)?;
Ok(Attachment {
title: self.title,
file_name: self.file_name,
attachment_location,
})
}
pub(crate) fn attachment_filename(idx: usize) -> PathBuf {
PathBuf::from(format!("{idx}.attachment"))
}
}
impl Attachment<PathBuf> {
pub(crate) fn generate_link(
&self,
@ -208,7 +228,7 @@ impl<A> JobOffer<A> {
self.status.is_published()
}
fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
pub(crate) fn folder_path(id: &JobOfferId, config: &ServerConfig) -> PathBuf {
config.config.data_storage_path.join(id)
}
@ -232,16 +252,7 @@ impl JobOffer<NamedTempFile> {
.attachments
.into_iter()
.enumerate()
.map(|(idx, entry)| {
let attachment_location = PathBuf::from(format!("{idx}.attachment"));
let file_path = folder_path.join(&attachment_location);
entry.attachment_location.persist(&file_path)?;
Ok(Attachment {
title: entry.title,
file_name: entry.file_name,
attachment_location,
})
})
.map(|(idx, entry)| entry.persist(idx, &folder_path))
.collect::<Result<_, tempfile::PersistError>>()?;
// use https://github.com/rust-lang/rust/issues/86555 when stabilized
@ -287,7 +298,6 @@ impl JobOffer<PathBuf> {
let offer = match toml::ser::to_string(self) {
Ok(offer) => offer,
Err(err) => {
dbg!(&err);
return Err(err.into());
}
};
@ -346,8 +356,6 @@ impl JobOffer<PathBuf> {
JobOffer::<PathBuf>::hash(self, &mut hasher);
let hash = hasher.finish();
dbg!(hash);
let attachments = self
.attachments
.iter()
@ -360,6 +368,12 @@ impl JobOffer<PathBuf> {
})
.collect::<Result<Vec<_>, UrlGenerationError>>()?;
let mut links: Vec<_> = self.links.iter().cloned().map(Some).collect();
if links.len() < crate::route::form_constants::LINK_FIELD_COUNT {
links.resize(crate::route::form_constants::LINK_FIELD_COUNT, None)
}
Ok(JobOfferEditData {
id: id.to_owned(),
hash,
@ -371,7 +385,7 @@ impl JobOffer<PathBuf> {
submission_date: self.date_of_submission.to_string(),
title: self.title.clone(),
attachments,
links: self.links.clone(),
links,
})
}

View file

@ -21,7 +21,7 @@ pub(crate) struct JobOfferEditData {
pub(crate) submission_date: String,
pub(crate) title: String,
pub(crate) attachments: Vec<Attachment<SerializableUrl>>,
pub(crate) links: Vec<Link>,
pub(crate) links: Vec<Option<Link>>,
}
#[derive(serde::Serialize)]

View file

@ -11,6 +11,7 @@ use url::Url;
mod auth;
pub(crate) mod error_handler;
pub(crate) mod form_constants;
mod job_offer;
mod license;

View file

@ -22,7 +22,6 @@ pub(crate) const PRE_APPROVED: &str = "pre_approved";
pub(crate) const SKIP_CONFIRMATION: &str = "skip_confirmation";
pub(crate) const PERMANENT_FIELD: &str = "permanent";
pub(crate) const ATTACHMENT_TITLES: &str = "file_title[]";
pub(crate) const ATTACHMENT_FILE_NAME: &str = "file_name[]";
pub(crate) const ATTACHMENT_FILES: &str = "file[]";
pub(crate) const LINK_TITLES: &str = "link_title[]";
pub(crate) const LINK_URLS: &str = "link_url[]";
@ -43,6 +42,9 @@ pub(crate) const MAX_LINK_URL_LEN: usize = 2048;
pub(crate) const MAX_ATTACHMENT_TITLE_LEN: usize = 128;
pub(crate) const MAX_LINK_COUNT: Option<u8> = Some(20);
pub(crate) const ATTACHMENT_FIELD_COUNT: usize = 4;
pub(crate) const LINK_FIELD_COUNT: usize = 4;
pub(crate) struct UploadLimits {
pub(crate) size: usize,
pub(crate) count: Option<u8>,
@ -63,3 +65,9 @@ pub(crate) const fn upload_limits(user: Option<&User>) -> UploadLimits {
}
pub(crate) const HASH: &str = "hash";
pub(crate) const ATTACHMENT_FILENAME_EDIT_FIELD: &str = "file_name_edit[]";
pub(crate) const ATTACHMENT_TITLE_EDIT_FIELD: &str = "file_title_edit[]";
pub(crate) const ATTACHMENT_FILE_REPLACE_FIELD: &str = "file_replace[]";
pub(crate) const DELETE_ATTACHMENT_FIELD: &'static str = "delete_attachment[]";

View file

@ -14,7 +14,6 @@ pub(crate) mod create;
pub(crate) mod delete;
pub(crate) mod edit;
pub(crate) mod error;
pub(crate) mod form_constants;
pub(crate) mod review;
use crate::auth::User;

View file

@ -11,6 +11,7 @@ use lettre::message::{Mailbox, SinglePart};
use lettre::{Address, AsyncTransport};
use log::{debug, error, warn};
use rand::distributions::DistString;
use serde::Serialize;
use serde_json::json;
use tempfile::NamedTempFile;
use url::Url;
@ -23,17 +24,23 @@ use crate::job_offers::{
Attachment, ConfirmationStatus, JobOffer, JobOfferStatus, JobOffers, Link, MutBorrowedJobOffer,
ReviewStatus,
};
use crate::route::form_constants::{self, UploadLimits};
use crate::route::job_offer::confirmation::JOBOFFER_CONFIRM_ROUTE;
use crate::route::job_offer::error::{FormProcessingError, SubmissionResponseError};
use crate::route::job_offer::form_constants::{self, UploadLimits};
use crate::route::HTML_CONTENT;
use crate::server_config::{EmailConfig, ServerConfig};
use crate::template;
use crate::util::{parse_date, parse_datetime, process_links};
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
use multipart_helper::{multi_field, multi_file, once_field};
pub(crate) const JOBOFFER_CREATION_ROUTE: &str = "create_offer";
#[derive(Serialize)]
struct SubmissionFormRenderData {
attachments: Vec<()>,
links: Vec<()>,
}
#[get("/new", name = "create_offer")]
pub(crate) async fn create_joboffer_get(
req: HttpRequest,
@ -43,9 +50,15 @@ pub(crate) async fn create_joboffer_get(
) -> Result<HttpResponse, PresentationError> {
let user = User::current(&session).ok();
let form_data = SubmissionFormRenderData {
attachments: vec![(); form_constants::ATTACHMENT_FIELD_COUNT],
links: vec![(); form_constants::LINK_FIELD_COUNT],
};
let data = json!({
"base": crate::route::base(&req, &config,"Create Joboffer")?,
"user": user,
"form": form_data,
});
let rendered = hb.render(template::JOBOFFER_CREATE, &data)?;
@ -469,26 +482,7 @@ impl JobOfferSubmitForm {
assert_eq!(attachment_titles.len(), attachment_datas.len());
let attachments = attachment_titles
.into_iter()
.zip(attachment_datas.into_iter())
.filter_map(|(title, (file_name, attachment_location))| {
attachment_location
.map(|attachment_location| (title, file_name, attachment_location))
})
.enumerate()
.map(
|(idx, (title, file_name, attachment_location))| Attachment {
title: if title.is_empty() {
format!("Attachment {}: {}", idx + 1, file_name)
} else {
title
},
file_name,
attachment_location,
},
)
.collect();
let attachments = process_new_attachments(attachment_titles, attachment_datas);
assert_eq!(link_titles.len(), link_urls.len());

View file

@ -2,11 +2,10 @@ use crate::auth::User;
use crate::error::{LoginRequired, PresentationError};
use crate::job_offers::error::SaveError;
use crate::job_offers::{Attachment, JobOffer, Link};
use crate::route::form_constants::{self, UploadLimits};
use crate::route::job_offer::error::FormProcessingError;
use crate::route::job_offer::form_constants;
use crate::route::job_offer::form_constants::UploadLimits;
use crate::route::{HTML_CONTENT, JOBOFFER_OVERVIEW_ROUTE};
use crate::util::{parse_date, parse_datetime, process_links};
use crate::util::{parse_date, parse_datetime, process_links, process_new_attachments};
use crate::{template, JobOffers, ServerConfig};
use actix_multipart::Multipart;
use actix_session::Session;
@ -16,11 +15,12 @@ use futures_util::StreamExt;
use handlebars::Handlebars;
use lettre::Address;
use log::warn;
use multipart_helper::{multi_field, once_field};
use multipart_helper::{multi_field, multi_file, once_field};
use serde_json::json;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use std::path::PathBuf;
use tempfile::NamedTempFile;
#[derive(Debug, thiserror::Error)]
pub(crate) enum EditResponseError {
@ -63,9 +63,15 @@ pub(crate) async fn edit_joboffer_get(
let base =
crate::route::base(&req, &config, "Edit Job Offer").map_err(PresentationError::Url)?;
let additional_slots =
form_constants::ATTACHMENT_FIELD_COUNT.saturating_sub(job_offer.attachments.len());
let data = json!({
"base": base,
"job_offer": job_offer
"job_offer": job_offer,
"form" : {
"remaining_attachments": vec![(); additional_slots]
}
});
let body = hb
@ -100,8 +106,6 @@ pub(crate) async fn edit_joboffer_post(
JobOffer::<PathBuf>::hash(&offer, &mut orig_hasher);
let orig_hash = orig_hasher.finish();
dbg!(orig_hash);
if orig_hash != form_data.hash {
return Err(EditResponseError::ConflictingChange);
}
@ -111,6 +115,7 @@ pub(crate) async fn edit_joboffer_post(
offer_mut_ref.title = form_data.title;
offer_mut_ref.permanent = form_data.permanent;
offer_mut_ref.offering_party = form_data.offering_party;
offer_mut_ref.contact_info = form_data.contact_data;
offer_mut_ref.public_contact_info = form_data.public_contact_data;
if let Some(back_date) = form_data.backdate {
@ -121,16 +126,66 @@ pub(crate) async fn edit_joboffer_post(
offer_mut_ref.links = form_data.links;
// currently adding/removing attachments is not supported
assert_eq!(offer_mut_ref.attachments.len(), form_data.attachments.len());
assert_eq!(
offer_mut_ref.attachments.len(),
form_data.attachment_edits.len()
);
for (offer, form) in offer_mut_ref
.attachments
.iter_mut()
.zip(form_data.attachments)
{
offer.title = form.title;
offer.file_name = form.file_name;
let job_offer_folder = JobOffer::<PathBuf>::folder_path(id, &config);
let tmp = std::mem::take(&mut offer_mut_ref.attachments)
.into_iter()
.zip(form_data.attachment_edits)
.filter_map(|(mut offer, edit)| match edit {
AttachmentEdit::Delete => {
// TODO error handling
let _todo = std::fs::remove_file(job_offer_folder.join(offer.attachment_location));
None
}
AttachmentEdit::Edit {
title,
file_name,
file,
} => {
offer.title = title;
offer.file_name = file_name;
if let Some(tmp_file) = file {
// TODO error handling
let _todo =
tmp_file.persist(&job_offer_folder.join(&offer.attachment_location));
}
Some(offer)
}
});
offer_mut_ref.attachments.extend(tmp);
let existing_files: Vec<_> = offer_mut_ref
.attachments
.iter()
.map(|attachment| attachment.attachment_location.clone())
.collect();
let mut idx = 0;
let mut new_attachments = form_data
.attachments_new
.into_iter()
.map(|attachment| {
// deleting attachment might mean the attachment at index 0 uses the file 2.attachment instead of 0.attachment
// so we cannot simply use the index this will get inserted at for the file name as that might already be in use
while existing_files.contains(&Attachment::attachment_filename(idx)) {
idx += 1;
}
attachment
.persist(idx, &job_offer_folder)
.map_err(SaveError::Persist)
})
.collect::<Result<_, _>>()?;
offer_mut_ref.attachments.append(&mut new_attachments);
offer.try_clean().await?;
@ -143,7 +198,6 @@ pub(crate) async fn edit_joboffer_post(
.finish())
}
#[derive(serde::Serialize)]
pub(crate) struct JobOfferEditForm {
pub(crate) hash: u64,
pub(crate) offering_party: String,
@ -153,17 +207,27 @@ pub(crate) struct JobOfferEditForm {
pub(crate) expiry_date: Option<Date>,
pub(crate) backdate: Option<Datetime>,
pub(crate) title: String,
pub(crate) attachments: Vec<Attachment<()>>,
pub(crate) attachment_edits: Vec<AttachmentEdit>,
pub(crate) attachments_new: Vec<Attachment<NamedTempFile>>,
pub(crate) links: Vec<Link>,
}
pub enum AttachmentEdit {
Delete,
Edit {
title: String,
file_name: String,
file: Option<NamedTempFile>,
},
}
impl JobOfferEditForm {
/// Convert the Multipart struct representing multipart form-data
/// into structured form data
async fn from_multipart_form(
mut multipart: Multipart,
user: Option<&User>,
_config: &ServerConfig,
config: &ServerConfig,
) -> Result<Self, FormProcessingError> {
let mut hash = None;
let mut offering_party = None;
@ -174,14 +238,20 @@ impl JobOfferEditForm {
let mut back_date = None;
let mut expiry_date = None;
let mut attachment_titles = Vec::new();
let mut attachment_filenames = Vec::new();
let mut attachment_title_edits = Vec::new();
let mut attachment_filename_edits = Vec::new();
let mut attachment_file_replace = Vec::new();
let mut delete_attachment = Vec::new();
let mut attachment_titles_new = Vec::new();
let mut attachment_files_new = Vec::new();
let mut link_titles = Vec::new();
let mut link_urls = Vec::new();
let UploadLimits {
size: _upload_size_limit,
size: upload_size_limit,
count: upload_count_limit,
} = form_constants::upload_limits(user);
@ -242,28 +312,70 @@ impl JobOfferEditForm {
)
.await?
}
form_constants::ATTACHMENT_TITLES => {
form_constants::ATTACHMENT_TITLE_EDIT_FIELD => {
multi_field(
field,
form_constants::ATTACHMENT_TITLES,
&mut attachment_titles,
form_constants::ATTACHMENT_TITLE_EDIT_FIELD,
&mut attachment_title_edits,
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILE_NAME => {
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD => {
multi_field(
field,
form_constants::ATTACHMENT_FILE_NAME,
&mut attachment_filenames,
form_constants::ATTACHMENT_FILENAME_EDIT_FIELD,
&mut attachment_filename_edits,
// the submission form does not enforce a limite here as there we get it via a header and
// that is already in memory when we could check
// that is already in memory when we could perform the check
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILE_REPLACE_FIELD => {
multi_file(
field,
form_constants::ATTACHMENT_FILE_REPLACE_FIELD,
&mut attachment_file_replace,
upload_size_limit,
upload_count_limit,
&config.config.data_storage_path,
)
.await?
}
form_constants::DELETE_ATTACHMENT_FIELD => {
multi_field(
field,
form_constants::DELETE_ATTACHMENT_FIELD,
&mut delete_attachment,
2,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_TITLES => {
multi_field(
field,
form_constants::ATTACHMENT_TITLES,
&mut attachment_titles_new,
form_constants::MAX_ATTACHMENT_TITLE_LEN,
upload_count_limit,
)
.await?
}
form_constants::ATTACHMENT_FILES => {
multi_file(
field,
form_constants::ATTACHMENT_TITLES,
&mut attachment_files_new,
upload_size_limit,
upload_count_limit,
&config.config.data_storage_path,
)
.await?
}
form_constants::LINK_TITLES => {
multi_field(
field,
@ -315,20 +427,48 @@ impl JobOfferEditForm {
let expiry_date = parse_date(expiry_date.as_deref())?;
let backdate = parse_datetime(back_date.as_deref())?;
assert_eq!(attachment_filenames.len(), attachment_titles.len());
assert_eq!(
attachment_filename_edits.len(),
attachment_title_edits.len()
);
// if not set we should still get a field with empty content
assert_eq!(attachment_title_edits.len(), attachment_file_replace.len());
// we only get a value if delete is checked
assert!(delete_attachment.len() <= attachment_filename_edits.len());
let attachments = attachment_titles
let attachment_edits = attachment_title_edits
.into_iter()
.zip(attachment_filenames.into_iter())
.filter_map(|(title, file_name)| {
Some(Attachment {
.zip(attachment_filename_edits.into_iter())
.zip(attachment_file_replace.into_iter())
.enumerate()
.map(
|(idx, ((title, specified_file_name), (upload_file_name, file)))| {
if delete_attachment.contains(&idx.to_string()) {
// prefer deletion over everything else
// maybe we should warn when a file has both been replaced and marked for deletion
AttachmentEdit::Delete
} else {
// prefer the user specified file_name over the uploads file_name
let file_name = if specified_file_name.is_empty() {
// unless the user specified an empty string
upload_file_name
} else {
specified_file_name
};
AttachmentEdit::Edit {
title,
file_name,
attachment_location: (),
})
})
file,
}
}
},
)
.collect();
assert_eq!(attachment_titles_new.len(), attachment_files_new.len());
let attachments_new = process_new_attachments(attachment_titles_new, attachment_files_new);
assert_eq!(link_titles.len(), link_urls.len());
let links = process_links(link_titles, link_urls);
@ -355,7 +495,8 @@ impl JobOfferEditForm {
title: title.ok_or(FormProcessingError::MissingField {
field: form_constants::TITLE,
})?,
attachments,
attachment_edits,
attachments_new,
links,
})
}

View file

@ -1,8 +1,9 @@
use crate::job_offers::Link;
use crate::job_offers::{Attachment, Link};
use better_toml_datetime::Offset;
use chrono::{DateTime, FixedOffset, NaiveDate, Offset as _, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::str::FromStr;
use tempfile::NamedTempFile;
use url::Url;
// we basically don't do any proper error handling here,
@ -200,3 +201,28 @@ pub(crate) fn process_links(titles: Vec<String>, urls: Vec<String>) -> Vec<Link>
})
.collect()
}
pub(crate) fn process_new_attachments(
titles: Vec<String>,
files: Vec<(String, Option<NamedTempFile>)>,
) -> Vec<Attachment<NamedTempFile>> {
titles
.into_iter()
.zip(files.into_iter())
.filter_map(|(title, (file_name, attachment_location))| {
attachment_location.map(|attachment_location| (title, file_name, attachment_location))
})
.enumerate()
.map(
|(idx, (title, file_name, attachment_location))| Attachment {
title: if title.is_empty() {
format!("Attachment {}: {}", idx + 1, file_name)
} else {
title
},
file_name,
attachment_location,
},
)
.collect()
}

View file

@ -50,7 +50,7 @@ body main {
margin: 10px;
}
main {
.header-main {
flex-grow:1;
}

View file

@ -8,7 +8,7 @@
{{/each}}
</head>
<body>
<div>
<div class="header-main">
{{> header}}
<main>
{{> @partial-block }}

View file

@ -18,36 +18,28 @@
<fieldset class="attachment-area">
<legend>Anhänge</legend>
{{#*inline "attachment" }}
{{#each form.attachments }}
{{#unless @first}}
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<div/>
{{/inline}}
{{> attachment }}
<hr />
{{> attachment }}
<hr />
{{> attachment }}
<hr />
{{> attachment }}
{{/each}}
</fieldset>
<fieldset class="link-area">
<legend>Links<sup>2</sup></legend>
{{#*inline "link" }}
{{#each form.links }}
{{#unless @first}}
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="link_title[]" placeholder="Online-Stellenausschreibung" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{base.routes.index}}" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" placeholder="{{@root.base.routes.index}}" />
<div/>
{{/inline}}
{{> link }}
<hr />
{{> link }}
<hr />
{{> link }}
<hr />
{{> link }}
{{/each}}
</fieldset>
<div class="notes">

View file

@ -25,9 +25,24 @@
{{#unless @first}}
<hr />
{{/unless}}
<input type="text" autocomplete="on" name="file_title[]" value="{{attachment.title}}" />
<input type="text" name="file_name[]" value="{{attachment.file_name}}" />
<a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
<input type="text" autocomplete="on" name="file_title_edit[]" value="{{attachment.title}}" />
<input type="text" name="file_name_edit[]" value="{{attachment.file_name}}" />
| <a href="{{attachment.attachment_location}}" target="_blank">View Attachment</a>
| <label for="delete-{{@index}}">Delete Attachment</label> <input id="delete-{{@index}}" type="checkbox" name="delete_attachment[]" value="{{@index}}" />
| <label for="replace-{{@index}}">Replace Attachment</label> <input id="replace-{{@index}}" type="file" accept=".pdf" name="file_replace[]" title="Replace Attachment" />
<div/>
{{/each}}
{{#each form.remaining_attachments }}
{{#unless @first}}
<hr />
{{else}}
{{#if ../job_offer.attachments }}
<hr />
{{/if}}
{{/unless}}
<div>
<input type="text" autocomplete="on" name="file_title[]" placeholder="Title"/>
<input type="file" accept=".pdf" name="file[]"/>
<div/>
{{/each}}
</fieldset>
@ -39,8 +54,8 @@
<hr />
{{/unless}}
<div>
<input type="text" autocomplete="on" name="link_title[]" value="{{link.title}}" />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" value="{{link.destination}}" />
<input type="text" autocomplete="on" name="link_title[]" {{#if link}}value="{{link.title}}"{{/if}} />
<input type="url" autocomplete="url" name="link_url[]" pattern="https://.+" {{#if link}}value="{{link.destination}}"{{/if}} />
<div/>
{{/each}}
</fieldset>