@antelopejs/dms 0.6.0 → 0.6.1
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.
- package/CHANGELOG.md +16 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/registerBlockFunctions.ts +7 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +11 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +63 -0
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +78 -16
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +21 -53
- package/frontend-vue/layers/dms-ui/app/plugins/register.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +28 -0
- package/frontend-vue/tests/block-page-refresh.test.ts +128 -0
- package/frontend-vue/tests/block-route-params.test.ts +190 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.6.1
|
|
4
|
+
|
|
5
|
+
[compare changes](https://github.com/AntelopeJS/dms/compare/v0.6.0...v0.6.1)
|
|
6
|
+
|
|
7
|
+
### 🚀 Enhancements
|
|
8
|
+
|
|
9
|
+
- **dms:** Resolve route tokens in block data URLs and refresh page blocks after an action ([#170](https://github.com/AntelopeJS/dms/pull/170))
|
|
10
|
+
|
|
11
|
+
### 🏡 Chore
|
|
12
|
+
|
|
13
|
+
- **release:** @antelopejs/interface-dms v0.4.1 ([224d341](https://github.com/AntelopeJS/dms/commit/224d341))
|
|
14
|
+
|
|
15
|
+
### ❤️ Contributors
|
|
16
|
+
|
|
17
|
+
- Antony Rizzitelli <rizzitelli.antony@pm.me>
|
|
18
|
+
|
|
3
19
|
## v0.6.0
|
|
4
20
|
|
|
5
21
|
[compare changes](https://github.com/AntelopeJS/dms/compare/v0.5.6...v0.6.0)
|
|
@@ -14,6 +14,7 @@ import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePe
|
|
|
14
14
|
import { PREVIEW_LOCK_ICON } from "#dms-ui/app/build/utils/permissionPreview";
|
|
15
15
|
import { runMountedQuickAction } from "#dms-ui/app/build/utils/quickActionTargets";
|
|
16
16
|
import { useActionTargets } from "#dms-ui/app/build/composables/actions/useActionTargets";
|
|
17
|
+
import { refreshPageBlocks } from "#dms-ui/app/utils/blockRefresh";
|
|
17
18
|
import {
|
|
18
19
|
dispatchQuickActionTarget,
|
|
19
20
|
findServedQuickAction,
|
|
@@ -60,11 +61,14 @@ const router = useDmsRouter();
|
|
|
60
61
|
const { $authFetch } = useAuthFetch();
|
|
61
62
|
|
|
62
63
|
// The page header runs a declared button's target as a table runs its
|
|
63
|
-
// toolbar buttons: confirmation, drawer, modal, API call, export, link.
|
|
64
|
+
// toolbar buttons: confirmation, drawer, modal, API call, export, link. A
|
|
65
|
+
// button that changed something (an API call, a form it opened) changes the
|
|
66
|
+
// record the page shows: its blocks read their data again.
|
|
64
67
|
const { handleCustomButton } = useActionTargets({
|
|
65
68
|
api: $authFetch,
|
|
66
69
|
componentId: "page-header",
|
|
67
70
|
pageId: siteLayout.findMatchingRoute(route.path)?.metadata?.fullId ?? "page",
|
|
71
|
+
refreshCallback: refreshPageBlocks,
|
|
68
72
|
handleApiError: (error, title) => useApiError(error, { title }),
|
|
69
73
|
});
|
|
70
74
|
|
|
@@ -12,9 +12,15 @@ export interface BlockItemsResponse<T> {
|
|
|
12
12
|
export interface UseBlockItemsOptions<T> {
|
|
13
13
|
/** The items written in the block's options (read reactively). */
|
|
14
14
|
items: () => T[] | undefined;
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Route answering `{ items }`; when set it replaces the static items. Its
|
|
17
|
+
* `{{params.X}}` and `{{query.X}}` tokens are filled from the page URL, and
|
|
18
|
+
* nothing is requested while one has no value (see useChartFetch).
|
|
19
|
+
*/
|
|
16
20
|
fetchUrl?: string;
|
|
17
21
|
fetchUrlMethod?: string;
|
|
22
|
+
/** The parameters of the page route (the block's `routeParams` prop). */
|
|
23
|
+
routeParams?: () => Record<string, string> | undefined;
|
|
18
24
|
watchActions?: WatchAction[];
|
|
19
25
|
componentId?: string;
|
|
20
26
|
}
|
|
@@ -31,8 +37,9 @@ export interface UseBlockItemsReturn<T> {
|
|
|
31
37
|
/**
|
|
32
38
|
* The items a list block (StatGroup, KeyValueList, NavCardGrid) renders:
|
|
33
39
|
* either the static ones of its options, or the `items` its `fetchUrl`
|
|
34
|
-
* answers with — refetched when a watched event changes the block's state
|
|
35
|
-
* like the KPI and
|
|
40
|
+
* answers with — refetched when a watched event changes the block's state or
|
|
41
|
+
* the page asks its blocks to refresh (refreshPageBlocks), like the KPI and
|
|
42
|
+
* top-list cards.
|
|
36
43
|
*/
|
|
37
44
|
export function useBlockItems<T>(
|
|
38
45
|
options: UseBlockItemsOptions<T>,
|
|
@@ -49,6 +56,7 @@ export function useBlockItems<T>(
|
|
|
49
56
|
>({
|
|
50
57
|
fetchUrl: options.fetchUrl,
|
|
51
58
|
fetchUrlMethod: options.fetchUrlMethod,
|
|
59
|
+
routeParams: options.routeParams,
|
|
52
60
|
watchSource: () => watchKey.value,
|
|
53
61
|
});
|
|
54
62
|
|
package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LocationQueryValue } from "#dms/frontend-module";
|
|
2
|
-
import { replaceUrlVariables } from "
|
|
2
|
+
import { replaceUrlVariables } from "../../utils/urlVariables";
|
|
3
3
|
|
|
4
4
|
type RouteQuery = Record<string, LocationQueryValue | LocationQueryValue[]>;
|
|
5
5
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `{{params.id}}` takes the bare name, which on a route repeating a placeholder
|
|
2
|
+
// is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
|
|
3
|
+
// a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
|
|
4
|
+
// table view (see extractRouteParams).
|
|
5
|
+
const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
|
|
6
|
+
const QUERY_TOKEN = /\{\{query\.(\w+)\}\}/g;
|
|
7
|
+
const RESPONSE_TOKEN = /\{\{response\.(\w+)\}\}/g;
|
|
8
|
+
const TOKEN_OPENING = "{{";
|
|
9
|
+
|
|
10
|
+
export interface ReplaceUrlVariablesContext {
|
|
11
|
+
routeParams?: Record<string, string>;
|
|
12
|
+
routeQuery: Record<string, unknown>;
|
|
13
|
+
response?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fills the `{{params.X}}`, `{{query.X}}` and (with a response)
|
|
18
|
+
* `{{response.X}}` tokens of a URL. A token with no value is left as written.
|
|
19
|
+
*/
|
|
20
|
+
export function replaceUrlVariables(
|
|
21
|
+
url: string,
|
|
22
|
+
context: ReplaceUrlVariablesContext,
|
|
23
|
+
): string {
|
|
24
|
+
let processedUrl = url.replace(
|
|
25
|
+
PARAM_TOKEN,
|
|
26
|
+
(match, key) => context.routeParams?.[key] || match,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
processedUrl = processedUrl.replace(QUERY_TOKEN, (match, key) => {
|
|
30
|
+
const value = context.routeQuery[key];
|
|
31
|
+
return typeof value === "string" ? value : match;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (context.response) {
|
|
35
|
+
processedUrl = processedUrl.replace(RESPONSE_TOKEN, (match, key) => {
|
|
36
|
+
const value = context.response?.[key];
|
|
37
|
+
return typeof value === "string" || typeof value === "number"
|
|
38
|
+
? String(value)
|
|
39
|
+
: match;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return processedUrl;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether a URL still names a token: written so, or left unresolved. */
|
|
47
|
+
export function hasUrlVariables(url: string): boolean {
|
|
48
|
+
return url.includes(TOKEN_OPENING);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The URL with its tokens filled, or `undefined` while one is left: a URL
|
|
53
|
+
* naming a value the page does not carry is not a route, and must not be
|
|
54
|
+
* requested as one.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveUrlVariables(
|
|
57
|
+
url: string | undefined,
|
|
58
|
+
context: ReplaceUrlVariablesContext,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
if (!url) return undefined;
|
|
61
|
+
const resolved = replaceUrlVariables(url, context);
|
|
62
|
+
return hasUrlVariables(resolved) ? undefined : resolved;
|
|
63
|
+
}
|
|
@@ -56,6 +56,7 @@ const { items, isPending, hasError, refresh } = useBlockItems<KeyValueItem>({
|
|
|
56
56
|
items: () => props.items,
|
|
57
57
|
fetchUrl: props.fetchUrl,
|
|
58
58
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
59
|
+
routeParams: () => props.routeParams,
|
|
59
60
|
watchActions: props.watchActions,
|
|
60
61
|
componentId: props.componentId,
|
|
61
62
|
});
|
|
@@ -50,6 +50,7 @@ const { items, isPending, hasError, refresh } = useBlockItems<StatGroupItem>({
|
|
|
50
50
|
items: () => props.items,
|
|
51
51
|
fetchUrl: props.fetchUrl,
|
|
52
52
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
53
|
+
routeParams: () => props.routeParams,
|
|
53
54
|
watchActions: props.watchActions,
|
|
54
55
|
componentId: props.componentId,
|
|
55
56
|
});
|
|
@@ -132,6 +132,7 @@ const isNested = computed(() => !!nestedContext);
|
|
|
132
132
|
const { data, isLoading } = useChartFetch<ChartResponse>({
|
|
133
133
|
fetchUrl: isNested.value ? undefined : props.fetchUrl,
|
|
134
134
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
135
|
+
routeParams: () => props.routeParams,
|
|
135
136
|
periodScope: props.periodScope,
|
|
136
137
|
realtimeTopic: props.realtimeTopic,
|
|
137
138
|
staticData: null,
|
|
@@ -69,6 +69,7 @@ const watchKey = computed(() => JSON.stringify(watchState.value));
|
|
|
69
69
|
const { data, isLoading } = useChartFetch<ChartCardResponse>({
|
|
70
70
|
fetchUrl: props.fetchUrl,
|
|
71
71
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
72
|
+
routeParams: () => props.routeParams,
|
|
72
73
|
periodScope: props.periodScope,
|
|
73
74
|
watchSource: () => watchKey.value,
|
|
74
75
|
});
|
|
@@ -79,6 +79,7 @@ const staticData = computed<KpiCardResponse | null>(() => {
|
|
|
79
79
|
const { data, isLoading, error, refresh } = useChartFetch<KpiCardResponse>({
|
|
80
80
|
fetchUrl: props.fetchUrl,
|
|
81
81
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
82
|
+
routeParams: () => props.routeParams,
|
|
82
83
|
periodScope: props.periodScope,
|
|
83
84
|
staticData: () => staticData.value,
|
|
84
85
|
watchSource: () => watchKey.value,
|
|
@@ -76,6 +76,7 @@ const { state: watchState } = useWatch(
|
|
|
76
76
|
const { data, isLoading, error, refresh } = useChartFetch<MeterResponse>({
|
|
77
77
|
fetchUrl: props.fetchUrl,
|
|
78
78
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
79
|
+
routeParams: () => props.routeParams,
|
|
79
80
|
watchSource: () => JSON.stringify(watchState.value),
|
|
80
81
|
});
|
|
81
82
|
|
|
@@ -30,6 +30,7 @@ const { state: watchState } = useWatch(
|
|
|
30
30
|
);
|
|
31
31
|
const { data: fetchedBadges } = useChartFetch<TabBadges>({
|
|
32
32
|
fetchUrl: props.badgesUrl,
|
|
33
|
+
routeParams: () => props.routeParams,
|
|
33
34
|
watchSource: () => JSON.stringify(watchState.value),
|
|
34
35
|
});
|
|
35
36
|
const { locale } = useI18n();
|
|
@@ -108,6 +108,7 @@ const staticData = computed<TopListCardResponse | null>(() =>
|
|
|
108
108
|
const { data, isLoading } = useChartFetch<TopListCardResponse>({
|
|
109
109
|
fetchUrl: props.fetchUrl,
|
|
110
110
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
111
|
+
routeParams: () => props.routeParams,
|
|
111
112
|
periodScope: props.periodScope,
|
|
112
113
|
staticData: () => staticData.value,
|
|
113
114
|
watchSource: () => watchKey.value,
|
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onBeforeUnmount,
|
|
4
|
+
onMounted,
|
|
5
|
+
ref,
|
|
6
|
+
watch,
|
|
7
|
+
type ComputedRef,
|
|
8
|
+
type Ref,
|
|
9
|
+
} from "vue";
|
|
2
10
|
import {
|
|
3
11
|
appendPeriodToUrl,
|
|
4
12
|
usePeriodScope,
|
|
5
13
|
} from "../../../../dms-core/app/composables/period/usePeriodScope";
|
|
6
14
|
import type { PeriodState } from "../../../../dms-core/app/composables/period/types";
|
|
7
15
|
import { useRealtimeTopic } from "../../../../dms-core/app/composables/realtime/useRealtimeTopic";
|
|
16
|
+
import {
|
|
17
|
+
hasUrlVariables,
|
|
18
|
+
resolveUrlVariables,
|
|
19
|
+
} from "../../build/utils/urlVariables";
|
|
20
|
+
import { onPageBlocksRefresh } from "../../utils/blockRefresh";
|
|
8
21
|
|
|
9
22
|
export interface UseChartFetchOptions<T> {
|
|
23
|
+
/**
|
|
24
|
+
* Where the data is read from. `{{params.X}}` is filled from `routeParams`
|
|
25
|
+
* and `{{query.X}}` from the page URL's query, again when they change;
|
|
26
|
+
* nothing is requested while a token is left without a value.
|
|
27
|
+
*/
|
|
10
28
|
fetchUrl?: string;
|
|
29
|
+
/** The parameters of the page route (a block's `routeParams` prop). */
|
|
30
|
+
routeParams?: () => Record<string, string> | undefined;
|
|
11
31
|
fetchUrlMethod?: string;
|
|
12
32
|
periodScope?: string;
|
|
13
33
|
realtimeTopic?: string | string[];
|
|
@@ -46,6 +66,13 @@ function buildFinalUrl(
|
|
|
46
66
|
return appendPeriodToUrl(url, scopeState);
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
/** What one fetch requests. */
|
|
70
|
+
interface FetchRequest {
|
|
71
|
+
url: string | undefined;
|
|
72
|
+
method: string | undefined;
|
|
73
|
+
periodScope: string | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
interface FetchRunner {
|
|
50
77
|
hasFetchedOnce: boolean;
|
|
51
78
|
/** Inputs the last fetch was started for, to collapse duplicate triggers. */
|
|
@@ -61,16 +88,16 @@ type AuthFetcher = <T>(
|
|
|
61
88
|
|
|
62
89
|
async function runFetch<T>(
|
|
63
90
|
authFetch: AuthFetcher,
|
|
64
|
-
|
|
91
|
+
request: FetchRequest,
|
|
65
92
|
scopeState: PeriodState | null,
|
|
66
93
|
runner: FetchRunner,
|
|
67
94
|
data: Ref<T | null>,
|
|
68
95
|
isLoading: Ref<boolean>,
|
|
69
96
|
error: Ref<unknown>,
|
|
70
97
|
): Promise<void> {
|
|
71
|
-
if (!
|
|
72
|
-
if (
|
|
73
|
-
const url = buildFinalUrl(
|
|
98
|
+
if (!request.url) return;
|
|
99
|
+
if (request.periodScope && !scopeState) return;
|
|
100
|
+
const url = buildFinalUrl(request.url, scopeState, request.periodScope);
|
|
74
101
|
if (!url) return;
|
|
75
102
|
if (runner.activeAbort) runner.activeAbort.abort();
|
|
76
103
|
const controller = new AbortController();
|
|
@@ -79,7 +106,7 @@ async function runFetch<T>(
|
|
|
79
106
|
error.value = null;
|
|
80
107
|
try {
|
|
81
108
|
const response = await authFetch<T>(url, {
|
|
82
|
-
method:
|
|
109
|
+
method: request.method || DEFAULT_HTTP_METHOD,
|
|
83
110
|
signal: controller.signal,
|
|
84
111
|
});
|
|
85
112
|
if (runner.activeAbort !== controller) return;
|
|
@@ -119,6 +146,34 @@ function subscribeRealtimeTopics(
|
|
|
119
146
|
}
|
|
120
147
|
}
|
|
121
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The URL to request: `fetchUrl` with its tokens filled, or `undefined` while
|
|
151
|
+
* one has no value. Only a URL naming a token reads the route.
|
|
152
|
+
*/
|
|
153
|
+
function useResolvedFetchUrl<T>(
|
|
154
|
+
options: UseChartFetchOptions<T>,
|
|
155
|
+
): ComputedRef<string | undefined> {
|
|
156
|
+
const { fetchUrl } = options;
|
|
157
|
+
if (!fetchUrl || !hasUrlVariables(fetchUrl)) return computed(() => fetchUrl);
|
|
158
|
+
const route = useDmsRoute();
|
|
159
|
+
return computed(() =>
|
|
160
|
+
resolveUrlVariables(fetchUrl, {
|
|
161
|
+
routeParams: options.routeParams?.(),
|
|
162
|
+
routeQuery: route.query as Record<string, unknown>,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Mounted only: the page refresh is a browser event, and a block the server
|
|
168
|
+
// renders has nothing to refetch.
|
|
169
|
+
function subscribePageRefresh(onRefresh: () => void): void {
|
|
170
|
+
let unsubscribe: (() => void) | undefined;
|
|
171
|
+
onMounted(() => {
|
|
172
|
+
unsubscribe = onPageBlocksRefresh(onRefresh);
|
|
173
|
+
});
|
|
174
|
+
onBeforeUnmount(() => unsubscribe?.());
|
|
175
|
+
}
|
|
176
|
+
|
|
122
177
|
export function useChartFetch<T>(
|
|
123
178
|
options: UseChartFetchOptions<T>,
|
|
124
179
|
): UseChartFetchReturn<T> {
|
|
@@ -130,7 +185,8 @@ export function useChartFetch<T>(
|
|
|
130
185
|
// period-scoped card renders server-side with no scope and no data. Start
|
|
131
186
|
// in the loading state so consumers show a skeleton instead of a hard zero
|
|
132
187
|
// for the whole hydrate-then-fetch window.
|
|
133
|
-
const
|
|
188
|
+
const resolvedUrl = useResolvedFetchUrl(options);
|
|
189
|
+
const isLoading = ref(Boolean(resolvedUrl.value));
|
|
134
190
|
const error = ref<unknown>(null);
|
|
135
191
|
const scopeState = usePeriodScope(options.periodScope);
|
|
136
192
|
const { $authFetch } = useAuthFetch();
|
|
@@ -144,7 +200,11 @@ export function useChartFetch<T>(
|
|
|
144
200
|
const performFetch = () =>
|
|
145
201
|
runFetch(
|
|
146
202
|
$authFetch as AuthFetcher,
|
|
147
|
-
|
|
203
|
+
{
|
|
204
|
+
url: resolvedUrl.value,
|
|
205
|
+
method: options.fetchUrlMethod,
|
|
206
|
+
periodScope: options.periodScope,
|
|
207
|
+
},
|
|
148
208
|
scopeState.value,
|
|
149
209
|
runner,
|
|
150
210
|
data,
|
|
@@ -159,7 +219,7 @@ export function useChartFetch<T>(
|
|
|
159
219
|
};
|
|
160
220
|
|
|
161
221
|
const currentInputs = () =>
|
|
162
|
-
`${scopeState.value?.key ?? ""}|${JSON.stringify(options.watchSource?.() ?? null)}`;
|
|
222
|
+
`${resolvedUrl.value}|${scopeState.value?.key ?? ""}|${JSON.stringify(options.watchSource?.() ?? null)}`;
|
|
163
223
|
|
|
164
224
|
const fetchScheduledInputs = () => {
|
|
165
225
|
runner.pendingTimer = null;
|
|
@@ -182,7 +242,7 @@ export function useChartFetch<T>(
|
|
|
182
242
|
const hadPending = runner.pendingTimer !== null;
|
|
183
243
|
if (runner.pendingTimer) clearTimeout(runner.pendingTimer);
|
|
184
244
|
runner.pendingTimer = null;
|
|
185
|
-
if (options.periodScope && !scopeState.value) {
|
|
245
|
+
if (!resolvedUrl.value || (options.periodScope && !scopeState.value)) {
|
|
186
246
|
settleCancelledRefresh(hadPending);
|
|
187
247
|
return;
|
|
188
248
|
}
|
|
@@ -203,10 +263,11 @@ export function useChartFetch<T>(
|
|
|
203
263
|
};
|
|
204
264
|
const scheduleRefresh = () => scheduleRefreshWith(true);
|
|
205
265
|
|
|
206
|
-
// A realtime event
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
266
|
+
// A realtime event or a page refresh (refreshPageBlocks) means the data
|
|
267
|
+
// behind unchanged inputs moved, so it has to bypass the deduplication
|
|
268
|
+
// above. The inputs did not change, so the values on screen stay current
|
|
269
|
+
// until the new answer replaces them.
|
|
270
|
+
const refreshSameInputs = () => {
|
|
210
271
|
runner.lastInputs = null;
|
|
211
272
|
scheduleRefreshWith(false);
|
|
212
273
|
};
|
|
@@ -225,11 +286,12 @@ export function useChartFetch<T>(
|
|
|
225
286
|
|
|
226
287
|
if (options.fetchUrl) {
|
|
227
288
|
watch(
|
|
228
|
-
() => [scopeState.value?.key, options.watchSource?.()],
|
|
289
|
+
() => [resolvedUrl.value, scopeState.value?.key, options.watchSource?.()],
|
|
229
290
|
scheduleRefresh,
|
|
230
291
|
);
|
|
231
292
|
onMounted(fetchOnMount);
|
|
232
|
-
subscribeRealtimeTopics(options.realtimeTopic,
|
|
293
|
+
subscribeRealtimeTopics(options.realtimeTopic, refreshSameInputs);
|
|
294
|
+
subscribePageRefresh(refreshSameInputs);
|
|
233
295
|
} else if (typeof options.staticData === "function") {
|
|
234
296
|
watch(
|
|
235
297
|
() => readStaticData(options.staticData),
|
|
@@ -9,6 +9,17 @@ import type { FormField, FormFieldOrGroup } from "./types/field";
|
|
|
9
9
|
import { isFieldGroup } from "./types/field";
|
|
10
10
|
import { resolveResponseToast } from "../../build/utils/responseWarning";
|
|
11
11
|
import { processFieldI18n } from "../../build/utils/fieldOptionsI18n";
|
|
12
|
+
import {
|
|
13
|
+
type ReplaceUrlVariablesContext,
|
|
14
|
+
hasUrlVariables,
|
|
15
|
+
replaceUrlVariables,
|
|
16
|
+
resolveUrlVariables,
|
|
17
|
+
} from "../../build/utils/urlVariables";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
type ReplaceUrlVariablesContext,
|
|
21
|
+
replaceUrlVariables,
|
|
22
|
+
} from "../../build/utils/urlVariables";
|
|
12
23
|
|
|
13
24
|
/** A toast a submit response asks for, on top of the success one. */
|
|
14
25
|
interface FormSubmitNotice {
|
|
@@ -135,47 +146,6 @@ function createBaseValidationSchema(
|
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
// `{{params.id}}` takes the bare name, which on a route repeating a placeholder
|
|
139
|
-
// is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
|
|
140
|
-
// a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
|
|
141
|
-
// table view (see extractRouteParams).
|
|
142
|
-
const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
|
|
143
|
-
|
|
144
|
-
export interface ReplaceUrlVariablesContext {
|
|
145
|
-
routeParams?: Record<string, string>;
|
|
146
|
-
routeQuery: Record<string, unknown>;
|
|
147
|
-
response?: Record<string, unknown>;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function replaceUrlVariables(
|
|
151
|
-
url: string,
|
|
152
|
-
context: ReplaceUrlVariablesContext,
|
|
153
|
-
): string {
|
|
154
|
-
let processedUrl = url.replace(
|
|
155
|
-
PARAM_TOKEN,
|
|
156
|
-
(match, key) => context.routeParams?.[key] || match,
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
processedUrl = processedUrl.replace(/\{\{query\.(\w+)\}\}/g, (match, key) => {
|
|
160
|
-
const value = context.routeQuery[key];
|
|
161
|
-
return typeof value === "string" ? value : match;
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
if (context.response) {
|
|
165
|
-
processedUrl = processedUrl.replace(
|
|
166
|
-
/\{\{response\.(\w+)\}\}/g,
|
|
167
|
-
(match, key) => {
|
|
168
|
-
const value = context.response?.[key];
|
|
169
|
-
return typeof value === "string" || typeof value === "number"
|
|
170
|
-
? String(value)
|
|
171
|
-
: match;
|
|
172
|
-
},
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return processedUrl;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
149
|
/** Where a submit goes, or what it lacks to go anywhere. */
|
|
180
150
|
export type SubmitTarget = { url: string } | { missing: "url" | "token" };
|
|
181
151
|
|
|
@@ -191,8 +161,8 @@ export function resolveSubmitTarget(
|
|
|
191
161
|
context: ReplaceUrlVariablesContext,
|
|
192
162
|
): SubmitTarget {
|
|
193
163
|
if (!submitUrl) return { missing: "url" };
|
|
194
|
-
const url =
|
|
195
|
-
return url
|
|
164
|
+
const url = resolveUrlVariables(submitUrl, context);
|
|
165
|
+
return url === undefined ? { missing: "token" } : { url };
|
|
196
166
|
}
|
|
197
167
|
|
|
198
168
|
function processBeforeStateMappers(
|
|
@@ -376,9 +346,9 @@ export function resolveSubmitDefaults(
|
|
|
376
346
|
|
|
377
347
|
const resolved: Record<string, unknown> = {};
|
|
378
348
|
for (const [key, value] of Object.entries(submitDefaults)) {
|
|
379
|
-
if (typeof value === "string" && value
|
|
380
|
-
const replaced =
|
|
381
|
-
if (replaced
|
|
349
|
+
if (typeof value === "string" && hasUrlVariables(value)) {
|
|
350
|
+
const replaced = resolveUrlVariables(value, context);
|
|
351
|
+
if (replaced === undefined) continue;
|
|
382
352
|
resolved[key] = replaced;
|
|
383
353
|
} else {
|
|
384
354
|
resolved[key] = value;
|
|
@@ -622,11 +592,9 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
|
|
|
622
592
|
resolveSubmitDefaults(props.submitDefaults, buildUrlContext()),
|
|
623
593
|
);
|
|
624
594
|
|
|
625
|
-
const resolvedFetchUrl = computed(() =>
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return url.includes("{{") ? undefined : url;
|
|
629
|
-
});
|
|
595
|
+
const resolvedFetchUrl = computed(() =>
|
|
596
|
+
resolveUrlVariables(props.fetchUrl, buildUrlContext()),
|
|
597
|
+
);
|
|
630
598
|
|
|
631
599
|
// A form that loaded a record and does not create one (a duplicate posts
|
|
632
600
|
// the loaded values as a new row) updates it: only its changes are sent.
|
|
@@ -638,11 +606,11 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
|
|
|
638
606
|
if (!props.fetchUrl) return undefined;
|
|
639
607
|
|
|
640
608
|
try {
|
|
641
|
-
const processedUrl =
|
|
609
|
+
const processedUrl = resolveUrlVariables(
|
|
642
610
|
props.fetchUrl,
|
|
643
611
|
buildUrlContext(),
|
|
644
612
|
);
|
|
645
|
-
if (processedUrl
|
|
613
|
+
if (processedUrl === undefined) return undefined;
|
|
646
614
|
|
|
647
615
|
const fetchedData = await $authFetch<FormFetchResponse>(processedUrl, {
|
|
648
616
|
method: props.fetchUrlMethod || "GET",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { registerDefaultDataTypes } from "../build/composables/data-types/registerDefaults";
|
|
2
2
|
import { registerDefaultFunctions } from "../build/composables/table-view/registerDefaultFunctions";
|
|
3
3
|
import { registerFormFunctions } from "../build/composables/form/registerFormFunctions";
|
|
4
|
+
import { registerBlockFunctions } from "../build/composables/blocks/registerBlockFunctions";
|
|
4
5
|
|
|
5
6
|
export default defineDmsPlugin(() => {
|
|
6
7
|
registerDefaultDataTypes();
|
|
7
8
|
registerDefaultFunctions();
|
|
8
9
|
registerFormFunctions();
|
|
10
|
+
registerBlockFunctions();
|
|
9
11
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The window event a data block (StatGroup, KeyValueList, NavCardGrid,
|
|
3
|
+
* ActivityFeed, KPI, top list, meter, chart, a tab set's badges) re-fetches
|
|
4
|
+
* on, keeping its values on screen until the new answer lands.
|
|
5
|
+
*/
|
|
6
|
+
export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
|
|
7
|
+
|
|
8
|
+
/** The watch functions every block can name (see interface-dms). */
|
|
9
|
+
export const BlockFunctions = {
|
|
10
|
+
REFRESH_PAGE: "DmsComponent.Block.RefreshPage",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Asks every data block mounted on the page to read its data again: what a
|
|
15
|
+
* component calls once an action of its own changed the record the page
|
|
16
|
+
* shows. The page header calls it after a button changed something.
|
|
17
|
+
*/
|
|
18
|
+
export function refreshPageBlocks(): void {
|
|
19
|
+
if (typeof window === "undefined") return;
|
|
20
|
+
window.dispatchEvent(new CustomEvent(BLOCK_REFRESH_EVENT));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. */
|
|
24
|
+
export function onPageBlocksRefresh(handler: () => void): () => void {
|
|
25
|
+
if (typeof window === "undefined") return () => {};
|
|
26
|
+
window.addEventListener(BLOCK_REFRESH_EVENT, handler);
|
|
27
|
+
return () => window.removeEventListener(BLOCK_REFRESH_EVENT, handler);
|
|
28
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Once an action changes the record a page shows, its data blocks read their
|
|
4
|
+
* data again in place: `refreshPageBlocks()` (or the `REFRESH_PAGE` watch
|
|
5
|
+
* function a form names) re-fetches every mounted block, keeps what it shows
|
|
6
|
+
* until the answer lands, and leaves an unmounted block alone.
|
|
7
|
+
*/
|
|
8
|
+
import { createApp, defineComponent, h, ref } from "vue";
|
|
9
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
BlockFunctions,
|
|
12
|
+
refreshPageBlocks,
|
|
13
|
+
} from "../layers/dms-ui/app/utils/blockRefresh";
|
|
14
|
+
|
|
15
|
+
const requests: string[] = [];
|
|
16
|
+
let answer = 1;
|
|
17
|
+
|
|
18
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
19
|
+
$authFetch: async (url: string) => {
|
|
20
|
+
requests.push(url);
|
|
21
|
+
return { value: answer };
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
vi.stubGlobal("useDmsRoute", () => ({ query: {} }));
|
|
25
|
+
vi.stubGlobal("ref", ref);
|
|
26
|
+
|
|
27
|
+
const { useChartFetch } = await import(
|
|
28
|
+
"../layers/dms-ui/app/composables/chart/useChartFetch"
|
|
29
|
+
);
|
|
30
|
+
const { useDefinedFunctions } = await import(
|
|
31
|
+
"../layers/dms-core/app/composables/functions/useDefinedFunction"
|
|
32
|
+
);
|
|
33
|
+
vi.stubGlobal("useDefinedFunctions", useDefinedFunctions);
|
|
34
|
+
const { registerBlockFunctions } = await import(
|
|
35
|
+
"../layers/dms-ui/app/build/composables/blocks/registerBlockFunctions"
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// useChartFetch debounces every refresh past the first by 180ms.
|
|
39
|
+
const SETTLE_MS = 250;
|
|
40
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
41
|
+
|
|
42
|
+
interface Probe {
|
|
43
|
+
data?: { value: { value: number } | null };
|
|
44
|
+
isLoading?: { value: boolean };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mountBlock(fetchUrl: string) {
|
|
48
|
+
const probe: Probe = {};
|
|
49
|
+
const Block = defineComponent({
|
|
50
|
+
setup() {
|
|
51
|
+
const { data, isLoading } = useChartFetch<{ value: number }>({
|
|
52
|
+
fetchUrl,
|
|
53
|
+
routeParams: () => ({ id: "ws-1" }),
|
|
54
|
+
});
|
|
55
|
+
probe.data = data;
|
|
56
|
+
probe.isLoading = isLoading;
|
|
57
|
+
return () => h("div");
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const app = createApp(Block);
|
|
61
|
+
app.mount(document.createElement("div"));
|
|
62
|
+
return { probe, unmount: () => app.unmount() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const apps: Array<() => void> = [];
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
requests.length = 0;
|
|
69
|
+
answer = 1;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
apps.splice(0).forEach((unmount) => unmount());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("re-fetches every mounted block, keeping its values until the answer", async () => {
|
|
77
|
+
const facts = mountBlock("/api/ws/{{params.id}}/facts");
|
|
78
|
+
const counts = mountBlock("/api/ws/{{params.id}}/tab-counts");
|
|
79
|
+
apps.push(facts.unmount, counts.unmount);
|
|
80
|
+
await settle();
|
|
81
|
+
expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-1/tab-counts"]);
|
|
82
|
+
|
|
83
|
+
answer = 2;
|
|
84
|
+
refreshPageBlocks();
|
|
85
|
+
expect(facts.probe.isLoading?.value).toBe(false);
|
|
86
|
+
expect(facts.probe.data?.value).toEqual({ value: 1 });
|
|
87
|
+
await settle();
|
|
88
|
+
|
|
89
|
+
expect(requests).toEqual([
|
|
90
|
+
"/api/ws/ws-1/facts",
|
|
91
|
+
"/api/ws/ws-1/tab-counts",
|
|
92
|
+
"/api/ws/ws-1/facts",
|
|
93
|
+
"/api/ws/ws-1/tab-counts",
|
|
94
|
+
]);
|
|
95
|
+
expect(facts.probe.data?.value).toEqual({ value: 2 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves an unmounted block alone", async () => {
|
|
99
|
+
const block = mountBlock("/api/ws/{{params.id}}/facts");
|
|
100
|
+
await settle();
|
|
101
|
+
block.unmount();
|
|
102
|
+
refreshPageBlocks();
|
|
103
|
+
await settle();
|
|
104
|
+
expect(requests).toEqual(["/api/ws/ws-1/facts"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not request a URL whose token is unresolved", async () => {
|
|
108
|
+
const block = mountBlock("/api/ws/{{params.workspace}}/facts");
|
|
109
|
+
apps.push(block.unmount);
|
|
110
|
+
refreshPageBlocks();
|
|
111
|
+
await settle();
|
|
112
|
+
expect(requests).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("runs from the REFRESH_PAGE watch function", async () => {
|
|
116
|
+
registerBlockFunctions();
|
|
117
|
+
const block = mountBlock("/api/ws/{{params.id}}/facts");
|
|
118
|
+
apps.push(block.unmount);
|
|
119
|
+
await settle();
|
|
120
|
+
|
|
121
|
+
const refresh = useDefinedFunctions().getFunction(
|
|
122
|
+
BlockFunctions.REFRESH_PAGE,
|
|
123
|
+
);
|
|
124
|
+
expect(refresh).toBeTypeOf("function");
|
|
125
|
+
refresh?.();
|
|
126
|
+
await settle();
|
|
127
|
+
expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-1/facts"]);
|
|
128
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A data block on a detail page reads the record of that page: the
|
|
4
|
+
* `{{params.X}}` / `{{query.X}}` tokens of its `fetchUrl` are filled from the
|
|
5
|
+
* page URL, again when it changes, and a URL with a token left unresolved is
|
|
6
|
+
* never requested.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
type Component,
|
|
10
|
+
createApp,
|
|
11
|
+
defineComponent,
|
|
12
|
+
h,
|
|
13
|
+
nextTick,
|
|
14
|
+
reactive,
|
|
15
|
+
ref,
|
|
16
|
+
} from "vue";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
import {
|
|
19
|
+
replaceUrlVariables,
|
|
20
|
+
resolveUrlVariables,
|
|
21
|
+
} from "../layers/dms-ui/app/build/utils/urlVariables";
|
|
22
|
+
|
|
23
|
+
const requests: string[] = [];
|
|
24
|
+
const route = reactive<{ query: Record<string, unknown> }>({ query: {} });
|
|
25
|
+
|
|
26
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
27
|
+
$authFetch: async (url: string) => {
|
|
28
|
+
requests.push(url);
|
|
29
|
+
return { items: [{ label: url }] };
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
vi.stubGlobal("useDmsRoute", () => route);
|
|
33
|
+
|
|
34
|
+
vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
|
|
35
|
+
useWatch: () => ({ state: ref({}) }),
|
|
36
|
+
}));
|
|
37
|
+
vi.mock(
|
|
38
|
+
"../layers/dms-core/app/composables/components/useComponentEvent",
|
|
39
|
+
() => ({ useComponentEvent: () => ({}) }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const { useChartFetch } = await import(
|
|
43
|
+
"../layers/dms-ui/app/composables/chart/useChartFetch"
|
|
44
|
+
);
|
|
45
|
+
const { useBlockItems } = await import(
|
|
46
|
+
"../layers/dms-ui/app/build/composables/blocks/useBlockItems"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// useChartFetch debounces every refresh past the first by 180ms.
|
|
50
|
+
const SETTLE_MS = 250;
|
|
51
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
52
|
+
|
|
53
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
54
|
+
|
|
55
|
+
function mount(component: Component) {
|
|
56
|
+
const app = createApp(component);
|
|
57
|
+
app.mount(document.createElement("div"));
|
|
58
|
+
mounted.push(app);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
requests.length = 0;
|
|
63
|
+
route.query = {};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("resolveUrlVariables", () => {
|
|
71
|
+
it("fills route parameters and query values", () => {
|
|
72
|
+
expect(
|
|
73
|
+
resolveUrlVariables("/api/ws/{{params.id}}/facts?tab={{query.tab}}", {
|
|
74
|
+
routeParams: { id: "ws-1" },
|
|
75
|
+
routeQuery: { tab: "billing" },
|
|
76
|
+
}),
|
|
77
|
+
).toBe("/api/ws/ws-1/facts?tab=billing");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reaches one occurrence of a repeated parameter", () => {
|
|
81
|
+
expect(
|
|
82
|
+
resolveUrlVariables("/api/ws/{{params.id:1}}/rows/{{params.id}}", {
|
|
83
|
+
routeParams: { id: "row-7", "id:1": "ws-1", "id:2": "row-7" },
|
|
84
|
+
routeQuery: {},
|
|
85
|
+
}),
|
|
86
|
+
).toBe("/api/ws/ws-1/rows/row-7");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("gives nothing while a token has no value", () => {
|
|
90
|
+
const context = { routeParams: {}, routeQuery: {} };
|
|
91
|
+
expect(resolveUrlVariables("/api/ws/{{params.id}}", context)).toBe(
|
|
92
|
+
undefined,
|
|
93
|
+
);
|
|
94
|
+
expect(replaceUrlVariables("/api/ws/{{params.id}}", context)).toBe(
|
|
95
|
+
"/api/ws/{{params.id}}",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("leaves a URL without tokens as it is", () => {
|
|
100
|
+
expect(resolveUrlVariables("/api/plain", { routeQuery: {} })).toBe(
|
|
101
|
+
"/api/plain",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("useChartFetch with a templated fetchUrl", () => {
|
|
107
|
+
function mountFetch(
|
|
108
|
+
fetchUrl: string,
|
|
109
|
+
routeParams: () => Record<string, string> | undefined,
|
|
110
|
+
) {
|
|
111
|
+
const state: { isLoading?: { value: boolean } } = {};
|
|
112
|
+
const Probe = defineComponent({
|
|
113
|
+
setup() {
|
|
114
|
+
const { isLoading } = useChartFetch<unknown>({ fetchUrl, routeParams });
|
|
115
|
+
state.isLoading = isLoading;
|
|
116
|
+
return () => h("div");
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
mount(Probe);
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it("requests the URL of the page's record", async () => {
|
|
124
|
+
mountFetch("/api/ws/{{params.id}}/facts", () => ({ id: "ws-1" }));
|
|
125
|
+
await settle();
|
|
126
|
+
expect(requests).toEqual(["/api/ws/ws-1/facts"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("requests nothing, and shows no skeleton, while a token is unresolved", async () => {
|
|
130
|
+
const state = mountFetch("/api/ws/{{params.id}}/facts", () => ({}));
|
|
131
|
+
await settle();
|
|
132
|
+
expect(requests).toEqual([]);
|
|
133
|
+
expect(state.isLoading?.value).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("follows the route to another record", async () => {
|
|
137
|
+
const params = ref<Record<string, string>>({ id: "ws-1" });
|
|
138
|
+
mountFetch("/api/ws/{{params.id}}/facts", () => params.value);
|
|
139
|
+
await settle();
|
|
140
|
+
params.value = { id: "ws-2" };
|
|
141
|
+
await nextTick();
|
|
142
|
+
await settle();
|
|
143
|
+
expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-2/facts"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("fetches once a query value it waited for appears", async () => {
|
|
147
|
+
mountFetch("/api/activity?actor={{query.actor}}", () => ({}));
|
|
148
|
+
await settle();
|
|
149
|
+
expect(requests).toEqual([]);
|
|
150
|
+
route.query = { actor: "u-1" };
|
|
151
|
+
await nextTick();
|
|
152
|
+
await settle();
|
|
153
|
+
expect(requests).toEqual(["/api/activity?actor=u-1"]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("useBlockItems with a templated fetchUrl", () => {
|
|
158
|
+
function mountItems(routeParams: Record<string, string>) {
|
|
159
|
+
const state: { items?: { value: unknown[] } } = {};
|
|
160
|
+
const Probe = defineComponent({
|
|
161
|
+
setup() {
|
|
162
|
+
const { items } = useBlockItems<{ label: string }>({
|
|
163
|
+
items: () => [{ label: "static" }],
|
|
164
|
+
fetchUrl: "/api/ws/{{params.id}}/billing-info",
|
|
165
|
+
routeParams: () => routeParams,
|
|
166
|
+
});
|
|
167
|
+
state.items = items;
|
|
168
|
+
return () => h("div");
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
mount(Probe);
|
|
172
|
+
return state;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
it("lists the items of the page's record", async () => {
|
|
176
|
+
const state = mountItems({ id: "ws-1" });
|
|
177
|
+
await settle();
|
|
178
|
+
expect(requests).toEqual(["/api/ws/ws-1/billing-info"]);
|
|
179
|
+
expect(state.items?.value).toEqual([
|
|
180
|
+
{ label: "/api/ws/ws-1/billing-info" },
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("shows its empty state, not the static items, without the record", async () => {
|
|
185
|
+
const state = mountItems({});
|
|
186
|
+
await settle();
|
|
187
|
+
expect(requests).toEqual([]);
|
|
188
|
+
expect(state.items?.value).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/AntelopeJS/dms.git"
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
|
|
38
38
|
"@antelopejs/interface-database": ">=0.1.8 <1.0.0",
|
|
39
39
|
"@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
|
|
40
|
-
"@antelopejs/interface-dms": ">=0.4.
|
|
40
|
+
"@antelopejs/interface-dms": ">=0.4.1 <0.5.0",
|
|
41
41
|
"@antelopejs/interface-email": ">=0.0.5 <1.0.0",
|
|
42
42
|
"@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
|
|
43
43
|
"archiver": "^7.0.1",
|
package/skills/dms/SKILL.md
CHANGED
|
@@ -32,6 +32,9 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
|
|
|
32
32
|
`Grid`, `KpiCard`, … — fluent builders producing a **serializable description**, not live UI: the
|
|
33
33
|
frontend renders it. **Components fetch their own data** via URLs (`fetchUrl`, `submitUrl`, …) —
|
|
34
34
|
routes often declared `@Get`/`@Post` on the same page class; the backend stays the source of truth.
|
|
35
|
+
A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL;
|
|
36
|
+
nothing is fetched while one is unresolved), and `.watch(FormEvents.SUBMIT_SUCCESS,
|
|
37
|
+
BlockFunctions.REFRESH_PAGE)` makes the page's data blocks re-fetch after a form saves.
|
|
35
38
|
- **It's interfaces all the way down.** Everything you consume from the DMS is an AntelopeJS
|
|
36
39
|
interface imported as an `@antelopejs/interface-dms/...` subpath (see the imports below);
|
|
37
40
|
a distributable module exposes its own from a companion `@antelopejs/interface-<name>` package listed in `antelopeJs.implements`.
|