@jskit-ai/users-web 0.1.81 → 0.1.83

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.
Files changed (40) hide show
  1. package/package.descriptor.mjs +51 -9
  2. package/package.json +21 -10
  3. package/src/client/bulkActions.js +47 -0
  4. package/src/client/components/AccountSettingsClientElement.vue +69 -24
  5. package/src/client/components/CrudAddEditScreen.vue +186 -0
  6. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  7. package/src/client/components/CrudListFilterSurface.vue +377 -0
  8. package/src/client/components/CrudListScreen.vue +434 -0
  9. package/src/client/components/CrudViewScreen.vue +186 -0
  10. package/src/client/components/ProfileClientElement.vue +19 -12
  11. package/src/client/composables/records/useAddEdit.js +23 -2
  12. package/src/client/composables/records/useCrudAddEdit.js +8 -0
  13. package/src/client/composables/records/useCrudList.js +11 -1
  14. package/src/client/composables/records/useCrudView.js +1 -0
  15. package/src/client/composables/records/useView.js +9 -2
  16. package/src/client/composables/runtime/operationUiHelpers.js +7 -3
  17. package/src/client/composables/runtime/useEndpointResource.js +20 -2
  18. package/src/client/composables/runtime/useUiFeedback.js +4 -2
  19. package/src/client/composables/support/resourceLoadStateHelpers.js +33 -1
  20. package/src/client/composables/useAccountSettingsRuntime.js +10 -1
  21. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  22. package/src/client/composables/useCrudListBulkActions.js +147 -0
  23. package/src/client/composables/useCrudListScreen.js +107 -0
  24. package/src/client/composables/useCrudViewScreen.js +67 -0
  25. package/src/client/composables/usePagedCollection.js +6 -1
  26. package/src/client/composables/useRealtimeQueryInvalidation.js +26 -0
  27. package/src/client/filters.js +15 -0
  28. package/src/client/index.js +5 -0
  29. package/templates/src/components/account/settings/AccountSettingsNotificationsSection.vue +34 -8
  30. package/templates/src/components/account/settings/AccountSettingsPreferencesSection.vue +34 -8
  31. package/templates/src/components/account/settings/AccountSettingsProfileSection.vue +34 -8
  32. package/test/crudListBulkActionSurface.test.js +27 -0
  33. package/test/crudListFilterSurface.test.js +45 -0
  34. package/test/crudScreenComponents.test.js +62 -0
  35. package/test/errorIntentContract.test.js +31 -0
  36. package/test/exportsContract.test.js +11 -0
  37. package/test/requestTransportOptions.test.js +110 -1
  38. package/test/resourceLoadStateHelpers.test.js +35 -1
  39. package/test/settingsPlacementContract.test.js +61 -0
  40. package/test/useCrudListBulkActions.test.js +65 -0
