Scaffolding, base structure, base backend

This commit is contained in:
Alex Piqueras 2024-10-10 18:42:23 +02:00
commit a9eaa5dbde
14 changed files with 582 additions and 0 deletions

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

15
index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Prueba CGI</title>
<link rel="icon" href="./favicon.ico" type="image/x-icon">
</head>
<body>
<script type="module" src="src/main.js" defer></script>
<main id="root"></main>
</body>
</html>

3
serve.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
npx http-server .

View File

@ -0,0 +1,52 @@
import Store from '../modules/store.js';
import text from '../modules/text.js';
class AppBase extends HTMLElement {
static observedAttributes = ['title', 'description'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.text = text;
this.store = new Store();
}
connectedCallback() {
this.store.subscribe(this.render.bind(this));
this.render();
console.log('Component constructed', this);
}
disconnectedCallback() {
console.log('Component disconnected', this);
}
attributeChangedCallback() {
this.render();
}
renderText() {
const textList = this.store.getRecords();
const selected = this.store.getSelected();
return textList.map((item) => `<option value=${item.id} ${selected.includes(item.id) && "selected"}>${item.text}</option>`).join('\n');
}
render() {
this.shadowRoot.innerHTML = `
<main-window title="${this.text.title}" description="${this.text.description}">
<select multiple is="item-list">
${this.renderText()}
</select>
<span id="button-bar">
<custom-button action="${Store.actions.REMOVE_LINE}" color="#324bff">Delete</custom-button>
<custom-button action="${Store.actions.ADD_LINE}" color="#324bff">Add</custom-button>
<custom-button action="${Store.actions.UNDO}" outline color="#324bff">Undo</custom-button>
</span>
</main-window>
`;
}
}
customElements.define('app-base', AppBase);

View File

@ -0,0 +1,60 @@
import Store from '../modules/store.js';
class CustomButton extends HTMLElement {
static observedAttributes = ['outline', 'color', 'action'];
constructor() {
super();
this.publish = Store.publish;
this.attachShadow({ mode: 'open' });
}
onClick(e) {
this.publish(this.getAttribute('action'));
}
connectedCallback() {
this.render();
this.shadowRoot.getElementById('button').addEventListener('click', this.onClick.bind(this));
console.log('Component constructed', this);
}
disconnectedCallback() {
this.shadowRoot.getElementById('button').removeEventListener('click', this.onClick);
console.log('Component disconnected', this);
}
attributeChangedCallback() {
this.render();
}
render() {
const isOutline = this.getAttribute('outline') !== null && this.getAttribute('outline') !== 'false';
this.shadowRoot.innerHTML = `
<style>
button {
border-radius: 50px;
border-width: 1px;
border-style: ${isOutline ? 'solid' : 'hidden'};
border-color: ${this.getAttribute('color')};
opacity: 1;
width: 138px;
height: 49px;
background-color: ${isOutline ? 'white' : this.getAttribute('color')};
color: ${isOutline ? this.getAttribute('color') : 'white'};
text-align: center;
font-size: 16px;
font-family: "Montserrat", sans-serif;
font-weight: normal
letter-spacing: 0px;
text-transform: uppercase;
}
</style>
<button id="button"><slot></slot></button>
`;
}
}
customElements.define('custom-button', CustomButton);

View File

@ -0,0 +1,45 @@
import Store from '../modules/store.js';
class ItemList extends HTMLSelectElement {
constructor() {
super();
this.publish = Store.publish
}
connectedCallback() {
this.addEventListener('click', this.onChange.bind(this));
this.updateStyle();
console.log('Component constructed', this);
}
onChange() {
const selectedIds = Array.from(this.selectedOptions).map((option) => option.value);
this.publish(Store.actions.SELECT, selectedIds);
}
disconnectedCallback() {
this.removeEventListener('change', this.onChange.bind(this));
console.log('Component disconnected', this);
}
attributeChangedCallback() {
this.updateStyle();
}
updateStyle() {
this.style.width = '800px';
this.style.height = '227px';
this.style.background = '#F7F7F7 0% 0% no-repeat padding-box';
this.style.border = '1px solid #CCCCCC';
this.style.opacity = '1';
this.style.fontSize = '18px';
this.style.fontFamily = '"Montserrat" sans-serif';
this.style.fontWeight = 'normal';
this.style.letterSpacing = '0px';
}
}
customElements.define('item-list', ItemList, { extends: 'select' });

View File

