diff --git a/pscompose/api/api.py b/pscompose/api/api.py index 236f997..40101f7 100644 --- a/pscompose/api/api.py +++ b/pscompose/api/api.py @@ -11,6 +11,7 @@ tasks, templates, tests, + home, ) # initialize FastAPI application @@ -25,7 +26,18 @@ ) # include submodule routers -for lib in [addresses, archives, basic_auth, contexts, groups, schedules, tasks, templates, tests]: +for lib in [ + addresses, + archives, + basic_auth, + contexts, + groups, + schedules, + tasks, + templates, + tests, + home, +]: app.include_router(lib.router) diff --git a/pscompose/api/routers/addresses.py b/pscompose/api/routers/addresses.py index 6ba0fd9..663a7b7 100644 --- a/pscompose/api/routers/addresses.py +++ b/pscompose/api/routers/addresses.py @@ -62,6 +62,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Address with id: {item_id} not found") diff --git a/pscompose/api/routers/archives.py b/pscompose/api/routers/archives.py index ecf01b3..291b7e6 100644 --- a/pscompose/api/routers/archives.py +++ b/pscompose/api/routers/archives.py @@ -55,6 +55,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Address with id: {item_id} not found") diff --git a/pscompose/api/routers/contexts.py b/pscompose/api/routers/contexts.py index 75b76f7..1165698 100644 --- a/pscompose/api/routers/contexts.py +++ b/pscompose/api/routers/contexts.py @@ -53,6 +53,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Address with id: {item_id} not found") diff --git a/pscompose/api/routers/groups.py b/pscompose/api/routers/groups.py index 5371788..fca6a98 100644 --- a/pscompose/api/routers/groups.py +++ b/pscompose/api/routers/groups.py @@ -99,6 +99,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Group with id: {item_id} not found") diff --git a/pscompose/api/routers/home.py b/pscompose/api/routers/home.py new file mode 100644 index 0000000..7943ad2 --- /dev/null +++ b/pscompose/api/routers/home.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from pscompose.backends.postgres import backend + +# Setup CRUD endpoints +router = APIRouter(tags=["home"]) + + +@router.get( + "/api/recently_edited", + summary="Return first 3 items of most recently edited", +) +def recently_edited(): + rows = backend.get_recently_edited(3) + return rows + + +@router.get( + "/api/favorites", + summary="Return first 3 items of favorites", +) +def favorites(): + rows = backend.get_favorites(3) + return rows diff --git a/pscompose/api/routers/schedules.py b/pscompose/api/routers/schedules.py index 7da043e..8180c0b 100644 --- a/pscompose/api/routers/schedules.py +++ b/pscompose/api/routers/schedules.py @@ -34,6 +34,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Address with id: {item_id} not found") diff --git a/pscompose/api/routers/tasks.py b/pscompose/api/routers/tasks.py index fd43ec3..e18dee6 100644 --- a/pscompose/api/routers/tasks.py +++ b/pscompose/api/routers/tasks.py @@ -55,6 +55,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Task with id: {item_id} not found") diff --git a/pscompose/api/routers/templates.py b/pscompose/api/routers/templates.py index 20ad91d..aa303e4 100644 --- a/pscompose/api/routers/templates.py +++ b/pscompose/api/routers/templates.py @@ -36,6 +36,7 @@ def get_existing_form(item_id: str): response = backend.get_datatype(datatype=DataTypes.TEMPLATE, item_id=item_id) response_json = response.json response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Address with id: {item_id} not found") diff --git a/pscompose/api/routers/tests.py b/pscompose/api/routers/tests.py index 76e3ac8..6350ee2 100644 --- a/pscompose/api/routers/tests.py +++ b/pscompose/api/routers/tests.py @@ -252,6 +252,7 @@ def get_existing_form(item_id: str): } # Need to remove null fields response_json["name"] = response.name # Adding "name" since it's not present in the json + response_json["favorited"] = response.favorited except HTTPException: raise HTTPException(status_code=404, detail=f"Test with id: {item_id} not found") diff --git a/pscompose/backends/postgres.py b/pscompose/backends/postgres.py index 8de5ab9..7284a3d 100644 --- a/pscompose/backends/postgres.py +++ b/pscompose/backends/postgres.py @@ -15,7 +15,9 @@ def __init__(self): DataTable.__table__.create(bind=engine, checkfirst=True) self.session = sessionmaker(bind=engine)() - def create_datatype(self, ref_set, datatype, json, name, created_by, last_edited_by): + def create_datatype( + self, ref_set, datatype, json, name, created_by, last_edited_by, favorited + ): try: new_type = DataTable( ref_set=ref_set, @@ -24,6 +26,7 @@ def create_datatype(self, ref_set, datatype, json, name, created_by, last_edited name=name, created_by=created_by, last_edited_by=last_edited_by, + favorited=favorited, # created_at = created_at, # last_edited_at = last_edited_at, # url = url @@ -61,6 +64,7 @@ def update_datatype(self, existing_result, updated_data): "ref_set": existing_result.ref_set, "last_edited_by": existing_result.last_edited_by, "last_edited_at": existing_result.last_edited_at, + "favorited": existing_result.favorited, } except Exception as e: self.session.rollback() @@ -179,5 +183,20 @@ def get_datatype(self, datatype, item_id): else: return result + def get_recently_edited(self, limit: int = 5): + query = self.session.query(DataTable).order_by(DataTable.created_at.desc()).limit(limit) + rows = query.all() + return [row for row in rows] + + def get_favorites(self, limit: int = 5): + query = ( + self.session.query(DataTable) + .filter_by(favorited=True) + .order_by(DataTable.created_at.desc()) + .limit(limit) + ) + rows = query.all() + return [row for row in rows] + backend = PostgresBackend() diff --git a/pscompose/form_schemas.py b/pscompose/form_schemas.py index 3e4f281..0706be4 100644 --- a/pscompose/form_schemas.py +++ b/pscompose/form_schemas.py @@ -89,12 +89,12 @@ { "type": "Control", "scope": "#/properties/lead-bind-address", - "customComponent": "input-text", + "customComponent": "input-text-autocomplete", }, { "type": "Control", "scope": "#/properties/pscheduler-address", - "customComponent": "input-text", + "customComponent": "input-text-autocomplete", }, { "type": "Control", diff --git a/pscompose/frontend/app/index.html b/pscompose/frontend/app/index.html index 29d2af5..fe56d14 100644 --- a/pscompose/frontend/app/index.html +++ b/pscompose/frontend/app/index.html @@ -42,8 +42,10 @@ + + @@ -246,7 +248,7 @@ icon: "locate", }, ], - addresses: { + address: { singular: "Address", plural: "Addresses", page_url: "/addresses/", @@ -254,7 +256,7 @@ list_endpoint: "http://localhost:8000/api/address/", icon: "hard-drive-download", }, - archives: { + archive: { singular: "Archive", plural: "Archives", page_url: "/archives/", @@ -262,7 +264,7 @@ list_endpoint: "http://localhost:8000/api/archive/", icon: "database", }, - contexts: { + context: { singular: "Context", plural: "Contexts", page_url: "/contexts/", @@ -270,7 +272,7 @@ list_endpoint: "http://localhost:8000/api/context/", icon: "file-question", }, - groups: { + group: { singular: "Group", plural: "Groups", page_url: "/groups/", @@ -278,7 +280,7 @@ list_endpoint: "http://localhost:8000/api/group/", icon: "group", }, - schedules: { + schedule: { singular: "Schedule", plural: "Schedules", page_url: "/schedules/", @@ -286,7 +288,7 @@ list_endpoint: "http://localhost:8000/api/schedule/", icon: "calendar-check", }, - tasks: { + task: { singular: "Task", plural: "Tasks", page_url: "/tasks/", @@ -294,7 +296,7 @@ list_endpoint: "http://localhost:8000/api/task/", icon: "clipboard-list", }, - templates: { + template: { singular: "Template", plural: "Templates", page_url: "/templates/", @@ -302,7 +304,7 @@ list_endpoint: "http://localhost:8000/api/template/", icon: "locate", }, - tests: { + test: { singular: "Test", plural: "Tests", page_url: "/tests/", @@ -313,14 +315,14 @@ }, }; psCompose.menu = { - addresses: psCompose.metadata["addresses"], - archives: psCompose.metadata["archives"], - contexts: psCompose.metadata["contexts"], - groups: psCompose.metadata["groups"], - schedules: psCompose.metadata["schedules"], - tasks: psCompose.metadata["tasks"], - templates: psCompose.metadata["templates"], - tests: psCompose.metadata["tests"], + address: psCompose.metadata["address"], + archive: psCompose.metadata["archive"], + context: psCompose.metadata["context"], + group: psCompose.metadata["group"], + schedule: psCompose.metadata["schedule"], + task: psCompose.metadata["task"], + template: psCompose.metadata["template"], + test: psCompose.metadata["test"], }; psCompose.router = { "/wizard/1/addresses/": psCompose.metadata["wizard"][0], @@ -333,22 +335,22 @@ "/wizard/8/template/": psCompose.metadata["wizard"][7], "/import/1/import_template/": psCompose.metadata["import"][0], "/import/2/import_template_saved/": psCompose.metadata["import"][1], - "/addresses/": psCompose.metadata["addresses"], - "/addresses/new/": psCompose.metadata["addresses"], - "/archives/": psCompose.metadata["archives"], - "/archives/new/": psCompose.metadata["archives"], - "/contexts/": psCompose.metadata["contexts"], - "/contexts/new/": psCompose.metadata["contexts"], - "/groups/": psCompose.metadata["groups"], - "/groups/new/": psCompose.metadata["groups"], - "/schedules/": psCompose.metadata["schedules"], - "/schedules/new/": psCompose.metadata["schedules"], - "/tasks/": psCompose.metadata["tasks"], - "/tasks/new/": psCompose.metadata["tasks"], - "/templates/": psCompose.metadata["templates"], - "/templates/new/": psCompose.metadata["templates"], - "/tests/": psCompose.metadata["tests"], - "/tests/new/": psCompose.metadata["tests"], + "/addresses/": psCompose.metadata["address"], + "/addresses/new/": psCompose.metadata["address"], + "/archives/": psCompose.metadata["archive"], + "/archives/new/": psCompose.metadata["archive"], + "/contexts/": psCompose.metadata["context"], + "/contexts/new/": psCompose.metadata["context"], + "/groups/": psCompose.metadata["group"], + "/groups/new/": psCompose.metadata["group"], + "/schedules/": psCompose.metadata["schedule"], + "/schedules/new/": psCompose.metadata["schedule"], + "/tasks/": psCompose.metadata["task"], + "/tasks/new/": psCompose.metadata["task"], + "/templates/": psCompose.metadata["template"], + "/templates/new/": psCompose.metadata["template"], + "/tests/": psCompose.metadata["test"], + "/tests/new/": psCompose.metadata["test"], }; // the actual URL router @@ -358,7 +360,7 @@ return "/pages/index.html"; } psCompose.activeRoute = psCompose.router[path]; - psCompose.activeMenuItem = path.split("/")[1]; + psCompose.activeMenuItem = psCompose.router[path].singular.toLowerCase(); psCompose.subsection = path.split("/")[2]; psCompose.pathParts = path.split("/"); diff --git a/pscompose/frontend/components/dropdown-single-select.js b/pscompose/frontend/components/dropdown-single-select.js index fda4fc1..d313302 100644 --- a/pscompose/frontend/components/dropdown-single-select.js +++ b/pscompose/frontend/components/dropdown-single-select.js @@ -102,6 +102,13 @@ export class SingleSelectDropdown extends FormControl { li.addEventListener("click", (e) => this.handleOptionClick(e)); this.optionsEl.appendChild(li); }); + document.addEventListener("click", (e) => this.closeDropdownOutside(e)); + } + + closeDropdownOutside(e) { + if (!this.contains(e.target)) { + this.optionsEl.classList.remove("open"); + } } render() { diff --git a/pscompose/frontend/components/input-checkbox-star.js b/pscompose/frontend/components/input-checkbox-star.js new file mode 100644 index 0000000..483a5a2 --- /dev/null +++ b/pscompose/frontend/components/input-checkbox-star.js @@ -0,0 +1,62 @@ +class InputCheckboxStar extends HTMLElement { + static get observedAttributes() { + return ["value", "disabled"]; + } + + get value() { + return this._value === true; + } + + set value(v) { + this._value = Boolean(v); + this.setAttribute("value", this._value); + } + + get disabled() { + return this.hasAttribute("disabled"); + } + + set disabled(v) { + v ? this.setAttribute("disabled", "") : this.removeAttribute("disabled"); + } + + constructor() { + super(); + } + + connectedCallback() { + this.render(); + lucide.createIcons(); + } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue == newValue) return; + this.render(); + lucide.createIcons(); + } + + toggle() { + if (this.hasAttribute("disabled")) return; + + this.value = !this.value; + this.dispatchEvent(new Event("change", { bubbles: true })); + } + + attachEventListener() { + this.starEl.addEventListener("click", () => this.toggle()); + } + + render() { + const disabled = this.hasAttribute("disabled"); + + this.innerHTML = ``; + this.starEl = this.querySelector("button"); + this.attachEventListener(); + } +} + +customElements.define("input-checkbox-star", InputCheckboxStar); diff --git a/pscompose/frontend/components/input-checkbox.js b/pscompose/frontend/components/input-checkbox.js index 992fff8..48bceee 100644 --- a/pscompose/frontend/components/input-checkbox.js +++ b/pscompose/frontend/components/input-checkbox.js @@ -1,6 +1,6 @@ import { InputText } from "./input-text.js"; -class InputCheckbox extends InputText { +export class InputCheckbox extends InputText { render() { super.render(); this.inputEl.checked = this.value === "true" || this.value === true; diff --git a/pscompose/frontend/components/input-text-autocomplete.js b/pscompose/frontend/components/input-text-autocomplete.js new file mode 100644 index 0000000..6a904a9 --- /dev/null +++ b/pscompose/frontend/components/input-text-autocomplete.js @@ -0,0 +1,105 @@ +import { InputText } from "./input-text.js"; + +class InputTextAutocomplete extends InputText { + constructor() { + super(); + this.slotEl = ` + + `; + this.inputEl = null; + this.wrapperEl = null; + this.optionsEl = null; + this.actionBtn = null; + this.autocompleteOptions = [ + "{% address[0] %}", + "{% address[1] %}", + "{% pscheduler_address[0] %}", + "{% pscheduler_address[1] %}", + "{% flip %}", + "{% localhost %}", + "{% scheduled_by_address %}", + "{% lead_bind_address[0] %}", + "{% lead_bind_address[1] %}", + ]; + } + + attachAutocompleteEventListener() { + this.inputEl.addEventListener("input", (e) => this.handleInput(e)); + document.addEventListener("click", (e) => this.closeDropdownOutside(e)); + } + + handleInput(e) { + const value = e.target.value; + const cursorPos = e.target.selectionStart; + + const beforeCursor = value.slice(0, cursorPos); + if (!beforeCursor.trim().startsWith("{%")) { + this.closeDropdown(); + return; + } + + const typed = beforeCursor.replace("{%", "").trim().toLowerCase(); + this.showAutocompleteDropdown(typed); + } + + showAutocompleteDropdown(filterText = "") { + this.optionsEl.innerHTML = ""; + this.optionsEl.classList.add("open"); + + const filtered = this.autocompleteOptions.filter((opt) => + opt.toLowerCase().includes(filterText), + ); + + if (filtered.length === 0) { + this.closeDropdown(); + return; + } + + filtered.forEach((option) => { + const li = document.createElement("li"); + li.textContent = option; + li.dataset.value = option; + + li.addEventListener("mousedown", (e) => { + e.preventDefault(); // prevents blur + this.insertAutocompleteValue(option); + }); + + this.optionsEl.appendChild(li); + }); + } + + insertAutocompleteValue(value) { + this.inputEl.value = value; + this.inputEl.dispatchEvent(new Event("change", { bubbles: true })); + this.closeDropdown(); + } + + closeDropdownOutside(e) { + if (!this.contains(e.target)) { + this.closeDropdown(); + } + } + + closeDropdown() { + this.optionsEl.classList.remove("open"); + this.optionsEl.innerHTML = ""; + } + + render() { + super.render(); + + this.inputEl = this.querySelector("input"); + this.wrapperEl = this.querySelector(".wrapper"); + this.optionsEl = this.querySelector(".options"); + + this.attachAutocompleteEventListener(); + } +} + +customElements.define("input-text-autocomplete", InputTextAutocomplete); diff --git a/pscompose/frontend/components/input-text.js b/pscompose/frontend/components/input-text.js index 61385cb..4bb7db6 100644 --- a/pscompose/frontend/components/input-text.js +++ b/pscompose/frontend/components/input-text.js @@ -28,8 +28,6 @@ export class InputText extends FormControl { } this.dispatchEvent(new Event("change", { bubbles: true })); }); - // this.inputEl?.addEventListener("input", () => this.markDirty(), { once: true }); - // this.inputEl?.addEventListener("blur", () => this.markDirty(), { once: true }); } render() { diff --git a/pscompose/frontend/css/web-components.css b/pscompose/frontend/css/web-components.css index 8ffd89e..6c69559 100644 --- a/pscompose/frontend/css/web-components.css +++ b/pscompose/frontend/css/web-components.css @@ -89,7 +89,7 @@ } /* INPUT-TEXT */ -.container .wrapper input { +.container input { border: none; appearance: none; outline: none; @@ -177,7 +177,7 @@ input[type="number"]::-webkit-outer-spin-button { padding: 0; } -.container input[type="checkbox"] { +.container .wrapper input[type="checkbox"] { width: 32px; height: 32px; } @@ -186,7 +186,7 @@ input[type="number"]::-webkit-outer-spin-button { background: var(--success-color); } -.container input[type="checkbox"]:checked::after { +.container .wrapper input[type="checkbox"]:checked::after { content: ""; position: absolute; left: 8px; @@ -204,6 +204,32 @@ input[type="number"]::-webkit-outer-spin-button { display: none; } +/* INPUT-CHECKBOX-STAR */ + +.star { + display: flex; + flex-direction: row; + align-items: center; + + width: 32px; + height: 32px; +} + +.star svg { + color: var(--copyAlt-color); + fill: var(--copyAlt-color); +} + +.star.checked svg { + fill: var(--warning-color); + stroke: var(--warning-color); +} + +.star[disabled] { + opacity: 0.8; + cursor: not-allowed; +} + /* INPUT-TEXT-AREA */ .container textarea { diff --git a/pscompose/frontend/pages/index.html b/pscompose/frontend/pages/index.html index 55c2e08..3c10f2c 100644 --- a/pscompose/frontend/pages/index.html +++ b/pscompose/frontend/pages/index.html @@ -157,3 +157,4 @@

Published Templates

+
diff --git a/pscompose/frontend/partials/customRenderer.js b/pscompose/frontend/partials/customRenderer.js index 32e975f..3a2c5ee 100644 --- a/pscompose/frontend/partials/customRenderer.js +++ b/pscompose/frontend/partials/customRenderer.js @@ -8,8 +8,10 @@ function toAllCaps(str) { const webComponents = [ "input-text", "input-text-area", + "input-text-autocomplete", "input-number", "input-checkbox", + "input-checkbox-star", "dropdown-single-select", "dropdown-multi-select", "dropdown-excludes", @@ -38,7 +40,7 @@ function createCustomRenderer(componentName) { ) || false, error: schema?.errors ?? false, - description: schema.schema?.description ?? false, + description: schema.schema?.description ?? undefined, value: data ?? schema.schema.default ?? undefined, }; @@ -85,27 +87,21 @@ document.body.addEventListener("json-form:beforeMount", (event) => { document.body.addEventListener("json-form:mounted", (event) => { if (event.detail[0].target.readonly == "true") { webComponents.forEach((component) => { - document - .querySelector("form") - .querySelectorAll(component) - .forEach((comp) => { - comp.disabled = true; - }); + document.querySelectorAll(component).forEach((comp) => { + comp.disabled = true; + }); }); } }); document.body.addEventListener("json-form:updated", (event) => { webComponents.forEach((component) => { - document - .querySelector("form") - .querySelectorAll(component) - .forEach((comp) => { - if (event.detail[0].target.readonly == "true") { - comp.disabled = true; - } else { - comp.disabled = false; - } - }); + document.querySelectorAll(component).forEach((comp) => { + if (event.detail[0].target.readonly == "true") { + comp.disabled = true; + } else { + comp.disabled = false; + } + }); }); }); diff --git a/pscompose/frontend/partials/data_edit_form.html b/pscompose/frontend/partials/data_edit_form.html index ec413df..27ac206 100644 --- a/pscompose/frontend/partials/data_edit_form.html +++ b/pscompose/frontend/partials/data_edit_form.html @@ -2,6 +2,7 @@

+
@@ -72,6 +73,8 @@

// Header document.getElementById("data-name").textContent = elem?.data?.name; + document.querySelector("input-checkbox-star").value = elem?.data?.favorited; + document .getElementById("edit-icon") .setAttribute("data-link", `${psCompose.activeRoute.page_url}?id=${id}&edit=true`); @@ -102,7 +105,8 @@

// 1. Retrieve jsonform data const elem = document.querySelector("json-form"); const form_data = JSON.parse(elem.serializeForm()); - const { name, ...rest } = form_data; // Need to remove name from the form + const { name, favorited, ...rest } = form_data; // Need to remove name from the form + const favoritedBool = document.querySelector("input-checkbox-star").value; // TODO: How would ref_set be updated? -> in backend sanitize_data function const updated_data = { @@ -111,6 +115,7 @@

name: name, last_edited_at: new Date().toISOString(), last_edited_by: "ssbaveja", // TODO: this will not be needed since it'll be parsed from user object + favorited: favoritedBool, // url: "", }; diff --git a/pscompose/frontend/partials/data_new_form.html b/pscompose/frontend/partials/data_new_form.html index b86b830..47109d2 100644 --- a/pscompose/frontend/partials/data_new_form.html +++ b/pscompose/frontend/partials/data_new_form.html @@ -2,6 +2,7 @@

+
@@ -69,7 +70,8 @@

// 1. Retrieve jsonform data const elem = document.querySelector("json-form"); const form_data = JSON.parse(elem.serializeForm()); - const { name, ...rest } = form_data; // Need to remove name from the form + const { name, favorited, ...rest } = form_data; // Need to remove name from the form + const favoritedBool = document.querySelector("input-checkbox-star").value; // TODO: Do we need to set "url" to something here? Is url going to always remain the same? const data = { @@ -79,6 +81,7 @@

name: name, created_by: "ssbaveja", // TODO: this will not be needed since it'll be parsed from user object last_edited_by: "ssbaveja", // TODO: this will not be needed since it'll be parsed from user object + favorited: favoritedBool, // url: "" }; diff --git a/pscompose/frontend/partials/nav_left.html b/pscompose/frontend/partials/nav_left.html index a842364..7aa75da 100644 --- a/pscompose/frontend/partials/nav_left.html +++ b/pscompose/frontend/partials/nav_left.html @@ -1,42 +1,42 @@