This commit is contained in:
SowinskiBraeden committed 2026-10-18 21:27:59 -07:00
1 parent b8a795060b
commit 824e10191b
16 files changed
+1171

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
:root {
color: #172033;
background-color: #f5f7fb;
font-family: Arial, Helvetica, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
main {
width: min(92%, 52rem);
margin: 3rem auto;
}
h1 {
margin: 0;
font-size: clamp(1.8rem, 5vw, 2.6rem);
}
h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.page-header {
margin-bottom: 2rem;
text-align: center;
}
.student-name {
margin: 0.75rem 0 0;
color: #536078;
font-size: 1.15rem;
}
.panel {
margin-bottom: 1.5rem;
padding: 1.5rem;
background-color: white;
border: 1px solid #d8deea;
border-radius: 0.75rem;
box-shadow: 0 0.75rem 2rem rgb(23 32 51 / 8%);
}
.description {
margin: 0 0 1rem;
color: #536078;
}
.query-label {
display: block;
margin-bottom: 0.4rem;
font-weight: 600;
}
.query-input {
display: block;
width: 100%;
margin-bottom: 1rem;
padding: 0.8rem;
color: #172033;
background-color: #eef3ff;
border: 1px solid #8398c2;
border-radius: 0.35rem;
font-family: "Courier New", Courier, monospace;
font-size: 1rem;
resize: vertical;
}
button {
padding: 0.65rem 1rem;
border: 0;
border-radius: 0.35rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.action-button {
color: white;
background-color: #315fc4;
}
.action-button:disabled {
background-color: #8398c2;
cursor: progress;
}
.status {
min-height: 1.2rem;
margin: 0 0 1rem;
color: #536078;
}
.response {
overflow-x: auto;
}
.result-table {
width: 100%;
border-collapse: collapse;
}
.result-table th,
.result-table td {
padding: 0.6rem 0.8rem;
border: 1px solid #d8deea;
text-align: left;
white-space: nowrap;
}
.result-table th {
color: white;
background-color: #315fc4;
}
.result-table tbody tr:nth-child(even) {
background-color: #eef3ff;
}
.success-message {
margin: 0;
color: #2f7a42;
font-weight: 600;
}
.error-message {
margin: 0;
color: #c43e45;
font-weight: 600;
overflow-wrap: anywhere;
}
.empty-message {
margin: 0;
color: #536078;
}
@media (max-width: 36rem) {
main {
margin: 1rem auto;
}
.panel {
padding: 1.25rem;
}
.action-button {
width: 100%;
}
}
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<main class="lab-page">
<header class="page-header">
<h1 id="page-title"></h1>
<p id="student-name" class="student-name"></p>
</header>
<section class="panel" aria-labelledby="insert-heading">
<h2 id="insert-heading"></h2>
<p id="insert-description" class="description"></p>
<button id="insert-button" class="action-button" type="button"></button>
</section>
<section class="panel" aria-labelledby="query-heading">
<h2 id="query-heading"></h2>
<p id="query-description" class="description"></p>
<label id="query-label" class="query-label" for="query-input"></label>
<textarea id="query-input" class="query-input" rows="5"></textarea>
<button id="query-button" class="action-button" type="button"></button>
</section>
<section class="panel" aria-labelledby="response-heading">
<h2 id="response-heading"></h2>
<p id="status" class="status" aria-live="polite"></p>
<div id="response" class="response" aria-live="polite"></div>
</section>
</main>
<script type="module" src="./js/script.js"></script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
import { STRINGS } from "../lang/messages/en/user.js";
// server 2 lives on its own origin, change this one line when it moves
const API_BASE_URL = "http://localhost:8080";
const SQL_ROUTE = "/lab5/api/v1/sql";
const SELECT_PATTERN = /^select\b/i;
const INSERT_PATTERN = /^insert\b/i;
class SqlStatement {
constructor(text) {
this.text = text.trim().replace(/;+$/, "").trim();
}
isEmpty() {
return this.text.length === 0;
}
isSelect() {
return SELECT_PATTERN.test(this.text);
}
isInsert() {
return INSERT_PATTERN.test(this.text);
}
// a semicolon inside a quoted literal is data, not a second statement
isSingleStatement() {
const withoutLiterals = this.text
.replace(/'(?:[^']|'')*'/g, "''")
.replace(/"(?:[^"]|"")*"/g, '""');
return !withoutLiterals.includes(";");
}
toString() {
return this.text;
}
}
class ApiClient {
constructor(baseUrl, route) {
this.baseUrl = baseUrl;
this.route = route;
}
getRouteUrl() {
return `${this.baseUrl}${this.route}`;
}
buildQueryUrl(statement) {
return `${this.getRouteUrl()}/${encodeURIComponent(statement.toString())}`;
}
select(statement) {
return this.send(this.buildQueryUrl(statement), {
method: "GET",
});
}
insert(statement) {
return this.send(this.getRouteUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: statement.toString() }),
});
}
// an empty body tells server 2 to insert the four sample patients
insertSamplePatients() {
return this.send(this.getRouteUrl(), {
method: "POST",
});
}
async send(url, options) {
const response = await fetch(url, options);
const body = await response.text();
let payload;
try {
payload = JSON.parse(body);
} catch {
payload = { success: false, error: body };
}
return {
ok: response.ok,
status: response.status,
payload: payload,
};
}
}
class ResponseView {
constructor(responseElement, statusElement) {
this.responseElement = responseElement;
this.statusElement = statusElement;
}
showStatus(message) {
this.statusElement.textContent = message;
}
clear() {
this.statusElement.textContent = "";
this.responseElement.replaceChildren();
}
showMessage(message, className) {
const paragraph = document.createElement("p");
paragraph.classList.add(className);
paragraph.textContent = message;
this.responseElement.replaceChildren(paragraph);
}
showError(message) {
this.showMessage(message, "error-message");
}
showRows(rows) {
if (rows.length === 0) {
this.showMessage(STRINGS.NO_ROWS, "empty-message");
return;
}
this.responseElement.replaceChildren(this.buildTable(rows));
}
buildTable(rows) {
const table = document.createElement("table");
const head = document.createElement("thead");
const body = document.createElement("tbody");
const columns = Object.keys(rows[0]);
table.classList.add("result-table");
head.appendChild(this.buildRow(columns, "th", (column) => column));
for (const row of rows) {
body.appendChild(this.buildRow(columns, "td", (column) => row[column]));
}
table.append(head, body);
return table;
}
buildRow(columns, cellTag, getValue) {
const tableRow = document.createElement("tr");
for (const column of columns) {
const cell = document.createElement(cellTag);
cell.textContent = this.formatValue(getValue(column));
tableRow.appendChild(cell);
}
return tableRow;
}
formatValue(value) {
if (value === null || value === undefined) {
return STRINGS.NULL_VALUE;
}
return String(value);
}
}
class LabPage {
constructor(api) {
this.api = api;
this.insertButton = document.getElementById("insert-button");
this.queryButton = document.getElementById("query-button");
this.queryInput = document.getElementById("query-input");
this.view = new ResponseView(
document.getElementById("response"),
document.getElementById("status")
);
}
init() {
document.title = STRINGS.PAGE_TITLE;
document.getElementById("page-title").textContent = STRINGS.PAGE_TITLE;
document.getElementById("student-name").textContent = STRINGS.STUDENT_NAME;
document.getElementById("insert-heading").textContent = STRINGS.INSERT_HEADING;
document.getElementById("insert-description").textContent = STRINGS.INSERT_DESCRIPTION;
document.getElementById("query-heading").textContent = STRINGS.QUERY_HEADING;
document.getElementById("query-description").textContent = STRINGS.QUERY_DESCRIPTION;
document.getElementById("query-label").textContent = STRINGS.QUERY_LABEL;
document.getElementById("response-heading").textContent = STRINGS.RESPONSE_HEADING;
this.insertButton.textContent = STRINGS.INSERT_BUTTON;
this.queryButton.textContent = STRINGS.QUERY_BUTTON;
this.queryInput.placeholder = STRINGS.QUERY_PLACEHOLDER;
this.view.showMessage(STRINGS.RESPONSE_EMPTY, "empty-message");
this.insertButton.addEventListener("click", () => this.handleInsert());
this.queryButton.addEventListener("click", () => this.handleQuery());
}
handleInsert() {
this.sendRequest(() => this.api.insertSamplePatients());
}
handleQuery() {
const statement = new SqlStatement(this.queryInput.value);
if (statement.isEmpty()) {
this.view.clear();
this.view.showError(STRINGS.EMPTY_QUERY);
return;
}
if (!statement.isSingleStatement()) {
this.view.clear();
this.view.showError(STRINGS.SINGLE_STATEMENT_ONLY);
return;
}
// reads go out as GET, inserts as POST. anything else still goes out
// as GET so the read only database user is the one that rejects it.
if (statement.isInsert()) {
this.sendRequest(() => this.api.insert(statement));
} else {
this.sendRequest(() => this.api.select(statement));
}
}
async sendRequest(request) {
this.view.clear();
this.view.showStatus(STRINGS.SENDING);
this.setBusy(true);
try {
this.showResult(await request());
} catch (error) {
this.view.showStatus("");
this.view.showError(STRINGS.NETWORK_ERROR + error.message);
} finally {
this.setBusy(false);
}
}
showResult(result) {
const payload = result.payload;
if (!result.ok || payload.success === false) {
this.view.showStatus("");
this.view.showError(STRINGS.SERVER_ERROR + (payload.error || result.status));
return;
}
if (Array.isArray(payload.data)) {
this.view.showStatus(STRINGS.ROWS_RETURNED + payload.data.length);
this.view.showRows(payload.data);
return;
}
this.view.showStatus(STRINGS.ROWS_INSERTED + (payload.rowCount ?? 0));
this.view.showMessage(payload.message, "success-message");
}
setBusy(isBusy) {
this.insertButton.disabled = isBusy;
this.queryButton.disabled = isBusy;
}
}
const api = new ApiClient(API_BASE_URL, SQL_ROUTE);
new LabPage(api).init();
+28
View File
@@ -0,0 +1,28 @@
export const STRINGS = {
PAGE_TITLE: "Lab 5: DB Intro",
STUDENT_NAME: "Braeden Sowinski",
INSERT_HEADING: "Insert Patients",
INSERT_DESCRIPTION: "Sends a POST request to server 2 that inserts the four sample patients. Press it as often as you like, the table keeps growing.",
INSERT_BUTTON: "Insert Rows",
QUERY_HEADING: "Run a Query",
QUERY_DESCRIPTION: "SELECT statements are sent with GET, INSERT statements are sent with POST. Anything else is still sent, and the read only database user is what refuses it.",
QUERY_LABEL: "SQL query",
QUERY_PLACEHOLDER: "SELECT * FROM patient",
QUERY_BUTTON: "Send Query",
RESPONSE_HEADING: "Server Response",
RESPONSE_EMPTY: "No request has been sent yet.",
SENDING: "Sending request...",
ROWS_RETURNED: "Rows returned: ",
ROWS_INSERTED: "Rows inserted: ",
NO_ROWS: "The query returned no rows.",
NULL_VALUE: "NULL",
EMPTY_QUERY: "Please enter a SQL query first.",
SINGLE_STATEMENT_ONLY: "Please send one statement at a time.",
SERVER_ERROR: "Server responded with an error: ",
NETWORK_ERROR: "Could not reach the API server: ",
};