@jskit-ai/http-web 0.1.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.
Files changed (110) hide show
  1. package/fixtures/crud-list-date-filters/App.vue +107 -0
  2. package/fixtures/crud-list-date-filters/index.html +12 -0
  3. package/fixtures/crud-list-date-filters/main.js +24 -0
  4. package/fixtures/crud-list-date-filters/vite.config.mjs +18 -0
  5. package/package.json +150 -0
  6. package/src/client/bulkActions.js +47 -0
  7. package/src/client/components/CrudAddEditScreen.vue +186 -0
  8. package/src/client/components/CrudDeleteAction.vue +93 -0
  9. package/src/client/components/CrudListBulkActionSurface.vue +126 -0
  10. package/src/client/components/CrudListDateFilterControl.vue +188 -0
  11. package/src/client/components/CrudListFilterSurface.vue +388 -0
  12. package/src/client/components/CrudListRecordActionMenu.vue +120 -0
  13. package/src/client/components/CrudListScreen.vue +501 -0
  14. package/src/client/components/CrudViewScreen.vue +197 -0
  15. package/src/client/composables/crud/crudBindingSupport.js +75 -0
  16. package/src/client/composables/crud/crudHttpClientSupport.js +57 -0
  17. package/src/client/composables/crud/crudJsonApiTransportSupport.js +145 -0
  18. package/src/client/composables/crud/crudLookupFieldLabelSupport.js +151 -0
  19. package/src/client/composables/crud/crudLookupFieldRuntime.js +251 -0
  20. package/src/client/composables/crud/crudSchemaFormHelpers.js +464 -0
  21. package/src/client/composables/internal/crudListFilterLookupSupport.js +161 -0
  22. package/src/client/composables/internal/crudListParentTitleSupport.js +171 -0
  23. package/src/client/composables/internal/useOperationScope.js +144 -0
  24. package/src/client/composables/records/useAddEdit.js +224 -0
  25. package/src/client/composables/records/useCrudAddEdit.js +227 -0
  26. package/src/client/composables/records/useCrudList.js +101 -0
  27. package/src/client/composables/records/useCrudView.js +47 -0
  28. package/src/client/composables/records/useList.js +500 -0
  29. package/src/client/composables/records/useView.js +164 -0
  30. package/src/client/composables/runtime/addEditUiRuntime.js +130 -0
  31. package/src/client/composables/runtime/listUiRuntime.js +123 -0
  32. package/src/client/composables/runtime/modelStateHelpers.js +90 -0
  33. package/src/client/composables/runtime/operationAdapters.js +34 -0
  34. package/src/client/composables/runtime/operationUiHelpers.js +125 -0
  35. package/src/client/composables/runtime/operationValidationHelpers.js +51 -0
  36. package/src/client/composables/runtime/useAddEditCore.js +112 -0
  37. package/src/client/composables/runtime/useCommandCore.js +123 -0
  38. package/src/client/composables/runtime/useEndpointResource.js +199 -0
  39. package/src/client/composables/runtime/useFieldErrorBag.js +61 -0
  40. package/src/client/composables/runtime/useListCore.js +112 -0
  41. package/src/client/composables/runtime/useUiFeedback.js +98 -0
  42. package/src/client/composables/runtime/useViewCore.js +93 -0
  43. package/src/client/composables/runtime/viewUiRuntime.js +96 -0
  44. package/src/client/composables/support/errorMessageHelpers.js +66 -0
  45. package/src/client/composables/support/listQueryParamSupport.js +473 -0
  46. package/src/client/composables/support/listSearchSupport.js +70 -0
  47. package/src/client/composables/support/refValueHelpers.js +19 -0
  48. package/src/client/composables/support/requestQueryRuntimeSupport.js +141 -0
  49. package/src/client/composables/support/resourceLoadStateHelpers.js +208 -0
  50. package/src/client/composables/support/routeTemplateHelpers.js +285 -0
  51. package/src/client/composables/support/scopeHelpers.js +145 -0
  52. package/src/client/composables/useAccess.js +109 -0
  53. package/src/client/composables/useCommand.js +118 -0
  54. package/src/client/composables/useCrudAddEditScreen.js +88 -0
  55. package/src/client/composables/useCrudDeleteAction.js +160 -0
  56. package/src/client/composables/useCrudListBulkActions.js +147 -0
  57. package/src/client/composables/useCrudListFilterLookups.js +146 -0
  58. package/src/client/composables/useCrudListFilters.js +339 -0
  59. package/src/client/composables/useCrudListParentTitle.js +151 -0
  60. package/src/client/composables/useCrudListRowActions.js +144 -0
  61. package/src/client/composables/useCrudListScreen.js +237 -0
  62. package/src/client/composables/useCrudViewScreen.js +77 -0
  63. package/src/client/composables/usePagedCollection.js +181 -0
  64. package/src/client/composables/useRealtimeQueryInvalidation.js +156 -0
  65. package/src/client/composables/useScopeRuntime.js +135 -0
  66. package/src/client/filters.js +15 -0
  67. package/src/client/index.js +24 -0
  68. package/src/client/lib/httpClient.js +47 -0
  69. package/src/client/lib/permissions.js +34 -0
  70. package/src/client/rowActions.js +47 -0
  71. package/src/client/support/contractGuards.js +34 -0
  72. package/src/client/support/crudListDateFilterSupport.js +91 -0
  73. package/test/addEditUiRuntime.test.js +110 -0
  74. package/test/crudBindingSupport.test.js +110 -0
  75. package/test/crudHttpClientSupport.test.js +77 -0
  76. package/test/crudJsonApiTransportSupport.test.js +179 -0
  77. package/test/crudListBulkActionSurface.test.js +27 -0
  78. package/test/crudListDateFilterSupport.test.js +92 -0
  79. package/test/crudListDateFilterSurface.browser.test.js +182 -0
  80. package/test/crudListFilterSurface.test.js +90 -0
  81. package/test/crudLookupFieldRuntime.test.js +264 -0
  82. package/test/crudScreenComponents.test.js +110 -0
  83. package/test/errorIntentContract.test.js +31 -0
  84. package/test/errorMessageHelpers.test.js +28 -0
  85. package/test/exportsContract.test.js +68 -0
  86. package/test/listQueryParamSupport.test.js +223 -0
  87. package/test/listUiRuntime.test.js +102 -0
  88. package/test/operationValidationHelpers.test.js +64 -0
  89. package/test/packageBoundary.test.js +31 -0
  90. package/test/permissions.test.js +35 -0
  91. package/test/refValueHelpers.test.js +14 -0
  92. package/test/requestTransportOptions.test.js +457 -0
  93. package/test/resourceLoadStateHelpers.test.js +189 -0
  94. package/test/routeTemplateHelpers.test.js +85 -0
  95. package/test/scopeHelpers.test.js +57 -0
  96. package/test/useAddEditCore.test.js +124 -0
  97. package/test/useAddEditRequestQueryParams.test.js +190 -0
  98. package/test/useCrudAddEdit.test.js +415 -0
  99. package/test/useCrudDeleteAction.test.js +139 -0
  100. package/test/useCrudListBulkActions.test.js +65 -0
  101. package/test/useCrudListFilterLookups.test.js +114 -0
  102. package/test/useCrudListFilters.test.js +293 -0
  103. package/test/useCrudListParentTitle.test.js +262 -0
  104. package/test/useCrudListRowActions.test.js +77 -0
  105. package/test/useListRouteInitialization.test.js +337 -0
  106. package/test/useListSearchSupport.test.js +61 -0
  107. package/test/usePagedCollection.test.js +53 -0
  108. package/test/useViewRequestQueryParams.test.js +20 -0
  109. package/test/viewCoreLoading.test.js +44 -0
  110. package/test/viewUiRuntime.test.js +95 -0
