Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions nx/blocks/form/data/model.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { DA_ORIGIN } from 'https://da.live/blocks/shared/constants.js';
import { daFetch } from 'https://da.live/blocks/shared/utils.js';
import HTMLConverter from '../utils/html2json.js';
import JSONConverter from '../utils/json2html.js';
import { Validator } from '../../../deps/da-form/dist/index.js';
import { annotateProp, setValueByPath } from '../utils/utils.js';

/**
* A data model that represents a piece of structured content.
*/
export default class FormModel {
constructor({ path, html, json, schemas }) {
if (!(html || json)) {
// eslint-disable-next-line no-console
console.log('Please supply JSON or HTML to make a form model');
return;
}

if (html) {
this._html = html;
this.updateJson();
} else if (json) {
this._json = json;
this.updateHtml();
}

this._path = path;
this._schemas = schemas;
this._schema = schemas[this._json.metadata.schemaName];
this._annotated = annotateProp('data', this._json.data, this._schema, this._schema);
}

clone() {
return new FormModel({
path: this._path,
html: this._html,
json: JSON.parse(JSON.stringify(this._json)), // Deep copy of JSON
schemas: this._schemas, // or clone this too if needed
});
}

validate() {
const validator = new Validator(this._schema, '2020-12');
return validator.validate(this._json.data);
}

updateJson() {
const converter = new HTMLConverter(this._html);
this._json = converter.json;
}

updateHtml() {
const html = JSONConverter(this._json);
this._html = html;
}

updateProperty({ name, value }) {
setValueByPath(this._json, name, value);
this.updateHtml();
}

async saveHtml() {
const body = new FormData();
const data = new Blob([this._html], { type: 'text/html' });
body.append('data', data);

const opts = { method: 'POST', body };

// TODO: Don't assume the save went perfect
await daFetch(`${DA_ORIGIN}/source${this._path}`, opts);
}

set html(html) {
this._html = html;
}

get html() {
return this._html;
}

get annotated() {
return this._annotated;
}

get schema() {
return this._schema;
}

get json() {
return this._json;
}
}
1 change: 1 addition & 0 deletions nx/blocks/form/deps/prism-json.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions nx/blocks/form/deps/prism.js

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions nx/blocks/form/form.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
:host {
display: block;
}

sl-textarea {
height: 200px;
}

.da-form-wrapper {
width: 900px;
margin: 0 auto;
position: relative;

.da-form-title {
line-height: 1;
font-weight: 700;
margin: 0 0 8px;
}

.da-form-editor {
width: 700px;
}

da-form-editor {
margin-bottom: 20px;
}

da-form-sidebar {
position: absolute;
top: 0;
width: 300px;
right: -120px; /* 20px offset */
}
}
145 changes: 145 additions & 0 deletions nx/blocks/form/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { LitElement, html, nothing } from 'da-lit';
import getPathDetails from 'https://da.live/blocks/shared/pathDetails.js';

import FormModel from './data/model.js';

// Internal utils
import { schemas as schemasPromise } from './utils/schema.js';
import { loadHtml } from './utils/utils.js';

import 'https://da.live/blocks/edit/da-title/da-title.js';

// Internal Web Components
import './views/editor.js';
import './views/sidebar.js';
import './views/preview.js';

import generateEmptyObject from './utils/generator.js';

// External Web Components
await import('../../public/sl/components.js');

// Styling
const { default: getStyle } = await import('../../utils/styles.js');
const style = await getStyle(import.meta.url);

const EL_NAME = 'da-form';
const PREVIEW_PREFIX = 'https://da-sc.adobeaem.workers.dev/preview';
const LIVE_PREFIX = 'https://da-sc.adobeaem.workers.dev/live';

class FormEditor extends LitElement {
static properties = {
details: { attribute: false },
formModel: { state: true },
_schemas: { state: true },
};

connectedCallback() {
super.connectedCallback();
this.shadowRoot.adoptedStyleSheets = [style];
this.fetchDoc(this.details);
}

async fetchDoc() {
const resultPromise = loadHtml(this.details);

const [schemas, result] = await Promise.all([schemasPromise, resultPromise]);

if (schemas) this._schemas = schemas;

if (!result.html) {
this.formModel = null;
return;
}

const path = this.details.fullpath;
this.formModel = new FormModel({ path, html: result.html, schemas });
}

async handleSelectSchema(e) {
const schemaId = e.target.value;
if (!schemaId) return;

const title = this.details.name;

const data = generateEmptyObject(this._schemas[schemaId]);
const metadata = { title, schemaName: schemaId };
const emptyForm = { data, metadata };

const path = this.details.fullpath;
this.formModel = new FormModel({ path, json: emptyForm, schemas: this._schemas });
}

async handleUpdate({ detail }) {
this.formModel.updateProperty(detail);

// Update the view with the new values
this.formModel = this.formModel.clone();

// Persist the data
await this.formModel.saveHtml();
}

renderSchemaSelector() {
return html`
<p class="da-form-title">Please select a schema to get started</p>
<sl-select @change=${this.handleSelectSchema}>
<option value="">Select schema</option>
${Object.entries(this._schemas).map(([key, value]) => html`
<option value="${key}">${value.title}</option>
`)}
</sl-select>`;
}

renderFormEditor() {
if (this.formModel === null) {
if (this._schemas) return this.renderSchemaSelector();

return html`
<p class="da-form-title">Please create a schema</p>
<a href="https://main--da-live--adobe.aem.live/apps/schema?nx=schema#/${this.details.owner}/${this.details.repo}">Schema Editor</a>
`;
}

return html`
<div class="da-form-editor">
<da-form-editor @update=${this.handleUpdate} .formModel=${this.formModel}></da-form-editor>
<da-form-preview .formModel=${this.formModel}></da-form-preview>
</div>`;
}

render() {
return html`
<div class="da-form-wrapper">
${this.formModel !== undefined ? this.renderFormEditor() : nothing}
<da-form-sidebar .formModel=${this.formModel}></da-form-sidebar>
</div>
`;
}
}

customElements.define(EL_NAME, FormEditor);

function setDetails(parent, name, details) {
const cmp = document.createElement(name);
cmp.details = details;

if (name === 'da-title') {
cmp.previewPrefix = `${PREVIEW_PREFIX}/${details.owner}/${details.repo}`;
cmp.livePrefix = `${LIVE_PREFIX}/${details.owner}/${details.repo}`;
}

parent.append(cmp);
}

function setup(el) {
el.replaceChildren();
const details = getPathDetails();
setDetails(el, 'da-title', details);
setDetails(el, EL_NAME, details);
}

export default function init(el) {
setup(el);
window.addEventListener('hashchange', () => { setup(el); });
}
85 changes: 85 additions & 0 deletions nx/blocks/form/utils/generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Generate an object with empty values from a JSON Schema
* @param {object} schema - JSON Schema (draft 2020-12)
* @param {Set} requiredFields - Set of required field names (used internally)
* @param {object} rootSchema - Root schema for resolving $ref (used internally)
* @returns {object} - Object with empty values
*/
export default function generateEmptyObject(
schema,
requiredFields = new Set(),
rootSchema = schema,
) {
// Handle $ref references
if (schema.$ref) {
const refPath = schema.$ref.split('/').slice(1); // Remove leading #
let resolved = rootSchema;
for (const part of refPath) {
resolved = resolved[part];
}
return generateEmptyObject(resolved, requiredFields, rootSchema);
}

// Handle oneOf - take the first option
if (schema.oneOf) {
return generateEmptyObject(schema.oneOf[0], requiredFields, rootSchema);
}

// If field has enum values, return the first one if it's required
if (schema.enum && schema.enum.length > 0 && requiredFields.size > 0) {
return schema.enum[0];
}

const { type } = schema;

switch (type) {
case 'object': {
const obj = {};
if (schema.properties) {
// Create a set of required fields for child properties
const childRequired = new Set(schema.required || []);

for (const [key, propSchema] of Object.entries(schema.properties)) {
// Pass down whether this specific field is required
const isRequired = childRequired.has(key);
const reqSet = isRequired ? new Set([key]) : new Set();
obj[key] = generateEmptyObject(propSchema, reqSet, rootSchema);
}
}
return obj;
}

case 'array': {
// If array items have enum and array is required, include first item
if (schema.items?.enum && requiredFields.size > 0) {
return [schema.items.enum[0]];
}
// If array items are objects and array is required, generate one empty object
if (requiredFields.size > 0 && schema.items
&& (schema.items.type === 'object' || schema.items.properties)) {
return [generateEmptyObject(schema.items, new Set(), rootSchema)];
}
// If array items have a $ref, resolve it to check if it's an object
if (requiredFields.size > 0 && schema.items?.$ref) {
return [generateEmptyObject(schema.items, new Set(), rootSchema)];
}
return [];
}

case 'string':
return schema.enum && schema.enum.length > 0 ? schema.enum[0] : '';

case 'number':
case 'integer':
return schema.enum && schema.enum.length > 0 ? schema.enum[0] : 0;

case 'boolean':
return schema.enum && schema.enum.length > 0 ? schema.enum[0] : false;

case 'null':
return null;

default:
return null;
}
}
Loading