@ -0,0 +1,83 @@
class MainWindow extends HTMLElement {
static observedAttributes = ['title', 'description'];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
console.log('Component constructed', this);
}
disconnectedCallback() {
console.log('Component disconnected', this);
}
attributeChangedCallback() {
this.render();
}
render() {
const title = this.getAttribute('title') || "No title";
const description = this.getAttribute('description') || "No description";
this.shadowRoot.innerHTML = `
<style>
div {
width: 900px;
height: 577px;
background: #FFFFFF 0% 0% no-repeat padding-box;
box-shadow: 0px 5px 12px #0000001F;
border-radius: 20px;
opacity: 1;
}
span {
width: 800px;
margin: 0 50px 13px 50px;
}
h1 {
top: 221px;
margin: 50px 50px 13px 50px;
height: 49px;
text-align: center;
font-size: 40px;
font-family: "Montserrat", sans-serif;
font-weight: normal;
letter-spacing: 0px;
color: #333333;
opacity: 1;
}
p {
margin: 0 50px 0 50px;
text-align: center;
height: 74px;
/* UI Properties */
text-align: center;
font-size: 18px;
font-family: "Montserrat", sans-serif;
font-weight: normal;
letter-spacing: 0px;
color: #333333;
opacity: 1;
}
slot {
margin: 0 50px 0 50px;
}
</style>
<div>
<h1>${title}</h1>
<p>${description}</p>
<span><slot></slot></span>
</div>
`;
}
}
customElements.define('main-window', MainWindow);

34
src/main.js Normal file
View File

@ -0,0 +1,34 @@
import './components/app-base.js';
import './components/main-window.js';
import './components/custom-button.js';
import './components/item-list.js';
class App {
constructor(root, text, store) {
this.root = root;
}
start() {
this.root.innerHTML = `
<style>
html {
height: 100%;
}
body {
background: transparent linear-gradient(135deg, #A1C4FD 0%, #C2E9FB 100%) 0% 0% no-repeat padding-box;
opacity: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 100%;
}
</style>
<app-base></app-base>
`;
}
}
const app = new App(document.getElementById('root'));
app.start();

95
src/modules/store.js Normal file
View File

@ -0,0 +1,95 @@
export default class Store {
static actions = {
ADD_LINE: 'ADD_LINE',
REMOVE_LINE: 'REMOVE_LINE',
UNDO: 'UNDO',
SELECT: 'SELECT',
}
constructor() {
this.state = {
records: [],
selected: [],
};
this.callbacks = [];
Object.values(this.constructor.actions).forEach(action => document.addEventListener(action, this.eventHandler.bind(this), false));
}
subscribe(callback) {
this.callbacks = [ ...this.callbacks, callback ];
}
static publish(action, data = {}) {
if (!Object.values(Store.actions).includes(action)) {
console.error(`Action ${action} is not valid`);
return;
}
const event = new CustomEvent(action, { detail: data });
document.dispatchEvent(event);
}
eventHandler(event) {
if (!Object.values(this.constructor.actions).includes(event.type)) {
console.error(`Action "${event.type}" is not valid`);
return;
}
switch (event.type) {
case this.constructor.actions.ADD_LINE:
this.state = {
...this.state,
records: this.state.records.concat({
id: this.state.records.length,
text: 'asdf',
action: 'add',
}),
};
break;
case this.constructor.actions.REMOVE_LINE:
this.state = {
...this.state,
records: this.records.concat({
id: this.state.records.length,
action: 'remove',
}),
};
break;
case this.constructor.actions.UNDO:
this.state = {
...this.state,
records: this.state.records.slice(0, -1),
}
break;
case this.constructor.actions.SELECT:
this.state = {
...this.state,
selected: event.detail,
}
break;
default:
console.error(`Action "${event.type}" not implemented in eventHandler`);
return;
}
this.callbacks.forEach((cb) => cb());
}
getRecords() {
return this.state.records.reduce((acc, current) => {
if (current.action === "add") {
return acc.concat({id: current.id, text: current.text});
}
if (current.action === "remove") {
return acc.filter((item) => item.id !== current.targetId);
}
console.error(`Action ${current.action} not valid`);
}, []);
}
getSelected() {
return this.state.selected;
}
}

4
src/modules/text.js Normal file
View File

@ -0,0 +1,4 @@
export default {
title: 'This is a technical proof',
description: 'Lorem ipsum dolor sit amet consectetur adipiscing, elit mus primis nec inceptos. Lacinia habitasse arcu molestie maecenas cursus quam nunc, hendrerit posuere augue fames dictumst placerat porttitor, dis mi pharetra vestibulum venenatis phasellus.',
}

0
src/style.css Normal file
View File

17
test/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Test suite</title>
</head>
<body>
<script type="module" src="./main.js" defer></script>
<pre id=root>
Test suite
</pre>
</body>
</html>

50
test/main.js Normal file
View File

@ -0,0 +1,50 @@
import StoreTest from './modules/store.spec.js';
class Test {
constructor(root) {
this.root = root;
this.tests = [];
this.pass = 0
this.fail = 0
}
start() {
this.defineTests();
this.tests.forEach((testClass) => {
this.log(`Testing ${testClass.constructor.name}`);
testClass.test();
}
);
this.log(`
Passed ${this.pass} tests
Failed ${this.fail} tests
`);
}
defineTests() {
this.tests = [
new StoreTest(this.assert.bind(this)),
];
}
assert(description, current, expected) {
const sameType = typeof current === typeof expected;
const sameValue = JSON.stringify(current) === JSON.stringify(expected);
let result = '[ERROR]';
if (sameType && sameValue) {
this.log(` [PASS] ${description}`);
this.pass++;
} else {
this.log(` [FAIL] ${description}. Expected (${typeof expected}) ${JSON.stringify(expected)}, got (${typeof current}) ${JSON.stringify(current)}`);
this.fail++;
}
}
log(text) {
this.root.insertAdjacentText("beforeend", `${text}\n`);
}
}
const testSuite = new Test(document.getElementById('root'));
testSuite.start();

124
test/modules/store.spec.js Normal file
View File

@ -0,0 +1,124 @@
import Store from '../../src/modules/store.js';
export default class StoreTest {
constructor(assert) {
this.assert = assert;
}
test() {
(() => {
let called = false
const store = new Store(() => called = true);
const event = new Event('addLine');
store.eventHandler(event);
this.assert("eventHandler calls the callback after successfull event", called, true);
})();
(() => {
const store = new Store(() => {});
store.records = [
{id: 0, text: "asdf", action: 'add'},
{id: 1, text: "qwer", action: 'add'},
{id: 2, action: 'remove'},
]
const event = new Event('unknown');
store.eventHandler(event);
const expected = [
{id: 0, text: "asdf", action: 'add'},
{id: 1, text: "qwer", action: 'add'},
{id: 2, action: 'remove'},
];
this.assert("eventHandler does nothing on an unknown event type", store.records, expected);
})();
(() => {
const store = new Store(() => {});
const event = new Event('addLine');
store.eventHandler(event);
const expected = [
{id: 0, text: "asdf", action: 'add'},
];
this.assert("eventHandler adds the appropiate record when Event 'addLine' is passed", store.records, expected);
})();
(() => {
const store = new Store(() => {});
store.records = [
{id: 0, text: "asdf", action: 'add'},
]
const event = new Event('removeLine');
store.eventHandler(event);
const expected = [
{id: 0, text: "asdf", action: 'add'},
{id: 1, action: 'remove'},
];
this.assert("eventHandler adds the appropiate record when Event 'removeLine' is passed", store.records, expected);
})();
(() => {
const store = new Store(() => {});
store.records = [
{id: 0, text: "asdf", action: 'add'},
{id: 1, text: "qwer", action: 'add'},
{id: 2, action: 'remove'},
]
const event = new Event('undo');
store.eventHandler(event);
const expected = [
{id: 0, text: "asdf", action: 'add'},
{id: 1, text: "qwer", action: 'add'},
];
this.assert("eventHandler deletes the appropiate record when Event 'undo' is passed", store.records, expected);
})();
(() => {
const store = new Store(() => {});
store.records = [
{id: 0, text: "Line 1", action: 'add'},
{id: 1, text: "Line 2", action: 'add'},
{id: 2, text: "Line 3", action: 'add'},
];
const expected = [
{id: 0, text: "Line 1"},
{id: 1, text: "Line 2"},
{id: 2, text: "Line 3"},
];
this.assert("getState returns an array of strings with 3 add records", store.getState(), expected);
})();
(() => {
const store = new Store(() => {});
store.records = [
{id: 0, text: "Line 1", action: 'add'},
{id: 1, text: "Line 2", action: 'add'},
{id: 2, text: "Line 3", action: 'add'},
{id: 3, targetId: 1, action: 'remove'},
];
const expected = [
{id: 0, text: "Line 1"},
{id: 2, text: "Line 3"},
];
this.assert("getState returns an array of strings with 3 add records and a delete record", store.getState(), expected);
})();
}
}