@@ -0,0 +1,197 @@
1
+ <script setup>
2
+ import { computed, unref } from "vue";
3
+
4
+ const props = defineProps({
5
+ screen: {
6
+ type: Object,
7
+ required: true
8
+ },
9
+ resourceSingularTitle: {
10
+ type: String,
11
+ default: "Record"
12
+ },
13
+ resourcePluralTitle: {
14
+ type: String,
15
+ default: "Records"
16
+ },
17
+ description: {
18
+ type: String,
19
+ default: ""
20
+ },
21
+ unavailableTitle: {
22
+ type: String,
23
+ default: "Record unavailable"
24
+ }
25
+ });
26
+
27
+ const view = computed(() => props.screen?.view || {});
28
+ const recordTitle = computed(() => unref(props.screen?.recordTitle) || props.resourceSingularTitle);
29
+ const listLocation = computed(() => unref(props.screen?.listLocation) || null);
30
+ const editLocation = computed(() => unref(props.screen?.editLocation) || null);
31
+ const resolvedDescription = computed(() =>
32
+ String(props.description || `Review this ${props.resourceSingularTitle} record.`).trim()
33
+ );
34
+ </script>
35
+
36
+ <template>
37
+ <section class="generated-ui-screen generated-ui-screen--operator ui-generator-view-element d-flex flex-column ga-4">
38
+ <header class="ui-generator-view-header">
39
+ <div class="ui-generator-view-header__copy">
40
+ <p class="text-overline text-medium-emphasis mb-1">{{ resourceSingularTitle }}</p>
41
+ <h1 class="ui-generator-view-header__title">{{ recordTitle }}</h1>
42
+ <p class="text-body-2 text-medium-emphasis mb-0">{{ resolvedDescription }}</p>
43
+ </div>
44
+ <div class="ui-generator-view-header__actions">
45
+ <v-btn
46
+ v-if="listLocation"
47
+ color="primary"
48
+ variant="outlined"
49
+ :to="listLocation"
50
+ >
51
+ Back to {{ resourcePluralTitle }}
52
+ </v-btn>
53
+ <slot name="actions" :screen="screen" :view="view" />
54
+ <v-btn
55
+ v-if="editLocation"
56
+ color="primary"
57
+ variant="flat"
58
+ :to="editLocation"
59
+ >
60
+ Edit
61
+ </v-btn>
62
+ </div>
63
+ </header>
64
+
65
+ <v-sheet rounded="lg" border class="ui-generator-view-panel">
66
+ <div v-if="view.loadError || view.isNotFound" class="ui-generator-view-state">
67
+ <h2 class="text-h6 mb-2">{{ unavailableTitle }}</h2>
68
+ <p class="text-body-2 text-medium-emphasis mb-4">
69
+ {{ view.loadError || `This ${resourceSingularTitle} could not be found.` }}
70
+ </p>
71
+ <div class="ui-generator-view-state__actions">
72
+ <v-btn
73
+ v-if="view.loadError"
74
+ color="primary"
75
+ variant="tonal"
76
+ :loading="view.isFetching"
77
+ @click="view.refresh"
78
+ >
79
+ Retry
80
+ </v-btn>
81
+ <v-btn
82
+ v-else-if="listLocation"
83
+ color="primary"
84
+ variant="tonal"
85
+ :to="listLocation"
86
+ >
87
+ Back to {{ resourcePluralTitle }}
88
+ </v-btn>
89
+ </div>
90
+ </div>
91
+
92
+ <template v-else-if="view.isLoading">
93
+ <div class="pa-4">
94
+ <v-skeleton-loader type="text@2, list-item-two-line@5" />
95
+ </div>
96
+ </template>
97
+
98
+ <template v-else>
99
+ <v-progress-linear v-if="view.isRefetching" indeterminate />
100
+ <div class="pa-4">
101
+ <slot name="before-fields" :view="view" />
102
+ <v-row class="ui-generator-view-fields">
103
+ <slot name="fields" :view="view" />
104
+ </v-row>
105
+ <slot name="after-fields" :view="view" />
106
+ </div>
107
+ </template>
108
+ </v-sheet>
109
+
110
+ <div v-if="$slots['supporting-content']" class="ui-generator-view-supporting-content">
111
+ <slot name="supporting-content" :view="view" />
112
+ </div>
113
+ </section>
114
+ </template>
115
+
116
+ <style scoped>
117
+ .generated-ui-screen {
118
+ --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
119
+ --generated-ui-screen-state-padding: 2.5rem 1.25rem;
120
+ }
121
+
122
+ .generated-ui-screen--operator {
123
+ --generated-ui-screen-state-padding: 2rem 1rem;
124
+ }
125
+
126
+ .ui-generator-view-header {
127
+ align-items: flex-start;
128
+ display: flex;
129
+ gap: 1rem;
130
+ justify-content: space-between;
131
+ }
132
+
133
+ .ui-generator-view-header__copy {
134
+ min-width: 0;
135
+ }
136
+
137
+ .ui-generator-view-header__title {
138
+ font-size: var(--generated-ui-screen-title-size);
139
+ font-weight: 650;
140
+ letter-spacing: -0.02em;
141
+ line-height: 1.15;
142
+ margin: 0 0 0.35rem;
143
+ overflow-wrap: anywhere;
144
+ }
145
+
146
+ .ui-generator-view-header__actions {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ gap: 0.5rem;
150
+ justify-content: flex-end;
151
+ }
152
+
153
+ .ui-generator-view-panel {
154
+ overflow: hidden;
155
+ }
156
+
157
+ .ui-generator-view-state {
158
+ margin-inline: auto;
159
+ max-width: 30rem;
160
+ padding: var(--generated-ui-screen-state-padding);
161
+ text-align: center;
162
+ }
163
+
164
+ .ui-generator-view-state__actions {
165
+ display: flex;
166
+ flex-wrap: wrap;
167
+ gap: 0.5rem;
168
+ justify-content: center;
169
+ }
170
+
171
+ .ui-generator-view-fields :deep(.v-col) {
172
+ min-width: 0;
173
+ }
174
+
175
+ .ui-generator-view-supporting-content {
176
+ min-width: 0;
177
+ }
178
+
179
+ @media (max-width: 960px) {
180
+ .ui-generator-view-header {
181
+ flex-direction: column;
182
+ }
183
+
184
+ .ui-generator-view-header__actions {
185
+ width: 100%;
186
+ }
187
+
188
+ .ui-generator-view-header__actions :deep(.v-btn) {
189
+ min-height: 48px;
190
+ flex: 1 1 10rem;
191
+ }
192
+
193
+ .ui-generator-view-state__actions :deep(.v-btn) {
194
+ min-height: 48px;
195
+ }
196
+ }
197
+ </style>
@@ -0,0 +1,75 @@
1
+ import { unref } from "vue";
2
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { asPlainObject } from "../support/scopeHelpers.js";
4
+
5
+ const CRUD_BINDING_MODE_ROUTE = "route";
6
+ const CRUD_BINDING_MODE_MERGE = "merge";
7
+ const CRUD_BINDING_MODE_EXPLICIT = "explicit";
8
+ const CRUD_BINDING_MODE_NONE = "none";
9
+
10
+ function normalizeCrudBindingMode(value = "") {
11
+ const normalizedValue = normalizeText(value).toLowerCase();
12
+ if (normalizedValue === CRUD_BINDING_MODE_MERGE) {
13
+ return CRUD_BINDING_MODE_MERGE;
14
+ }
15
+ if (normalizedValue === CRUD_BINDING_MODE_EXPLICIT) {
16
+ return CRUD_BINDING_MODE_EXPLICIT;
17
+ }
18
+ if (normalizedValue === CRUD_BINDING_MODE_NONE) {
19
+ return CRUD_BINDING_MODE_NONE;
20
+ }
21
+
22
+ return CRUD_BINDING_MODE_ROUTE;
23
+ }
24
+
25
+ function normalizeCrudBindingConfig(binding = {}) {
26
+ const source = asPlainObject(unref(binding));
27
+ return Object.freeze({
28
+ mode: normalizeCrudBindingMode(source.mode),
29
+ values: source.values ?? null
30
+ });
31
+ }
32
+
33
+ function resolveCrudBindingValues(values, context = {}) {
34
+ if (typeof values === "function") {
35
+ return asPlainObject(values(context));
36
+ }
37
+
38
+ return asPlainObject(unref(values));
39
+ }
40
+
41
+ function resolveCrudBoundValues({
42
+ binding = {},
43
+ routeValues = {},
44
+ context = {}
45
+ } = {}) {
46
+ const normalizedBinding = normalizeCrudBindingConfig(binding);
47
+ const normalizedRouteValues = asPlainObject(routeValues);
48
+ const explicitValues = resolveCrudBindingValues(normalizedBinding.values, context);
49
+
50
+ if (normalizedBinding.mode === CRUD_BINDING_MODE_NONE) {
51
+ return {};
52
+ }
53
+ if (normalizedBinding.mode === CRUD_BINDING_MODE_EXPLICIT) {
54
+ return explicitValues;
55
+ }
56
+ if (normalizedBinding.mode === CRUD_BINDING_MODE_MERGE) {
57
+ return {
58
+ ...normalizedRouteValues,
59
+ ...explicitValues
60
+ };
61
+ }
62
+
63
+ return normalizedRouteValues;
64
+ }
65
+
66
+ export {
67
+ CRUD_BINDING_MODE_ROUTE,
68
+ CRUD_BINDING_MODE_MERGE,
69
+ CRUD_BINDING_MODE_EXPLICIT,
70
+ CRUD_BINDING_MODE_NONE,
71
+ normalizeCrudBindingMode,
72
+ normalizeCrudBindingConfig,
73
+ resolveCrudBindingValues,
74
+ resolveCrudBoundValues
75
+ };
@@ -0,0 +1,57 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { getHttpWebClient } from "../../lib/httpClient.js";
3
+
4
+ const CRUD_API_ACCESS_AUTHENTICATED = "authenticated";
5
+ const CRUD_API_ACCESS_PUBLIC = "public";
6
+ const CRUD_API_ACCESS_VALUES = Object.freeze([
7
+ CRUD_API_ACCESS_AUTHENTICATED,
8
+ CRUD_API_ACCESS_PUBLIC
9
+ ]);
10
+ const publicCrudClients = new WeakSet();
11
+
12
+ function normalizeCrudApiAccess(value = "") {
13
+ const normalized = normalizeText(value).toLowerCase() || CRUD_API_ACCESS_AUTHENTICATED;
14
+ if (CRUD_API_ACCESS_VALUES.includes(normalized)) {
15
+ return normalized;
16
+ }
17
+
18
+ throw new TypeError(
19
+ `CRUD resource apiAccess must be one of: ${CRUD_API_ACCESS_VALUES.join(", ")}. ` +
20
+ `Received: ${String(value || "") || "(empty)"}`
21
+ );
22
+ }
23
+
24
+ function resolveCrudHttpClient(resource = null, { client = null } = {}) {
25
+ const activeClient = client || getHttpWebClient();
26
+ if (!activeClient || typeof activeClient.request !== "function") {
27
+ throw new TypeError("resolveCrudHttpClient requires a client with request().");
28
+ }
29
+
30
+ if (normalizeCrudApiAccess(resource?.apiAccess) !== CRUD_API_ACCESS_PUBLIC) {
31
+ return activeClient;
32
+ }
33
+ if (publicCrudClients.has(activeClient)) {
34
+ return activeClient;
35
+ }
36
+
37
+ const publicClient = Object.freeze({
38
+ request(url, options = {}) {
39
+ const requestOptions = options && typeof options === "object" && !Array.isArray(options)
40
+ ? options
41
+ : {};
42
+ return activeClient.request(url, {
43
+ ...requestOptions,
44
+ csrf: false
45
+ });
46
+ }
47
+ });
48
+ publicCrudClients.add(publicClient);
49
+ return publicClient;
50
+ }
51
+
52
+ export {
53
+ CRUD_API_ACCESS_AUTHENTICATED,
54
+ CRUD_API_ACCESS_PUBLIC,
55
+ normalizeCrudApiAccess,
56
+ resolveCrudHttpClient
57
+ };
@@ -0,0 +1,145 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeCrudLookupNamespace } from "@jskit-ai/resource-crud-core/shared/crudLookup";
3
+
4
+ function isRecord(value) {
5
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ function resolveSchemaFieldDefinitions(definition = null) {
9
+ const schema = definition?.schema;
10
+ if (!schema || typeof schema.getFieldDefinitions !== "function") {
11
+ return {};
12
+ }
13
+
14
+ const definitions = schema.getFieldDefinitions();
15
+ return isRecord(definitions) ? definitions : {};
16
+ }
17
+
18
+ function resolveLookupFieldMap(resource = null) {
19
+ const outputDefinition =
20
+ resource?.operations?.view?.output ||
21
+ resource?.operations?.create?.output ||
22
+ resource?.operations?.patch?.output ||
23
+ null;
24
+ const mapping = {};
25
+
26
+ for (const [fieldKey, fieldDefinition] of Object.entries(resolveSchemaFieldDefinitions(outputDefinition))) {
27
+ const normalizedFieldDefinition = isRecord(fieldDefinition) ? fieldDefinition : {};
28
+ if (!normalizeText(normalizedFieldDefinition.belongsTo)) {
29
+ continue;
30
+ }
31
+
32
+ const relationshipName = normalizeText(normalizedFieldDefinition.as, {
33
+ fallback: fieldKey
34
+ });
35
+ const normalizedFieldKey = normalizeText(fieldKey);
36
+ if (!normalizedFieldKey || !relationshipName) {
37
+ continue;
38
+ }
39
+
40
+ mapping[relationshipName] = normalizedFieldKey;
41
+ }
42
+
43
+ return Object.freeze(mapping);
44
+ }
45
+
46
+ function isJsonApiResourceTransport(transport = null) {
47
+ return normalizeText(transport?.kind).toLowerCase() === "jsonapi-resource";
48
+ }
49
+
50
+ function resolveCrudJsonApiResourceType(resource = null) {
51
+ return normalizeText(resource?.namespace);
52
+ }
53
+
54
+ function inferCrudLookupJsonApiTransport({ namespace = "", apiPath = "" } = {}) {
55
+ const resourceType =
56
+ normalizeCrudLookupNamespace(namespace) ||
57
+ normalizeCrudLookupNamespace(apiPath);
58
+ if (!resourceType) {
59
+ return null;
60
+ }
61
+
62
+ return Object.freeze({
63
+ kind: "jsonapi-resource",
64
+ responseType: resourceType,
65
+ responseKind: "collection"
66
+ });
67
+ }
68
+
69
+ function inferCrudJsonApiTransport(resource = null, { mode = "", operationName = "" } = {}) {
70
+ const resourceType = resolveCrudJsonApiResourceType(resource);
71
+ if (!resourceType) {
72
+ return null;
73
+ }
74
+
75
+ const normalizedMode = normalizeText(mode).toLowerCase();
76
+ const normalizedOperationName = normalizeText(operationName).toLowerCase();
77
+ if (normalizedMode === "list") {
78
+ return Object.freeze({
79
+ kind: "jsonapi-resource",
80
+ responseType: resourceType,
81
+ responseKind: "collection"
82
+ });
83
+ }
84
+
85
+ if (normalizedMode === "view") {
86
+ return Object.freeze({
87
+ kind: "jsonapi-resource",
88
+ responseType: resourceType,
89
+ responseKind: "record"
90
+ });
91
+ }
92
+
93
+ if (normalizedMode === "delete") {
94
+ return Object.freeze({
95
+ kind: "jsonapi-resource",
96
+ responseType: resourceType,
97
+ responseKind: "record"
98
+ });
99
+ }
100
+
101
+ if (normalizedMode === "add-edit") {
102
+ return Object.freeze({
103
+ kind: "jsonapi-resource",
104
+ ...(normalizedOperationName ? { requestType: resourceType } : {}),
105
+ responseType: resourceType,
106
+ responseKind: "record"
107
+ });
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ function resolveCrudJsonApiTransport(transport = null, resource = null, options = {}) {
114
+ if (transport != null) {
115
+ throw new TypeError(
116
+ "CRUD hooks do not accept explicit transport. Derive JSON:API transport from the shared resource."
117
+ );
118
+ }
119
+
120
+ const baseTransport = inferCrudJsonApiTransport(resource, options);
121
+ if (!isJsonApiResourceTransport(baseTransport)) {
122
+ return baseTransport;
123
+ }
124
+
125
+ const lookupFieldMap = resolveLookupFieldMap(resource);
126
+ const lookupContainerKey = normalizeText(resource?.contract?.lookup?.containerKey, {
127
+ fallback: "lookups"
128
+ });
129
+ if (Object.keys(lookupFieldMap).length < 1 && !lookupContainerKey) {
130
+ return baseTransport;
131
+ }
132
+
133
+ return Object.freeze({
134
+ ...baseTransport,
135
+ ...(Object.keys(lookupFieldMap).length > 0 ? { lookupFieldMap } : {}),
136
+ ...(lookupContainerKey ? { lookupContainerKey } : {})
137
+ });
138
+ }
139
+
140
+ export {
141
+ inferCrudJsonApiTransport,
142
+ inferCrudLookupJsonApiTransport,
143
+ resolveCrudJsonApiTransport,
144
+ resolveLookupFieldMap
145
+ };
@@ -0,0 +1,151 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeCrudLookupContainerKey } from "@jskit-ai/resource-crud-core/shared/crudLookup";
3
+ import { asPlainObject } from "../support/scopeHelpers.js";
4
+
5
+ const LOOKUP_LABEL_COMPOSITION_CANDIDATES = Object.freeze([
6
+ Object.freeze(["name", "surname"]),
7
+ Object.freeze(["name", "lastName"]),
8
+ Object.freeze(["firstName", "surname"]),
9
+ Object.freeze(["firstName", "lastName"]),
10
+ Object.freeze(["name"]),
11
+ Object.freeze(["firstName"])
12
+ ]);
13
+ const DEFAULT_RECORD_TITLE = "-";
14
+
15
+ function hasDisplayValue(value) {
16
+ if (value == null) {
17
+ return false;
18
+ }
19
+ if (typeof value === "string") {
20
+ return normalizeText(value).length > 0;
21
+ }
22
+
23
+ return true;
24
+ }
25
+
26
+ function resolveComposedLabel(source = {}, candidates = LOOKUP_LABEL_COMPOSITION_CANDIDATES) {
27
+ for (const candidate of candidates) {
28
+ const parts = [];
29
+ for (const key of candidate) {
30
+ const part = normalizeText(source[key]);
31
+ if (!part) {
32
+ parts.length = 0;
33
+ break;
34
+ }
35
+ parts.push(part);
36
+ }
37
+ if (parts.length === candidate.length) {
38
+ return parts.join(" ");
39
+ }
40
+ }
41
+
42
+ return "";
43
+ }
44
+
45
+ function resolveLookupItemLabel(item = {}, labelKey = "") {
46
+ const source = asPlainObject(item);
47
+ const composedLabel = resolveComposedLabel(source);
48
+ if (composedLabel) {
49
+ return composedLabel;
50
+ }
51
+
52
+ const normalizedLabelKey = normalizeText(labelKey);
53
+ if (!normalizedLabelKey) {
54
+ return "";
55
+ }
56
+
57
+ return normalizeText(source[normalizedLabelKey]);
58
+ }
59
+
60
+ function resolveRecordTitle(record = {}, { fallbackKey = "", defaultValue = DEFAULT_RECORD_TITLE } = {}) {
61
+ const source = asPlainObject(record);
62
+ const composedLabel = resolveComposedLabel(source);
63
+ if (composedLabel) {
64
+ return composedLabel;
65
+ }
66
+
67
+ const normalizedFallbackKey = normalizeText(fallbackKey);
68
+ if (normalizedFallbackKey) {
69
+ const fallbackValue = normalizeText(source[normalizedFallbackKey]);
70
+ if (fallbackValue) {
71
+ return fallbackValue;
72
+ }
73
+ }
74
+
75
+ const normalizedDefaultValue = normalizeText(defaultValue);
76
+ return normalizedDefaultValue || DEFAULT_RECORD_TITLE;
77
+ }
78
+
79
+ function resolveLookupFieldDescriptor(field = {}, relationKind = "", valueKey = "", labelKey = "") {
80
+ if (typeof field === "string") {
81
+ return {
82
+ key: normalizeText(field),
83
+ relation: {
84
+ kind: normalizeText(relationKind).toLowerCase(),
85
+ valueKey: normalizeText(valueKey) || "id",
86
+ labelKey: normalizeText(labelKey),
87
+ containerKey: ""
88
+ }
89
+ };
90
+ }
91
+
92
+ const sourceField = asPlainObject(field);
93
+ const relation = asPlainObject(sourceField.relation);
94
+ return {
95
+ key: normalizeText(sourceField.key),
96
+ relation: {
97
+ kind: normalizeText(relation.kind).toLowerCase(),
98
+ valueKey: normalizeText(relation.valueKey) || "id",
99
+ labelKey: normalizeText(relation.labelKey),
100
+ containerKey: normalizeText(relation.containerKey)
101
+ }
102
+ };
103
+ }
104
+
105
+ function resolveFallbackLookupKey(key = "") {
106
+ const normalizedKey = normalizeText(key);
107
+ if (!normalizedKey.endsWith("Id") || normalizedKey.length <= 2) {
108
+ return "";
109
+ }
110
+
111
+ return normalizedKey.slice(0, -2);
112
+ }
113
+
114
+ function resolveLookupFieldDisplayValue(record = {}, field = {}, relationKind = "", valueKey = "", labelKey = "") {
115
+ const sourceRecord = asPlainObject(record);
116
+ const descriptor = resolveLookupFieldDescriptor(field, relationKind, valueKey, labelKey);
117
+ const key = descriptor.key;
118
+ if (!key) {
119
+ return "";
120
+ }
121
+
122
+ if (descriptor.relation.kind !== "lookup") {
123
+ return sourceRecord[key];
124
+ }
125
+
126
+ const lookupContainerKey = normalizeCrudLookupContainerKey(descriptor.relation.containerKey, {
127
+ context: `lookup relation "${key}" containerKey`
128
+ });
129
+ const sourceLookups = asPlainObject(sourceRecord[lookupContainerKey]);
130
+ let lookupRecord = asPlainObject(sourceLookups[key]);
131
+ if (Object.keys(lookupRecord).length < 1) {
132
+ lookupRecord = asPlainObject(sourceLookups[resolveFallbackLookupKey(key)]);
133
+ }
134
+ const lookupLabel = resolveLookupItemLabel(lookupRecord, descriptor.relation.labelKey);
135
+ if (lookupLabel) {
136
+ return lookupLabel;
137
+ }
138
+
139
+ const lookupValue = lookupRecord[descriptor.relation.valueKey];
140
+ if (hasDisplayValue(lookupValue)) {
141
+ return lookupValue;
142
+ }
143
+
144
+ return sourceRecord[key];
145
+ }
146
+
147
+ export {
148
+ resolveLookupItemLabel,
149
+ resolveLookupFieldDisplayValue,
150
+ resolveRecordTitle
151
+ };