diff --git a/public/assets/pages/adminpage/ctrl_workflow.css b/public/assets/pages/adminpage/ctrl_workflow.css index 4c089923..2601d235 100644 --- a/public/assets/pages/adminpage/ctrl_workflow.css +++ b/public/assets/pages/adminpage/ctrl_workflow.css @@ -1,6 +1,11 @@ .component_page_workflow .pull-right { float: right; } +.component_page_workflow button { + font-size: 0.9rem; + padding: 3px 5px; + margin: 0 2px; +} .component_page_workflow .box { display: block; background: white; @@ -11,7 +16,7 @@ padding: 15px; overflow: visible; } -.component_page_workflow .box.status-unpublished { +.component_page_workflow .box.disabled { background: var(--border); } .component_page_workflow .box h3 { @@ -24,26 +29,14 @@ font-style: italic; color: var(--light); } -.component_page_workflow button { - font-size: 0.9rem; - padding: 3px 5px; - margin: 0 2px; -} .component_page_workflow .box svg { float: left; width: 25px; - margin-right: 10px; fill: var(--light); } -.component_page_workflow button.box { - padding: 5px 10px; - background: var(--light); - color: var(--bg-color); -} .component_page_workflow .box .workflow-summary button { color: var(--light); } - .component_page_workflow hr { border: none; height: 30px; @@ -53,13 +46,162 @@ .component_page_workflow hr:after { content: " "; position: absolute; - left: 20px; + left: 26px; top: 0; bottom: 0; - border-right: 4px dashed rgba(0, 0, 0, 0.15); + border-right: 4px dashed rgba(0, 0, 0, 0.2); border-right-style: dotted; } - -.component_page_admin .page_container a:hover { - opacity: 1; +.component_page_workflow .box [data-bind="form"] { + overflow: hidden; +} +.component_page_workflow .box [data-bind="form"] .formbuilder { + padding: 10px; + border-radius: 5px; + margin-top: 10px; + border: 2px solid #ebebec; + background: transparent; +} +.component_page_workflow .box.disabled [data-bind="form"] .formbuilder { + border-color: var(--border); +} +.component_page_workflow .box [data-bind="form"] .formbuilder:empty { + display: none; +} + +.component_page_workflow .box h3 button.pull-right { + padding: 0; + margin: 0; +} + +/* details page buttons */ +.component_page_workflow .box button[alt="delete"] { + position: absolute; + top: -13px; + right: -13px; +} +.component_page_workflow .box button[alt="delete"] svg { + width: 10px; + height: 10px; + border: 2px solid var(--border); + background: #ebebec; + fill: var(--light); + padding: 5px; + border-radius: 15px; +} + +/* Workflow creation modal */ +.component_workflow_create { + font-size: 1rem; +} +.component_workflow_create h2 { + margin: 0 0 5px 0; + font-size: 1.1rem; + color: var(--light); +} +.component_workflow_create form { + margin-bottom: 15px; +} +.component_workflow_create form input { + font-size: 1rem; + border: 2px solid var(--border); + border-radius: 5px; + padding: 5px 10px; + color: rgba(0,0,0,0.75); + width: 100%; + display: block; + box-sizing: border-box; + margin-bottom: 5px; +} +.component_workflow_create form input::placeholder { + color: rgba(0,0,0,0.4); +} +.component_workflow_create [data-bind="list"] { + overflow: hidden; +} +.component_workflow_create [data-bind="list"] .item { + color: var(--color); + background: #f2f2f2; + transition: all 0.1s ease; + padding: 5px 5px 5px 12px; + border-radius: 5px; + cursor: pointer; + letter-spacing: -0.5px; + margin-top: 2px; + align-items: center; +} +.component_workflow_create [data-bind="list"] .item svg { + height: 20px; + margin-right: 5px; + fill: var(--light); +} + +/* save button */ +.workflow-fab { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--emphasis); + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); + cursor: pointer; +} +.workflow-fab select { + background: var(--emphasis); + color: var(--bg-color); + font-family: monospace; + border: none; + padding-left: 10px; + padding-right: 5px; + text-align: right; + border-top-left-radius: 2px; + border-bottom-left-radius: 10px; + font-weight: 600; +} +.workflow-fab svg { + height: 30px; + fill: var(--bg-color); + padding: 7px; +} + +/* ADD BUTTON */ +.component_page_workflow [data-bind="add"] { + display: inline-block; +} +.component_page_workflow [data-bind="add"] .box { + align-items: center; + background: var(--emphasis); + display: flex; + padding: 4px 0px 2px 0px; + position: relative; + left: 5px; + overflow-x: scroll; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); + border-width: 0; +} +.component_page_workflow [data-bind="add"] .box button { + color: var(--bg-color); + font-family: monospace; + text-transform: uppercase; + padding-left: 0; + padding-right: 0; +} +.component_page_workflow [data-bind="add"] .box svg { + margin-right: 0; + fill: var(--bg-color); + transition: transform 0.2s ease; +} +.component_page_workflow [data-bind="add"] .box > .item { + position: sticky; + left: 0; + z-index: 1; + background: transparent; + backdrop-filter: blur(2px); + padding: 3px 8px; +} +.component_page_workflow [data-bind="add"] .box .sub { + padding: 0 5px; +} +.component_page_workflow [data-bind="add"] .box > .flex { + padding: 0 5px 0 0; } diff --git a/public/assets/pages/adminpage/ctrl_workflow.js b/public/assets/pages/adminpage/ctrl_workflow.js index f7640b4a..865d36f0 100644 --- a/public/assets/pages/adminpage/ctrl_workflow.js +++ b/public/assets/pages/adminpage/ctrl_workflow.js @@ -5,34 +5,251 @@ import AdminHOC from "./decorator.js"; import ctrlList from "./ctrl_workflow_list.js"; import ctrlDetails from "./ctrl_workflow_details.js"; -const mockWorkflows = [ +const workflows = [ { - id: "uuid", - name: "Notify team when file uploaded", - description: "Send notification to #general when any file is uploaded to /shared", - status: "published", - lastEdited: "2 days ago", - trigger: "File uploaded", - action: "Send notification" + id: "dummy", + name: "My First Workflow", + published: false, + trigger: { name: "user" }, + actions: [ + { + name: "tools/debug", + }, + { + name: "notify/email", + } + ], }, { - id: "uuid", - name: "Notify team when file uploaded", - description: "Send notification to #general when any file is uploaded to /shared", - status: "unpublished", + id: "uuid0", + name: "Detection de fichier d'inbox", + published: true, lastEdited: "2 days ago", - trigger: "File uploaded", - action: "Send notification" + trigger: { name: "watch" }, + actions: [ + { + name: "run/program", + }, + { + name: "notify/email", + }, + ], + }, + { + id: "uuid1", + name: "Notify team when file is moved", + published: true, + lastEdited: "2 days ago", + history: [], + trigger: { name: "operation" }, + actions: [ + { + name: "notify/email", + }, + ] + }, + { + id: "uuid2", + name: "Any change to the contract folder", + published: true, + history: [], + trigger: { name: "operation", values: {} }, + actions: [ + { + name: "notify/email", + // values: {} // TODO + }, + ] }, ]; -export default AdminHOC(async function(render) { - const id = new URLSearchParams(location.search).get("id"); +const triggers = [ + { + name: "schedule", + title: "On a Schedule", + icon: ``, + specs: { + "start": { + name: "test", + type: "datetime", + }, + "frequency": { + type: "select", + options: ["hourly", "weekly", "monthly", "yearly"], + }, + }, + }, + { + name: "user", + title: "When a User Creates a Request", + icon: ``, + specs: { + name: { type: "text" }, + form: { type: "text", placeholder: "Optional form user should submit" }, + visibility: { type: "text" }, + }, + }, + { + name: "operation", + title: "When a File Action Happens", + icon: ``, + specs: { + event: { + type: "text", + datalist: ["ls", "cat", "mkdir", "mv", "rm", "touch"], + multi: true, + }, + path: { + type: "text", + }, + }, + }, + { + name: "watch", + title: "When the Filesystem Changes", + icon: ``, + specs: { + token: { + type: "text", + }, + path: { + type: "text", + }, + }, + }, + { + name: "webhook", + title: "From a webhook", + icon: ``, + specs: { + url: { + type: "text", + readonly: true, + value: "http://example.com/workflow/webhook?id=generatedID", + } + }, + }, +]; +const actions = [ + { + name: "run/program", + title: "Execute Program", + subtitle: "name", + icon: ``, + specs: { + name: { + type: "select", + options: ["ocr", "duplicate", "expiry", "sign"], + } + }, + }, + { + name: "run/api", + title: "Make API Call", + icon: ``, + specs: { + url: { + type: "text", + }, + method: { + options: ["POST", "PUT", "GET"], + }, + headers: { + type: "long_text", + }, + body: { + type: "long_text", + }, + }, + }, + { + name: "notify/email", + title: "Notify", + subtitle: "email", + icon: ``, + specs: { + email: { + type: "text", + }, + message: { + type: "long_text", + } + }, + }, + { + name: "approval/email", + title: "Approval", + subtitle: "email", + icon: ``, + specs: { + email: { + type: "text", + }, + message: { + type: "long_text", + }, + }, + }, + { + name: "tools/debug", + title: "Debug", + icon: ``, + specs: {}, + }, + { + name: "tools/map", + title: "Map", + icon: ``, + specs: { + transform: { + type: "long_text", + } + }, + } + // TODO: expand macros +]; + +const macros = [ + { + name: "files/mv", + specs: [ + { name: "from", type: "text" }, + { name: "to", type: "text" }, + ], + run: [ + { + name: "run/api", + values: [ + { name: "url", value: "{{ .endpoint }}/api/files/mv?from={{ .from }}&to={{ .to }}" }, + { name: "method", value: "POST" }, + { name: "headers", value: "Authorization: Bearer {{ .authorization }}" }, + ] + }, + ], + }, + // { + // name: "metadata/add", + // } +] + +export default AdminHOC(async function(render) { await loadCSS(import.meta.url, "./ctrl_workflow.css"); render(createElement("")); - await new Promise((done) => setTimeout(() => done(), 100)); - if (id) ctrlDetails(render, mockWorkflows[0]); - else ctrlList(render, mockWorkflows); + const specs = getSpecs(); + if (specs) ctrlDetails(render, { workflow: specs, triggers, actions }); + else { + await new Promise((done) => setTimeout(() => done(), 100)); + ctrlList(render, { workflows, triggers, actions }); + } }); + +function getSpecs() { + const GET = new URLSearchParams(location.search) + try { + return JSON.parse(atob(GET.get("specs"))); + } catch (err ) { + return null; + } +} diff --git a/public/assets/pages/adminpage/ctrl_workflow_details.js b/public/assets/pages/adminpage/ctrl_workflow_details.js index 0de642c3..f419038d 100644 --- a/public/assets/pages/adminpage/ctrl_workflow_details.js +++ b/public/assets/pages/adminpage/ctrl_workflow_details.js @@ -1,8 +1,15 @@ -import { createElement } from "../../lib/skeleton/index.js"; +import { createElement, createFragment } from "../../lib/skeleton/index.js"; +import { animate, slideXIn, slideXOut } from "../../lib/animate.js"; +import { qs, qsa } from "../../lib/dom.js"; +import { createForm, mutateForm } from "../../lib/form.js"; +import { formTmpl } from "../../components/form.js"; +import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js"; import transition from "./animate.js"; -export default async function(render, { name }) { +// TODO: auto id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6); + +export default async function(render, { workflow, triggers, actions }) { const $page = createElement(`

@@ -11,42 +18,204 @@ export default async function(render, { name }) { - ${name} + ${workflow.name}

-
-
- -

On a Schedule (every day)

-
-
-
- -

Execute Program (duplicate)

-
-
- Frequency: -
-
-
- Frequency: -
-
-
+
+
+
-
-
-
- -

Notify (john@example.com)

-
-
- -
- + +
`); render(transition($page)); + + // feature1: setup trigger + const $trigger = qs($page, `[data-bind="trigger"]`); + $trigger.appendChild(await createTrigger({ workflow, triggers })); + + // feature2: setup actions + const $actions = qs($page, `[data-bind="actions"]`); + for (let i=0; i { + const $action = await createAction({ action, actions }); + qs($action, `button[alt="delete"]`).onclick = (e) => removeAction(e.target); + $actions.appendChild($action); + withToggle(qs($action, `[data-bind="form"]`)); + }, + })); + + // feature4: save button + $page.parentElement.appendChild(createSave()); + + // feature5: toggle form visibility + qsa($page, `[data-bind="form"]`).forEach(($form) => { + withToggle($form); + if (workflow.id) $form.classList.add("hidden"); + }); + + // feature6: remove button + qsa($page, `button[alt="delete"]`).forEach(($delete) => $delete.onclick = (e) => { + removeAction(e.target); + }); +} + +async function createTrigger({ workflow, triggers }) { + const trigger = triggers.find(({ name }) => name === workflow.trigger.name); + if (!trigger) return createElement(`
Trigger not found "${workflow.trigger.name}"
`); + const { title, icon } = trigger; + const $trigger = createFragment(` +
+ ${icon} +

+ ${title} + +

+
+

+ `); + const $form = await createForm(trigger.specs, formTmpl()); + qs($trigger, `[data-bind="form"]`).appendChild($form); + return $trigger; +} + +async function createAction({ action, actions }) { + const selected = actions.find((_action) => _action.name === action.name); + if (!selected) return createElement(`
Action not found "${action.name}"
`); + const subtitle = selected.subtitle ? `({{ ${action.subtitle} }})` : ""; + const $action = createElement(` +
+
+ ${selected.icon} +

+ ${selected.title} ${subtitle} + + +

+
+
+
+
+ `); + const $form = await createForm(selected.specs, formTmpl()); + qs($action, `[data-bind="form"]`).appendChild($form); + return $action; +} + +async function removeAction($target) { + const $box = $target.closest(".box"); + await animate($box, { + time: 150, + keyframes: slideXOut(10), + }); + $box.parentElement.remove(); +} + +async function createAdd({ actions, createAction }) { + const $el = createElement(` +
+ +
+ `); + const categories = actions.reduce((acc, { name }) => { + const s = name.split("/"); + if (!acc[s[0]]) acc[s[0]] = []; + acc[s[0]].push(name); + return acc; + }, {}); + const $categories = createElement(` + + `); + const $item = qs($el, ".item"); + const width = 45; + let rotate = 0; + $el.appendChild($categories); + $item.onclick = async (e) => { + rotate += 45; + $item.firstElementChild.style.transform = `rotate(${rotate}deg)`; + if (rotate % 90 === 0) { + $categories.classList.add("hidden"); + await animate($el, { + time: 150, + keyframes: [ + { width: "300px" }, + { width: `${width}px` }, + ], + }); + } else { + await animate($el, { + time: 150, + keyframes: [ + { width: `${width}px` }, + { width: "300px" }, + ], + }); + $categories.classList.remove("hidden"); + animate($categories, { time: 80, keyframes: slideXIn(-20) }); + } + }; + + qsa($el, ".sub").forEach(($action) => $action.onclick = () => { + const action = actions.find(({ name }) => name === $action.getAttribute("data-name")); + if (action) createAction({ action, actions }); + $item.onclick(); + }); + return $el; +} + +function createSave() { + const $fab = createElement(` +
+ + + + +
+ `); + animate($fab, { time: 100, keyframes: slideXIn(5) }); + return $fab; +} + +function withToggle($form) { + const height = $form.clientHeight; + const $box = $form.closest(".box"); + qs($box, `h3 button[alt="configure"]`).onclick = () => { + const shouldOpen = $form.classList.contains("hidden"); + if (shouldOpen) { + animate($form, { + time: 120, + keyframes: [{ height: "0px" }, { height: `${height}px` }], + }); + $form.classList.remove("hidden"); + } else { + animate($form, { + time: 80, + keyframes: [{ height: `${height}px` }, { height: "0px" }], + onExit: () => $form.classList.add("hidden"), + }); + } + }; } diff --git a/public/assets/pages/adminpage/ctrl_workflow_list.js b/public/assets/pages/adminpage/ctrl_workflow_list.js index 07d3decf..17f1297d 100644 --- a/public/assets/pages/adminpage/ctrl_workflow_list.js +++ b/public/assets/pages/adminpage/ctrl_workflow_list.js @@ -1,41 +1,100 @@ import { createElement } from "../../lib/skeleton/index.js"; import rxjs, { effect, onClick } from "../../lib/rx.js"; import { qs } from "../../lib/dom.js"; +import { animate } from "../../lib/animate.js"; import { createModal } from "../../components/modal.js"; +import { generateSkeleton } from "../../components/skeleton.js"; +import t from "../../locales/index.js"; import transition from "./animate.js"; -export default async function(render, workflows) { +export default async function(render, { workflows, triggers }) { const $page = createElement(`

Workflows +

-
+
`); render(transition($page)); - workflows.forEach((workflow) => qs($page, `[data-bind="workflows"]`).appendChild(createWorkflow(workflow))); effect(onClick(qs($page, "h2 > a")).pipe( - rxjs.tap((a) => createModal({ withButtonsRight: "Create", withButtonsLeft: "Cancel" })(createElement(` -
- -
- `))), + rxjs.tap((a) => ctrlModal(createModal(), { triggers })), )); } -function createWorkflow({ id, name, status }) { +function createWorkflow(specs) { + const { name, published, actions, trigger } = specs; + const summaryHTML = { + trigger: ``, + actions: actions.map(({ name }) => ``).join(""), + }; const $workflow = createElement(` - +

${name} (2 weeks ago)

- + ${summaryHTML.trigger} → ${summaryHTML.actions}
`); return $workflow; } + +function ctrlModal(render, { triggers }) { + const $page = createElement(` +
+
+

Step1: Name the Workflow

+ +
+ ${generateSkeleton(1)} +
+
+
+ `); + render($page); + + const $list = qs($page, `[data-bind="list"]`); + const $input = qs($page, "input"); + effect(rxjs.of(triggers).pipe( + rxjs.map((arr) => arr.map(({ name, title, icon }) => createElement(` + ${icon} ${title} + `))), + rxjs.map(($els) => { + $list.innerHTML = ""; + $input.focus(); + const $fragment = document.createDocumentFragment(); + $fragment.appendChild(createElement(`

Step2: Select a Trigger

`)); + $els.forEach(($el) => $fragment.appendChild($el)); + $list.appendChild($fragment); + const height = $list.clientHeight; + $list.style.height = "0"; + return { height, $els }; + }), + rxjs.mergeMap(({ height, $els }) => rxjs.fromEvent($input, "keydown").pipe( + rxjs.debounceTime(200), + rxjs.tap((e) => { + const shouldOpen = e.target.value.length > 0; + if ($list.clientHeight === 0 && shouldOpen) animate($list, { + time: Math.max(50, Math.min(height, 150)), + keyframes: [{ height: "0" }, { height: `${height}px` }], + onExit: () => $list.style.height = "", + }); + else if ($list.clientHeight > 0 && !shouldOpen) animate($list, { + time: Math.max(50, Math.min(height, 150)), + keyframes: [{ height: `${height}px` }, { height: "0" }], + onExit: () => $list.style.height = "0", + }); + $els.forEach(($el) => $el.setAttribute("href", "./admin/workflow?specs="+btoa(JSON.stringify({ + name: e.target.value, + published: false, + trigger: { name: $el.getAttribute("data-name") }, + actions: [ { name: "tools/debug" }] + })))); + }), + )), + )); +}