@@ -0,0 +1,107 @@
1
+ import { computed } from "vue";
2
+ import { useCrudList } from "./records/useCrudList.js";
3
+ import { useCrudListBulkActions } from "./useCrudListBulkActions.js";
4
+ import { useCrudListFilters } from "./useCrudListFilters.js";
5
+
6
+ function formatCrudListCardValue(value) {
7
+ if (value === null || value === undefined || value === "") {
8
+ return "-";
9
+ }
10
+ if (value === true) {
11
+ return "Yes";
12
+ }
13
+ if (value === false) {
14
+ return "No";
15
+ }
16
+ return value;
17
+ }
18
+
19
+ function useCrudListScreen({
20
+ adapter = null,
21
+ resource = null,
22
+ resourceNamespace = "resource",
23
+ apiSuffix = "",
24
+ recordIdParam = "recordId",
25
+ recordIdSelector = null,
26
+ titleFallbackFieldKey = "",
27
+ viewUrlTemplate = "",
28
+ editUrlTemplate = "",
29
+ newUrlTemplate = "",
30
+ recordChangedEvents = [],
31
+ listFilters = {},
32
+ listBulkActions = [],
33
+ routeQueryBlacklist = Object.freeze(["include", "cursor", "limit"]),
34
+ fallbackLoadError = "Unable to load records."
35
+ } = {}) {
36
+ const filterRuntime = useCrudListFilters(listFilters);
37
+ const normalizedRecordChangedEvents = Array.isArray(recordChangedEvents)
38
+ ? recordChangedEvents
39
+ : [];
40
+ const normalizedResourceNamespace = String(resourceNamespace || "resource").trim() || "resource";
41
+ const records = useCrudList({
42
+ adapter: adapter || undefined,
43
+ resource,
44
+ apiSuffix,
45
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
46
+ "ui-generator",
47
+ normalizedResourceNamespace,
48
+ "list",
49
+ String(surfaceId || ""),
50
+ String(workspaceSlug || "")
51
+ ],
52
+ search: {
53
+ enabled: true,
54
+ mode: "query"
55
+ },
56
+ queryParams: filterRuntime.queryParams,
57
+ syncToRoute: {
58
+ enabled: true,
59
+ mode: "replace",
60
+ search: true,
61
+ queryParams: true,
62
+ queryParamBlacklist: routeQueryBlacklist
63
+ },
64
+ placementSource: `ui-generator.${normalizedResourceNamespace}.list`,
65
+ fallbackLoadError,
66
+ recordIdParam,
67
+ recordIdSelector,
68
+ viewUrlTemplate,
69
+ editUrlTemplate,
70
+ realtime: normalizedRecordChangedEvents.length > 0
71
+ ? {
72
+ events: normalizedRecordChangedEvents
73
+ }
74
+ : null
75
+ });
76
+ const bulkActions = useCrudListBulkActions(listBulkActions, {
77
+ resolveRecordId: (record, index) => records.resolveRowKey(record, index),
78
+ resolveContext: () => ({
79
+ records,
80
+ reload: records.reload
81
+ })
82
+ });
83
+ const listPrimaryAction = computed(() =>
84
+ newUrlTemplate ? records.resolveParams(newUrlTemplate) : ""
85
+ );
86
+
87
+ function resolveRecordTitle(record) {
88
+ return records.resolveRecordTitle(record, {
89
+ fallbackKey: titleFallbackFieldKey,
90
+ defaultValue: "Record"
91
+ });
92
+ }
93
+
94
+ return Object.freeze({
95
+ records,
96
+ listFilters,
97
+ filterRuntime,
98
+ bulkActions,
99
+ listPrimaryAction,
100
+ hasViewUrl: Boolean(viewUrlTemplate),
101
+ hasEditUrl: Boolean(editUrlTemplate),
102
+ resolveRecordTitle,
103
+ formatListCardValue: formatCrudListCardValue
104
+ });
105
+ }
106
+
107
+ export { useCrudListScreen };
@@ -0,0 +1,67 @@
1
+ import { computed, unref } from "vue";
2
+ import { useRoute } from "vue-router";
3
+ import { useCrudView } from "./records/useCrudView.js";
4
+
5
+ function useCrudViewScreen({
6
+ adapter = null,
7
+ resource = null,
8
+ resourceNamespace = "resource",
9
+ apiUrlTemplate = "",
10
+ recordIdParam = "recordId",
11
+ titleFallbackFieldKey = "",
12
+ listUrlTemplate = "",
13
+ editUrlTemplate = "",
14
+ recordChangedEvent = "",
15
+ fallbackLoadError = "Unable to load record.",
16
+ notFoundMessage = "Record not found."
17
+ } = {}) {
18
+ const route = useRoute();
19
+ const normalizedResourceNamespace = String(resourceNamespace || "resource").trim() || "resource";
20
+ const view = useCrudView({
21
+ adapter: adapter || undefined,
22
+ resource,
23
+ apiUrlTemplate,
24
+ recordIdParam,
25
+ includeRecordIdInQueryKey: true,
26
+ queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
27
+ "ui-generator",
28
+ normalizedResourceNamespace,
29
+ "view",
30
+ String(surfaceId || ""),
31
+ String(workspaceSlug || "")
32
+ ],
33
+ placementSource: `ui-generator.${normalizedResourceNamespace}.view`,
34
+ fallbackLoadError,
35
+ notFoundMessage,
36
+ listUrlTemplate,
37
+ editUrlTemplate,
38
+ realtime: recordChangedEvent
39
+ ? {
40
+ event: recordChangedEvent
41
+ }
42
+ : null
43
+ });
44
+ const recordTitle = computed(() =>
45
+ view.resolveRecordTitle(view.record, {
46
+ fallbackKey: titleFallbackFieldKey,
47
+ defaultValue: "Record"
48
+ })
49
+ );
50
+
51
+ function resolveRouteLocation(urlTemplate = "") {
52
+ const path = view.resolveParams(unref(urlTemplate));
53
+ return path ? { path, query: route.query } : null;
54
+ }
55
+
56
+ const listLocation = computed(() => resolveRouteLocation(listUrlTemplate));
57
+ const editLocation = computed(() => resolveRouteLocation(editUrlTemplate));
58
+
59
+ return Object.freeze({
60
+ view,
61
+ recordTitle,
62
+ listLocation,
63
+ editLocation
64
+ });
65
+ }
66
+
67
+ export { useCrudViewScreen };
@@ -3,6 +3,7 @@ import { useInfiniteQuery, useQueryClient } from "@tanstack/vue-query";
3
3
  import { asPlainObject } from "./support/scopeHelpers.js";
4
4
  import { resolveEnabledRef } from "./support/refValueHelpers.js";
5
5
  import { toQueryErrorMessage } from "./support/errorMessageHelpers.js";
6
+ import { mergeQueryMeta } from "./support/resourceLoadStateHelpers.js";
6
7
 
7
8
  function defaultSelectItems(page) {
8
9
  return Array.isArray(page?.items) ? page.items : [];
@@ -83,7 +84,11 @@ function usePagedCollection({
83
84
  queryFn,
84
85
  getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
85
86
  getNextPageParam(lastPage, allPages, lastPageParam, allPageParams),
86
- ...(asPlainObject(queryOptions))
87
+ ...mergeQueryMeta(asPlainObject(queryOptions), {
88
+ jskit: {
89
+ refreshOnPull: true
90
+ }
91
+ })
87
92
  });
88
93
 
89
94
  const pages = computed(() => {
@@ -14,6 +14,31 @@ function normalizeRealtimeOptions(value = {}) {
14
14
  return value;
15
15
  }
16
16
 
17
+ function hasRealtimeEventConfig(value = {}) {
18
+ const source = normalizeRealtimeOptions(value);
19
+ return Object.hasOwn(source, "event") || Object.hasOwn(source, "events");
20
+ }
21
+
22
+ function resolveOperationRealtimeOptions({
23
+ realtime = undefined,
24
+ fallbackRealtime = null
25
+ } = {}) {
26
+ if (realtime === false) {
27
+ return null;
28
+ }
29
+
30
+ const fallback = normalizeRealtimeOptions(fallbackRealtime);
31
+ const explicit = realtime == null ? {} : normalizeRealtimeOptions(realtime);
32
+ if (hasRealtimeEventConfig(explicit) || !hasRealtimeEventConfig(fallback)) {
33
+ return Object.keys(explicit).length > 0 ? explicit : null;
34
+ }
35
+
36
+ return Object.freeze({
37
+ ...fallback,
38
+ ...explicit
39
+ });
40
+ }
41
+
17
42
  function resolveEnabled(value) {
18
43
  if (typeof value === "undefined") {
19
44
  return true;
@@ -125,6 +150,7 @@ function useOperationRealtime({
125
150
  }
126
151
 
127
152
  export {
153
+ resolveOperationRealtimeOptions,
128
154
  useRealtimeQueryInvalidation,
129
155
  useOperationRealtime
130
156
  };
@@ -0,0 +1,15 @@
1
+ export {
2
+ CRUD_LIST_FILTER_TYPE_FLAG,
3
+ CRUD_LIST_FILTER_TYPE_ENUM,
4
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
5
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
6
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
7
+ CRUD_LIST_FILTER_TYPE_DATE,
8
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
9
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
10
+ CRUD_LIST_FILTER_TYPE_PRESENCE,
11
+ CRUD_LIST_FILTER_PRESENCE_PRESENT,
12
+ CRUD_LIST_FILTER_PRESENCE_MISSING,
13
+ CRUD_LIST_FILTER_PRESENCE_OPTIONS,
14
+ defineCrudListFilters
15
+ } from "@jskit-ai/kernel/shared/support/crudListFilters";
@@ -2,6 +2,11 @@ import { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
2
2
 
3
3
  export { UsersWebClientProvider } from "./providers/UsersWebClientProvider.js";
4
4
  export { default as AccountSettingsClientElement } from "./components/AccountSettingsClientElement.vue";
5
+ export { default as CrudAddEditScreen } from "./components/CrudAddEditScreen.vue";
6
+ export { default as CrudListBulkActionSurface } from "./components/CrudListBulkActionSurface.vue";
7
+ export { default as CrudListFilterSurface } from "./components/CrudListFilterSurface.vue";
8
+ export { default as CrudListScreen } from "./components/CrudListScreen.vue";
9
+ export { default as CrudViewScreen } from "./components/CrudViewScreen.vue";
5
10
 
6
11
  const clientProviders = Object.freeze([UsersWebClientProvider]);
7
12
 
@@ -10,12 +10,11 @@ const notifications = props.runtime.notifications;
10
10
  </script>
11
11
 
12
12
  <template>
13
- <v-card rounded="lg" elevation="0" border>
14
- <v-card-item>
15
- <v-card-title class="text-subtitle-1">Notifications</v-card-title>
16
- </v-card-item>
17
- <v-divider />
18
- <v-card-text>
13
+ <v-sheet rounded="lg" border class="account-settings-section">
14
+ <header class="account-settings-section__header">
15
+ <h2 class="account-settings-section__title">Notifications</h2>
16
+ </header>
17
+ <div class="account-settings-section__body">
19
18
  <v-form @submit.prevent="notifications.submit" novalidate>
20
19
  <v-switch
21
20
  v-model="notifications.form.productUpdates"
@@ -50,6 +49,33 @@ const notifications = props.runtime.notifications;
50
49
  Save notification settings
51
50
  </v-btn>
52
51
  </v-form>
53
- </v-card-text>
54
- </v-card>
52
+ </div>
53
+ </v-sheet>
55
54
  </template>
55
+
56
+ <style scoped>
57
+ .account-settings-section {
58
+ overflow: hidden;
59
+ }
60
+
61
+ .account-settings-section__header {
62
+ padding: 1rem 1rem 0;
63
+ }
64
+
65
+ .account-settings-section__title {
66
+ font-size: 1rem;
67
+ font-weight: 650;
68
+ line-height: 1.2;
69
+ margin: 0;
70
+ }
71
+
72
+ .account-settings-section__body {
73
+ padding: 1rem;
74
+ }
75
+
76
+ @media (max-width: 640px) {
77
+ .account-settings-section__body :deep(.v-btn) {
78
+ min-height: 48px;
79
+ }
80
+ }
81
+ </style>
@@ -10,12 +10,11 @@ const preferences = props.runtime.preferences;
10
10
  </script>
11
11
 
12
12
  <template>
13
- <v-card rounded="lg" elevation="0" border>
14
- <v-card-item>
15
- <v-card-title class="text-subtitle-1">Preferences</v-card-title>
16
- </v-card-item>
17
- <v-divider />
18
- <v-card-text>
13
+ <v-sheet rounded="lg" border class="account-settings-section">
14
+ <header class="account-settings-section__header">
15
+ <h2 class="account-settings-section__title">Preferences</h2>
16
+ </header>
17
+ <div class="account-settings-section__body">
19
18
  <v-form @submit.prevent="preferences.submit" novalidate>
20
19
  <v-row>
21
20
  <v-col cols="12" md="4">
@@ -120,6 +119,33 @@ const preferences = props.runtime.preferences;
120
119
  Save preferences
121
120
  </v-btn>
122
121
  </v-form>
123
- </v-card-text>
124
- </v-card>
122
+ </div>
123
+ </v-sheet>
125
124
  </template>
125
+
126
+ <style scoped>
127
+ .account-settings-section {
128
+ overflow: hidden;
129
+ }
130
+
131
+ .account-settings-section__header {
132
+ padding: 1rem 1rem 0;
133
+ }
134
+
135
+ .account-settings-section__title {
136
+ font-size: 1rem;
137
+ font-weight: 650;
138
+ line-height: 1.2;
139
+ margin: 0;
140
+ }
141
+
142
+ .account-settings-section__body {
143
+ padding: 1rem;
144
+ }
145
+
146
+ @media (max-width: 640px) {
147
+ .account-settings-section__body :deep(.v-btn) {
148
+ min-height: 48px;
149
+ }
150
+ }
151
+ </style>
@@ -10,12 +10,11 @@ const profile = props.runtime.profile;
10
10
  </script>
11
11
 
12
12
  <template>
13
- <v-card rounded="lg" elevation="0" border>
14
- <v-card-item>
15
- <v-card-title class="text-subtitle-1">Profile</v-card-title>
16
- </v-card-item>
17
- <v-divider />
18
- <v-card-text>
13
+ <v-sheet rounded="lg" border class="account-settings-section">
14
+ <header class="account-settings-section__header">
15
+ <h2 class="account-settings-section__title">Profile</h2>
16
+ </header>
17
+ <div class="account-settings-section__body">
19
18
  <v-form @submit.prevent="profile.submit" novalidate>
20
19
  <v-row class="mb-2">
21
20
  <v-col cols="12" md="4" class="d-flex flex-column align-center justify-center">
@@ -89,6 +88,33 @@ const profile = props.runtime.profile;
89
88
  Save profile
90
89
  </v-btn>
91
90
  </v-form>
92
- </v-card-text>
93
- </v-card>
91
+ </div>
92
+ </v-sheet>
94
93
  </template>
94
+
95
+ <style scoped>
96
+ .account-settings-section {
97
+ overflow: hidden;
98
+ }
99
+
100
+ .account-settings-section__header {
101
+ padding: 1rem 1rem 0;
102
+ }
103
+
104
+ .account-settings-section__title {
105
+ font-size: 1rem;
106
+ font-weight: 650;
107
+ line-height: 1.2;
108
+ margin: 0;
109
+ }
110
+
111
+ .account-settings-section__body {
112
+ padding: 1rem;
113
+ }
114
+
115
+ @media (max-width: 640px) {
116
+ .account-settings-section__body :deep(.v-btn) {
117
+ min-height: 48px;
118
+ }
119
+ }
120
+ </style>
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import path from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ test("CrudListBulkActionSurface stays adaptive and runtime-owned", async () => {
11
+ const source = await readFile(
12
+ path.join(PACKAGE_DIR, "src", "client", "components", "CrudListBulkActionSurface.vue"),
13
+ "utf8"
14
+ );
15
+
16
+ assert.match(source, /defineProps/);
17
+ assert.match(source, /useDisplay/);
18
+ assert.match(source, /v-if="shouldRender"/);
19
+ assert.match(source, /selectedCount/);
20
+ assert.match(source, /hasActions/);
21
+ assert.match(source, /hasSelection/);
22
+ assert.match(source, /v-menu/);
23
+ assert.match(source, /Bulk actions/);
24
+ assert.match(source, /min-height:\s*48px/);
25
+ assert.doesNotMatch(source, /useCrudList\(/);
26
+ assert.doesNotMatch(source, /apiSuffix|repository|server/);
27
+ });
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ test("users-web exposes client-side CRUD list filter definition helpers", async () => {
11
+ const { defineCrudListFilters } = await import("@jskit-ai/users-web/client/filters");
12
+
13
+ const filters = defineCrudListFilters({
14
+ status: {
15
+ type: "enum",
16
+ label: "Status",
17
+ options: [
18
+ { value: "active", label: "Active" }
19
+ ]
20
+ }
21
+ });
22
+
23
+ assert.equal(filters.status.queryKey, "status");
24
+ assert.equal(filters.status.options[0].label, "Active");
25
+ });
26
+
27
+ test("CrudListFilterSurface provides adaptive controls without owning server semantics", async () => {
28
+ const source = await readFile(
29
+ path.join(PACKAGE_DIR, "src", "client", "components", "CrudListFilterSurface.vue"),
30
+ "utf8"
31
+ );
32
+
33
+ assert.match(source, /defineProps/);
34
+ assert.match(source, /useDisplay/);
35
+ assert.match(source, /v-if="shouldRender"/);
36
+ assert.match(source, /filterEntries/);
37
+ assert.match(source, /runtimeValues\[filter\.key\]/);
38
+ assert.match(source, /activeChips/);
39
+ assert.match(source, /clearChip/);
40
+ assert.match(source, /clearFilters/);
41
+ assert.match(source, /v-dialog/);
42
+ assert.match(source, /min-height:\s*48px/);
43
+ assert.doesNotMatch(source, /useCrudList\(/);
44
+ assert.doesNotMatch(source, /apiSuffix|server|repository/);
45
+ });
@@ -0,0 +1,62 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ async function readComponent(name) {
11
+ return readFile(path.join(PACKAGE_DIR, "src", "client", "components", name), "utf8");
12
+ }
13
+
14
+ test("CRUD screen components own generated list/view/form chrome centrally", async () => {
15
+ const listSource = await readComponent("CrudListScreen.vue");
16
+ const viewSource = await readComponent("CrudViewScreen.vue");
17
+ const addEditSource = await readComponent("CrudAddEditScreen.vue");
18
+
19
+ assert.match(listSource, /CrudListBulkActionSurface/);
20
+ assert.match(listSource, /CrudListFilterSurface/);
21
+ assert.match(listSource, /ui-generator-list-cards d-md-none/);
22
+ assert.match(listSource, /ui-generator-list-table d-none d-md-block/);
23
+ assert.match(listSource, /class="ui-generator-list-fab d-md-none"/);
24
+ assert.match(listSource, /#activator[\s\S]*Actions/);
25
+ assert.match(listSource, /min-height:\s*48px/);
26
+ assert.match(listSource, /<slot[\s\S]*name="card-fields"/);
27
+ assert.match(listSource, /<slot name="table-header"/);
28
+ assert.match(listSource, /<slot name="table-row"/);
29
+
30
+ assert.match(viewSource, /generated-ui-screen generated-ui-screen--operator ui-generator-view-element/);
31
+ assert.match(viewSource, /ui-generator-view-panel/);
32
+ assert.match(viewSource, /@click="view\.refresh"/);
33
+ assert.match(viewSource, /<slot name="fields"/);
34
+
35
+ assert.match(addEditSource, /generated-ui-screen generated-ui-screen--operator ui-generator-add-edit-form/);
36
+ assert.match(addEditSource, /addEdit\.canRetryLoad/);
37
+ assert.match(addEditSource, /@click="addEdit\.refresh"/);
38
+ assert.match(addEditSource, /<slot[\s\S]*name="fields"/);
39
+ });
40
+
41
+ test("account settings load state exposes a local retry action", async () => {
42
+ const source = await readComponent("AccountSettingsClientElement.vue");
43
+
44
+ assert.match(source, /settingsLoadError/);
45
+ assert.match(source, /@click="runtime\.refreshSettings"/);
46
+ });
47
+
48
+ test("CRUD screen composables are importable package APIs", async () => {
49
+ const [
50
+ listModule,
51
+ viewModule,
52
+ addEditModule
53
+ ] = await Promise.all([
54
+ import("@jskit-ai/users-web/client/composables/useCrudListScreen"),
55
+ import("@jskit-ai/users-web/client/composables/useCrudViewScreen"),
56
+ import("@jskit-ai/users-web/client/composables/useCrudAddEditScreen")
57
+ ]);
58
+
59
+ assert.equal(typeof listModule.useCrudListScreen, "function");
60
+ assert.equal(typeof viewModule.useCrudViewScreen, "function");
61
+ assert.equal(typeof addEditModule.useCrudAddEditScreen, "function");
62
+ });
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
9
+
10
+ async function readClientSource(relativePath) {
11
+ return readFile(path.join(PACKAGE_DIR, "src", "client", relativePath), "utf8");
12
+ }
13
+
14
+ test("users-web reports load errors as local resource-load intent by default", async () => {
15
+ const source = await readClientSource("composables/runtime/operationUiHelpers.js");
16
+
17
+ assert.match(source, /loadChannel = ""/);
18
+ assert.match(source, /notFoundChannel = ""/);
19
+ assert.match(source, /intent = "resource-load"/);
20
+ assert.match(source, /intent: "resource-load"/);
21
+ assert.doesNotMatch(source, /loadChannel = "banner"/);
22
+ });
23
+
24
+ test("users-web action feedback lets shell policy choose snackbar presentation", async () => {
25
+ const source = await readClientSource("composables/runtime/useUiFeedback.js");
26
+
27
+ assert.match(source, /successChannel = ""/);
28
+ assert.match(source, /errorChannel = ""/);
29
+ assert.match(source, /intent: "action-feedback"/);
30
+ assert.doesNotMatch(source, /errorChannel = "banner"/);
31
+ });
@@ -16,7 +16,14 @@ test("users-web exports are explicit and aligned with production/template usage"
16
16
  requiredExports: [
17
17
  "./client",
18
18
  "./client/components/AccountSettingsClientElement",
19
+ "./client/components/CrudAddEditScreen",
20
+ "./client/components/CrudListBulkActionSurface",
21
+ "./client/components/CrudListFilterSurface",
22
+ "./client/components/CrudListScreen",
23
+ "./client/components/CrudViewScreen",
19
24
  "./client/account-settings/sections",
25
+ "./client/bulkActions",
26
+ "./client/filters",
20
27
  "./client/composables/useAddEdit",
21
28
  "./client/composables/useCommand",
22
29
  "./client/composables/useEndpointResource",
@@ -24,10 +31,14 @@ test("users-web exports are explicit and aligned with production/template usage"
24
31
  "./client/composables/usePaths",
25
32
  "./client/composables/useView",
26
33
  "./client/composables/useCrudAddEdit",
34
+ "./client/composables/useCrudAddEditScreen",
35
+ "./client/composables/useCrudListBulkActions",
27
36
  "./client/composables/useCrudListFilterLookups",
28
37
  "./client/composables/useCrudListFilters",
29
38
  "./client/composables/useCrudList",
39
+ "./client/composables/useCrudListScreen",
30
40
  "./client/composables/useCrudView",
41
+ "./client/composables/useCrudViewScreen",
31
42
  "./client/lib/httpClient"
32
43
  ]
33
44
  });