@escalated-dev/escalated 0.9.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,8 +108,7 @@ function togglePayload(id) {
108
108
  <td colspan="5" class="bg-[var(--esc-panel-hover)] px-4 py-3">
109
109
  <pre
110
110
  class="max-h-40 overflow-auto rounded-lg bg-[var(--esc-panel-surface-alt)] p-3 text-xs text-[var(--esc-panel-text-tertiary)]"
111
- >{{ JSON.stringify(delivery.payload, null, 2) }}</pre
112
- >
111
+ >{{ JSON.stringify(delivery.payload, null, 2) }}</pre>
113
112
  </td>
114
113
  </tr>
115
114
  </template>
@@ -0,0 +1,24 @@
1
+ <script setup>
2
+ import Builder from './Builder.vue';
3
+
4
+ /**
5
+ * The workflow editor, under the name the backends render.
6
+ *
7
+ * Builder.vue is the editor itself and takes exactly these props. Several
8
+ * backends render `Escalated/Admin/Workflows/Form` for both create and edit,
9
+ * and a page name with no component behind it is not an error in Inertia --
10
+ * the panel simply comes up blank. This is that name.
11
+ */
12
+ defineProps({
13
+ workflow: { type: Object, default: null },
14
+
15
+ // Passed by the backends alongside the workflow. Builder derives its own
16
+ // trigger list, so this is accepted rather than forwarded; declaring it
17
+ // keeps it out of the fallthrough attributes.
18
+ triggerEvents: { type: [Array, Object], default: null },
19
+ });
20
+ </script>
21
+
22
+ <template>
23
+ <Builder :workflow="workflow" />
24
+ </template>
@@ -0,0 +1,61 @@
1
+ <script setup>
2
+ import EscalatedLayout from '../../components/EscalatedLayout.vue';
3
+ import { computed, resolveComponent } from 'vue';
4
+
5
+ /**
6
+ * A full page registered by a plugin.
7
+ *
8
+ * A plugin declares a route on the backend and a component on the frontend;
9
+ * the backend renders this page with the component's name, and this is where
10
+ * the two meet. `defineEscalatedPlugin` registers those components globally on
11
+ * the app, so the name is resolved from the global registry rather than
12
+ * imported.
13
+ *
14
+ * Two backends render `Escalated/Plugin/Page`, and there was no component
15
+ * behind the name — so every plugin page was blank, whether or not the plugin
16
+ * had registered its component correctly. A plugin that has not been installed
17
+ * on the frontend now says so instead.
18
+ */
19
+ const props = defineProps({
20
+ plugin: { type: String, default: '' },
21
+ component: { type: String, default: '' },
22
+ layout: { type: String, default: 'admin' },
23
+ props: { type: Object, default: () => ({}) },
24
+ });
25
+
26
+ const resolved = computed(() => {
27
+ if (!props.component) {
28
+ return null;
29
+ }
30
+
31
+ const found = resolveComponent(props.component);
32
+
33
+ // resolveComponent hands back the name as a string when nothing is
34
+ // registered under it, which would render as a literal <ComponentName>
35
+ // element rather than as anything useful.
36
+ return typeof found === 'string' ? null : found;
37
+ });
38
+
39
+ const title = computed(() => props.component || props.plugin || 'Plugin');
40
+ </script>
41
+
42
+ <template>
43
+ <EscalatedLayout :title="title">
44
+ <component :is="resolved" v-if="resolved" v-bind="props.props" />
45
+
46
+ <div
47
+ v-else
48
+ class="rounded-xl border border-[var(--esc-panel-border)] bg-[var(--esc-panel-surface)] p-6 text-center"
49
+ >
50
+ <p class="text-sm text-[var(--esc-panel-text-secondary)]">
51
+ The <strong>{{ plugin || 'plugin' }}</strong> plugin registered a page here, but no component named
52
+ <code class="rounded bg-[var(--esc-panel-hover)] px-1">{{ component || '(unnamed)' }}</code> is
53
+ available.
54
+ </p>
55
+ <p class="mt-2 text-xs text-[var(--esc-panel-text-muted)]">
56
+ Register it on the frontend with <code>defineEscalatedPlugin</code> — the backend route exists, so only
57
+ the component is missing.
58
+ </p>
59
+ </div>
60
+ </EscalatedLayout>
61
+ </template>
@@ -0,0 +1,160 @@
1
+ import manifest from '../pages.json';
2
+
3
+ /**
4
+ * Resolve an Escalated page name to its component, loudly.
5
+ *
6
+ * Inertia resolves a page name to a component, and a name with nothing behind
7
+ * it is not an error: `resolve()` returns undefined, Vue renders nothing, and
8
+ * the panel comes up blank. It looks like a permissions problem or an empty
9
+ * dataset, and the request was a 200 the whole time — which is how four screens
10
+ * shipped blank across this package and its backends without anyone noticing.
11
+ *
12
+ * This wraps the lookup so an unresolvable name says so. In development it
13
+ * throws, naming what was asked for and the nearest thing that exists; in
14
+ * production it logs and hands back a component that renders the same message,
15
+ * because a panel that explains itself beats a panel that is empty.
16
+ *
17
+ * Wire it into the host's Inertia setup:
18
+ *
19
+ * import { resolveEscalatedPage } from '@escalated-dev/escalated';
20
+ *
21
+ * createInertiaApp({
22
+ * resolve: (name) =>
23
+ * resolveEscalatedPage(name, () =>
24
+ * resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**')),
25
+ * ),
26
+ * });
27
+ *
28
+ * @param {string} name The page name the backend rendered.
29
+ * @param {Function} [fallback] Resolver for names this package does not own.
30
+ * @param {Object} [options]
31
+ * @param {boolean} [options.throwOnMissing] Defaults to true outside production.
32
+ * @returns {Promise<Object>|Object}
33
+ */
34
+ export function resolveEscalatedPage(name, fallback, options = {}) {
35
+ const pages = import.meta.glob('./pages/**/*.vue');
36
+ const path = `./pages/${String(name ?? '').replace(/^Escalated\//, '')}.vue`;
37
+
38
+ if (pages[path]) {
39
+ return pages[path]();
40
+ }
41
+
42
+ // Not ours: a host page, or a plugin's. Only names this package claims are
43
+ // its responsibility to complain about.
44
+ if (!isEscalatedPage(name)) {
45
+ if (fallback) {
46
+ return fallback(name);
47
+ }
48
+
49
+ return missing(name, options);
50
+ }
51
+
52
+ return missing(name, options);
53
+ }
54
+
55
+ /** Whether a name is one this package is supposed to own. */
56
+ export function isEscalatedPage(name) {
57
+ return String(name ?? '').startsWith('Escalated/');
58
+ }
59
+
60
+ /** Every page name this package can resolve. */
61
+ export function escalatedPages() {
62
+ return [...manifest.pages];
63
+ }
64
+
65
+ /**
66
+ * Names close enough to be the one that was meant.
67
+ *
68
+ * Nearly every real instance of this has been a naming difference rather than
69
+ * an absent screen — `CohortAnalysis` for `Cohorts`, `New` for `Form` — so the
70
+ * message is far more useful when it can point at the likely intent.
71
+ */
72
+ export function suggestPages(name, limit = 3) {
73
+ const wanted = segments(name);
74
+
75
+ if (!wanted.length) {
76
+ return [];
77
+ }
78
+
79
+ return manifest.pages
80
+ .map((page) => ({ page, score: similarity(wanted, segments(page)) }))
81
+ .filter((entry) => entry.score > 0)
82
+ .sort((a, b) => b.score - a.score || a.page.localeCompare(b.page))
83
+ .slice(0, limit)
84
+ .map((entry) => entry.page);
85
+ }
86
+
87
+ function segments(name) {
88
+ return String(name ?? '')
89
+ .toLowerCase()
90
+ .split('/')
91
+ .filter(Boolean);
92
+ }
93
+
94
+ /**
95
+ * How much two page paths have in common.
96
+ *
97
+ * Segment overlap rather than leaf equality: nearly every page ends in `Index`
98
+ * or `Form`, so matching on the last segment alone ranks half the package
99
+ * equally. What actually distinguishes the intended page is the path around it
100
+ * -- `Admin/KB/Articles/Index` and `Admin/KnowledgeBase/Articles/Index` share
101
+ * three segments and differ in one.
102
+ */
103
+ function similarity(wanted, candidate) {
104
+ const shared = wanted.filter((part) => candidate.includes(part)).length;
105
+
106
+ if (!shared) {
107
+ return 0;
108
+ }
109
+
110
+ // The folder counts for more than the leaf, so a differently named screen
111
+ // in the right place outranks the same leaf somewhere unrelated.
112
+ const sameFolder = wanted.slice(0, -1).join('/') === candidate.slice(0, -1).join('/') ? 2 : 0;
113
+ const lengthPenalty = Math.abs(wanted.length - candidate.length) * 0.5;
114
+
115
+ // Every page in a folder shares its whole path, so the folder alone ranks
116
+ // them all equally -- `CohortAnalysis` would tie with `AgentMetrics` and
117
+ // lose on alphabet. How much of the name itself matches is what separates
118
+ // the one that was meant.
119
+ const leaf = commonPrefix(wanted[wanted.length - 1], candidate[candidate.length - 1]);
120
+
121
+ return shared + sameFolder + leaf - lengthPenalty;
122
+ }
123
+
124
+ /** How many leading characters two names share, as a fraction of the shorter. */
125
+ function commonPrefix(a = '', b = '') {
126
+ let i = 0;
127
+
128
+ while (i < a.length && i < b.length && a[i] === b[i]) {
129
+ i += 1;
130
+ }
131
+
132
+ return i / Math.max(1, Math.min(a.length, b.length));
133
+ }
134
+
135
+ function missing(name, options) {
136
+ const suggestions = suggestPages(name);
137
+ const message =
138
+ `Escalated: no page component for "${name}". ` +
139
+ (suggestions.length
140
+ ? `Did you mean ${suggestions.map((s) => `"${s}"`).join(', ')}?`
141
+ : 'It is not in this package; a host or plugin page needs its own resolver.');
142
+
143
+ const shouldThrow = options.throwOnMissing ?? !isProduction();
144
+
145
+ if (shouldThrow) {
146
+ throw new Error(message);
147
+ }
148
+
149
+ console.error(message);
150
+
151
+ return { render: () => null, name: 'EscalatedMissingPage', __escalatedMissing: message };
152
+ }
153
+
154
+ function isProduction() {
155
+ try {
156
+ return import.meta.env?.PROD === true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }