mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Forms feature
Summary: A new widget type Forms. For now hidden behind GRIST_EXPERIMENTAL_PLUGINS(). This diff contains all the core moving parts as a serves as a base to extend this functionality further. Test Plan: New test added Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4130
This commit is contained in:
156
static/forms/form.html
Normal file
156
static/forms/form.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<!-- INSERT BASE -->
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
background-color: #f7f7f7;
|
||||
line-height: 1.42857143;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
<script src="forms/grist-form-submit.js"></script>
|
||||
<script src="forms/purify.min.js"></script>
|
||||
<style>
|
||||
.grist-form-container {
|
||||
color: #262633;
|
||||
background-color: #f7f7f7;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding-top: 52px;
|
||||
padding-bottom: 32px;
|
||||
font-size: 13px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
.grist-form-container .grist-form-confirm {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form.grist-form {
|
||||
padding: 32px;
|
||||
margin: 0px auto;
|
||||
background-color: white;
|
||||
border: 1px solid #E8E8E8;
|
||||
width: 640px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
form.grist-form .grist-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field label {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field .grist-field-description {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
margin-top: 4px;
|
||||
color: #929299;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
form.grist-form .grist-field input[type="text"] {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
line-height: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form.grist-form input[type="submit"] {
|
||||
background-color: #16b378;
|
||||
border: 1px solid #16b378;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="datetime-local"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="date"] {
|
||||
width: 100%;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
form.grist-form input[type="submit"]:hover {
|
||||
border-color: #009058;
|
||||
background-color: #009058;
|
||||
}
|
||||
|
||||
form.grist-form input[type="checkbox"] {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
form.grist-form .grist-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
form.grist-form select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #D9D9D9;
|
||||
font-size: 13px;
|
||||
outline-color: #16b378;
|
||||
outline-width: 1px;
|
||||
background: white;
|
||||
line-height: inherit;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form.grist-form .grist-choice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main class='grist-form-container'>
|
||||
<form class='grist-form'
|
||||
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'block', event.target.style.display = 'none'"
|
||||
data-grist-doc="<!-- INSERT DOC URL -->"
|
||||
data-grist-table="<!-- INSERT TABLE ID -->">
|
||||
<script>
|
||||
document.write(DOMPurify.sanitize(`<!-- INSERT CONTENT -->`));
|
||||
</script>
|
||||
</form>
|
||||
<div class='grist-form-confirm' style='display: none'>
|
||||
Thank you! Your response has been recorded.
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
169
static/forms/grist-form-submit.js
Normal file
169
static/forms/grist-form-submit.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// If the script is loaded multiple times, only register the handlers once.
|
||||
if (!window.gristFormSubmit) {
|
||||
(function() {
|
||||
|
||||
/**
|
||||
* gristFormSubmit(gristDocUrl, gristTableId, formData)
|
||||
* - `gristDocUrl` should be the URL of the Grist document, from step 1 of the setup instructions.
|
||||
* - `gristTableId` should be the table ID from step 2.
|
||||
* - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||
* object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it
|
||||
* can be convenient to use `new FormData(event.target)`.
|
||||
*
|
||||
* This function sends values from `formData` to add a new record in the specified Grist table. It
|
||||
* returns a promise for the result of the add-record API call. In case of an error, the promise
|
||||
* will be rejected with an error message.
|
||||
*/
|
||||
async function gristFormSubmit(docUrl, tableId, formData) {
|
||||
// Pick out the server and docId from the docUrl.
|
||||
const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl);
|
||||
if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); }
|
||||
const server = match[1];
|
||||
const docId = match[2] || match[3];
|
||||
|
||||
// Construct the URL to use for the add-record API endpoint.
|
||||
const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records";
|
||||
|
||||
const payload = {records: [{fields: formDataToJson(formData)}]};
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
|
||||
const resp = await window.fetch(destUrl, options);
|
||||
if (resp.status !== 200) {
|
||||
// Try to report a helpful error.
|
||||
let body = '', error, match;
|
||||
try { body = await resp.json(); } catch (e) {}
|
||||
if (typeof body.error === 'string' && (match = /KeyError '(.*)'/.exec(body.error))) {
|
||||
error = 'No column "' + match[1] + '" in table "' + tableId + '". ' +
|
||||
'Be sure to use column ID rather than column label';
|
||||
} else {
|
||||
error = body.error || String(body);
|
||||
}
|
||||
throw new Error('Failed to add record: ' + error);
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
|
||||
// Convert FormData into a mapping of Grist fields. Skips any keys starting with underscore.
|
||||
// For fields with multiple values (such as to populate ChoiceList), use field names like `foo[]`
|
||||
// (with the name ending in a pair of empty square brackets).
|
||||
function formDataToJson(f) {
|
||||
const keys = Array.from(f.keys()).filter(k => !k.startsWith("_"));
|
||||
return Object.fromEntries(keys.map(k =>
|
||||
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)]));
|
||||
}
|
||||
|
||||
|
||||
// Handle submissions for plain forms that include special data-grist-* attributes.
|
||||
async function handleSubmitPlainForm(ev) {
|
||||
if (!['data-grist-doc', 'data-grist-table']
|
||||
.some(attr => ev.target.hasAttribute(attr))) {
|
||||
// This form isn't configured for Grist at all; don't interfere with it.
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const docUrl = ev.target.getAttribute('data-grist-doc');
|
||||
const tableId = ev.target.getAttribute('data-grist-table');
|
||||
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
|
||||
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
|
||||
|
||||
const successUrl = ev.target.getAttribute('data-grist-success-url');
|
||||
|
||||
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
|
||||
|
||||
// On success, redirect to the requested URL.
|
||||
if (successUrl) {
|
||||
window.location.href = successUrl;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
reportSubmitError(ev, err);
|
||||
}
|
||||
}
|
||||
|
||||
function reportSubmitError(ev, err) {
|
||||
console.warn("grist-form-submit error:", err.message);
|
||||
// Find an element to use for the validation message to alert the user.
|
||||
let scapegoat = null;
|
||||
(
|
||||
(scapegoat = ev.submitter)?.setCustomValidity ||
|
||||
(scapegoat = ev.target.querySelector('input[type=submit]'))?.setCustomValidity ||
|
||||
(scapegoat = ev.target.querySelector('button'))?.setCustomValidity ||
|
||||
(scapegoat = [...ev.target.querySelectorAll('input')].pop())?.setCustomValidity
|
||||
)
|
||||
scapegoat?.setCustomValidity("Form misconfigured: " + err.message);
|
||||
ev.target.reportValidity();
|
||||
}
|
||||
|
||||
// Handle submissions for Contact Form 7 forms.
|
||||
async function handleSubmitWPCF7(ev) {
|
||||
try {
|
||||
const formId = ev.detail.contactFormId;
|
||||
const docUrl = ev.target.querySelector('[data-grist-doc]')?.getAttribute('data-grist-doc');
|
||||
const tableId = ev.target.querySelector('[data-grist-table]')?.getAttribute('data-grist-table');
|
||||
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
|
||||
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
|
||||
|
||||
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
|
||||
console.log("grist-form-submit WPCF7 Form %s: Added record", formId);
|
||||
|
||||
} catch (err) {
|
||||
console.warn("grist-form-submit WPCF7 Form %s misconfigured:", formId, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function setUpGravityForms(options) {
|
||||
// Use capture to get the event before GravityForms processes it.
|
||||
document.addEventListener('submit', ev => handleSubmitGravityForm(ev, options), true);
|
||||
}
|
||||
gristFormSubmit.setUpGravityForms = setUpGravityForms;
|
||||
|
||||
async function handleSubmitGravityForm(ev, options) {
|
||||
try {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const docUrl = options.docUrl;
|
||||
const tableId = options.tableId;
|
||||
if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); }
|
||||
if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); }
|
||||
|
||||
const f = new FormData(ev.target);
|
||||
for (const key of Array.from(f.keys())) {
|
||||
// Skip fields other than input fields.
|
||||
if (!key.startsWith("input_")) {
|
||||
f.delete(key);
|
||||
continue;
|
||||
}
|
||||
// Rename multiple fields to use "[]" convention rather than ".N" convention.
|
||||
const multi = key.split(".");
|
||||
if (multi.length > 1) {
|
||||
f.append(multi[0] + "[]", f.get(key));
|
||||
f.delete(key);
|
||||
}
|
||||
}
|
||||
console.warn("Processed FormData", f);
|
||||
await gristFormSubmit(docUrl, tableId, f);
|
||||
|
||||
// Follow through by doing the form submission normally.
|
||||
ev.target.submit();
|
||||
|
||||
} catch (err) {
|
||||
reportSubmitError(ev, err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.gristFormSubmit = gristFormSubmit;
|
||||
document.addEventListener('submit', handleSubmitPlainForm);
|
||||
document.addEventListener('wpcf7mailsent', handleSubmitWPCF7);
|
||||
|
||||
})();
|
||||
}
|
||||
3
static/forms/purify.min.js
vendored
Normal file
3
static/forms/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user