nexus-shared 2.0.0 → 3.0.0

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 (116) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +122 -25
  3. package/dist/Client.Index.d.ts +11 -0
  4. package/dist/Client.Index.js +11 -0
  5. package/dist/Components/Chats/Chat.d.ts +28 -0
  6. package/dist/Components/Chats/Chat.js +19 -0
  7. package/dist/Components/Chats/ChatButton.d.ts +26 -0
  8. package/dist/Components/Chats/ChatButton.js +41 -0
  9. package/dist/Components/Chats/ChatComposer.d.ts +41 -0
  10. package/dist/Components/Chats/ChatComposer.js +177 -0
  11. package/dist/Components/Chats/ChatConversations.d.ts +35 -0
  12. package/dist/Components/Chats/ChatConversations.js +38 -0
  13. package/dist/Components/Chats/ChatPanel.d.ts +66 -0
  14. package/dist/Components/Chats/ChatPanel.js +94 -0
  15. package/dist/Components/Chats/ChatParts.d.ts +87 -0
  16. package/dist/Components/Chats/ChatParts.js +100 -0
  17. package/dist/Components/Chats/ChatThread.d.ts +22 -0
  18. package/dist/Components/Chats/ChatThread.js +96 -0
  19. package/dist/Components/Documents/Menu.js +24 -20
  20. package/dist/Components/Documents/SplitButton.js +5 -3
  21. package/dist/Components/Documents/TabButtons.d.ts +24 -6
  22. package/dist/Components/Documents/TabButtons.js +23 -4
  23. package/dist/Components/Forms/ApiForm.d.ts +6 -4
  24. package/dist/Components/Forms/ApiForm.js +15 -14
  25. package/dist/Components/Forms/Crud.js +202 -50
  26. package/dist/Components/Forms/ExcelImport.d.ts +42 -0
  27. package/dist/Components/Forms/ExcelImport.js +190 -0
  28. package/dist/Components/Forms/Form.js +5 -1
  29. package/dist/Components/Inputs/DateTimePicker.js +2 -1
  30. package/dist/Components/Inputs/GroupForm.js +4 -2
  31. package/dist/Components/Inputs/InputRenderer.d.ts +2 -0
  32. package/dist/Components/Inputs/InputRenderer.js +1 -1
  33. package/dist/Components/Inputs/ReadOnlyNotice.js +2 -1
  34. package/dist/Components/Inputs/RowsInput.d.ts +12 -1
  35. package/dist/Components/Inputs/RowsInput.js +53 -4
  36. package/dist/Components/Inputs/TabularForm.d.ts +7 -3
  37. package/dist/Components/Inputs/TabularForm.js +85 -20
  38. package/dist/Components/Inputs/TimePicker.js +2 -1
  39. package/dist/Components/Layouts/ThemeSwitcher.d.ts +2 -2
  40. package/dist/Components/Layouts/ThemeSwitcher.js +35 -15
  41. package/dist/Components/Viewers/DataTable.js +88 -34
  42. package/dist/Components/Viewers/DataTableColumns.d.ts +28 -0
  43. package/dist/Components/Viewers/DataTableColumns.js +243 -0
  44. package/dist/Components/Viewers/DataTableParts.d.ts +6 -2
  45. package/dist/Components/Viewers/DataTableParts.js +21 -9
  46. package/dist/Helpers/ApiClient.d.ts +22 -0
  47. package/dist/Helpers/ApiClient.js +4 -1
  48. package/dist/Helpers/ApiFormHelpers.d.ts +27 -4
  49. package/dist/Helpers/ApiFormHelpers.js +73 -9
  50. package/dist/Helpers/ApiModules.d.ts +0 -4
  51. package/dist/Helpers/ApiModules.js +1 -0
  52. package/dist/Helpers/ApiResponses.d.ts +14 -4
  53. package/dist/Helpers/ApiResponses.js +116 -25
  54. package/dist/Helpers/ApiRoutes.d.ts +0 -10
  55. package/dist/Helpers/ApiRoutes.js +2 -10
  56. package/dist/Helpers/ChatBackend.d.ts +134 -0
  57. package/dist/Helpers/ChatBackend.js +332 -0
  58. package/dist/Helpers/ChatHelpers.d.ts +189 -0
  59. package/dist/Helpers/ChatHelpers.js +486 -0
  60. package/dist/Helpers/ChatHooks.d.ts +81 -0
  61. package/dist/Helpers/ChatHooks.js +175 -0
  62. package/dist/Helpers/ChatStore.d.ts +132 -0
  63. package/dist/Helpers/ChatStore.js +394 -0
  64. package/dist/Helpers/ChatSummaries.d.ts +39 -0
  65. package/dist/Helpers/ChatSummaries.js +118 -0
  66. package/dist/Helpers/CrudBackend.d.ts +47 -5
  67. package/dist/Helpers/CrudBackend.js +99 -4
  68. package/dist/Helpers/CrudHelpers.d.ts +39 -4
  69. package/dist/Helpers/CrudHelpers.js +76 -11
  70. package/dist/Helpers/ExcelBackend.d.ts +106 -0
  71. package/dist/Helpers/ExcelBackend.js +290 -0
  72. package/dist/Helpers/ExcelHelpers.d.ts +98 -0
  73. package/dist/Helpers/ExcelHelpers.js +302 -0
  74. package/dist/Helpers/FormStore.d.ts +2 -0
  75. package/dist/Helpers/FormStore.js +19 -1
  76. package/dist/Helpers/MessageBuilder.js +0 -2
  77. package/dist/Helpers/PopoverHelpers.d.ts +31 -6
  78. package/dist/Helpers/PopoverHelpers.js +116 -12
  79. package/dist/Helpers/RowsStore.d.ts +7 -1
  80. package/dist/Helpers/RowsStore.js +22 -0
  81. package/dist/Helpers/TableColumns.d.ts +7 -0
  82. package/dist/Helpers/TableColumns.js +41 -13
  83. package/dist/Helpers/TableHelpers.d.ts +9 -1
  84. package/dist/Helpers/TableHelpers.js +20 -0
  85. package/dist/Interfaces/ApiInterfaces.d.ts +29 -4
  86. package/dist/Interfaces/ApiInterfaces.js +16 -10
  87. package/dist/Interfaces/ChatInterfaces.d.ts +240 -0
  88. package/dist/Interfaces/ChatInterfaces.js +1 -0
  89. package/dist/Interfaces/CrudInterfaces.d.ts +73 -12
  90. package/dist/Interfaces/ExcelInterfaces.d.ts +215 -0
  91. package/dist/Interfaces/ExcelInterfaces.js +45 -0
  92. package/dist/Interfaces/FormInterfaces.d.ts +52 -2
  93. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  94. package/dist/Interfaces/TableInterfaces.d.ts +62 -2
  95. package/dist/Services/BrowserApi.d.ts +18 -1
  96. package/dist/Services/BrowserApi.js +18 -0
  97. package/dist/Services/ChatApi.d.ts +37 -0
  98. package/dist/Services/ChatApi.js +98 -0
  99. package/dist/Services/ExcelApi.d.ts +46 -0
  100. package/dist/Services/ExcelApi.js +196 -0
  101. package/dist/Services/ServerApi.d.ts +6 -1
  102. package/dist/Services/ServerApi.js +3 -0
  103. package/dist/Shared.Index.d.ts +8 -0
  104. package/dist/Shared.Index.js +8 -0
  105. package/package.json +6 -3
  106. package/src/Styles/Nexus.Button.css +2 -0
  107. package/src/Styles/Nexus.Chat.css +1489 -0
  108. package/src/Styles/Nexus.Crud.css +77 -0
  109. package/src/Styles/Nexus.Excel.css +370 -0
  110. package/src/Styles/Nexus.Form.css +23 -0
  111. package/src/Styles/Nexus.Index.css +2 -0
  112. package/src/Styles/Nexus.Menu.css +12 -2
  113. package/src/Styles/Nexus.Rows.css +78 -48
  114. package/src/Styles/Nexus.Tab.Buttons.css +55 -0
  115. package/src/Styles/Nexus.Table.css +286 -4
  116. package/src/Styles/Nexus.Theme.Picker.css +75 -2
@@ -1,5 +1,5 @@
1
1
  import { type ConfirmOptions } from "../Components/Layouts/MessageBox.tsx";
2
- import { type ApiCall } from "../Helpers/ApiClient.ts";
2
+ import { type ApiCall, type ApiResponseReader } from "../Helpers/ApiClient.ts";
3
3
  import { type ApiToastDefaults, type ApiToastOption } from "../Helpers/ApiToasts.ts";
4
4
  import type { ApiCallOptions, ApiResult, ApiTarget } from "../Interfaces/ApiInterfaces.ts";
5
5
  import type { DropdownItem, DropdownPage, DropdownPageRequest } from "../Interfaces/InputInterfaces.ts";
@@ -18,6 +18,12 @@ export interface BrowserApiConfig {
18
18
  credentials?: RequestCredentials;
19
19
  /** Milliseconds to wait for an answer. Default 60,000. */
20
20
  timeout?: number;
21
+ /**
22
+ * Reads every answer, for a backend that does not answer as Nexus or ASP.NET Core do. Default `readApiResponse`.
23
+ * A call still reads its own with `read`. Registered once, it holds for calls the components make inside
24
+ * themselves too (`Crud`'s lists and actions, `ApiForm`'s save), which cannot be given one.
25
+ */
26
+ read?: ApiResponseReader<BrowserApiOptions>;
21
27
  /** Which toasts show when a call does not say. Default: errors always, successes for changes. */
22
28
  toast?: ApiToastDefaults;
23
29
  /** After a 401, or an expired token (Nexus error 2003), such as to send the user to sign in. The error toast shows too. */
@@ -30,6 +36,17 @@ export interface BrowserApiConfig {
30
36
  * settings they name. Nothing is needed when the proxy is at `/api/app` and the defaults suit.
31
37
  */
32
38
  export declare function configureApi(config: BrowserApiConfig): void;
39
+ /**
40
+ * The app's default response reader, when it registered one (`configureApi({ read })`). For answers that did not come
41
+ * from a call and so have no client to read them: what an `ApiForm`'s own send function returned.
42
+ */
43
+ export declare function getApiReader(): ApiResponseReader<BrowserApiOptions> | undefined;
44
+ /**
45
+ * The app's call settings, for a request `api` cannot make: a download, which has to read the file's own bytes and the
46
+ * headers that name it, rather than an answer read as JSON. The headers, the token, and the cookie rule an app
47
+ * registered once belong on such a request too (`Services/ExcelApi.ts` is the one caller).
48
+ */
49
+ export declare function getApiCallSettings(): BrowserApiConfig;
33
50
  /** Options of `api.confirm`: the call's, and changes to the question. */
34
51
  export type ConfirmApiOptions = ApiCallOptions & BrowserApiOptions & {
35
52
  question?: Partial<ConfirmOptions>;
@@ -15,6 +15,21 @@ let settings = {};
15
15
  export function configureApi(config) {
16
16
  settings = { ...settings, ...config };
17
17
  }
18
+ /**
19
+ * The app's default response reader, when it registered one (`configureApi({ read })`). For answers that did not come
20
+ * from a call and so have no client to read them: what an `ApiForm`'s own send function returned.
21
+ */
22
+ export function getApiReader() {
23
+ return settings.read;
24
+ }
25
+ /**
26
+ * The app's call settings, for a request `api` cannot make: a download, which has to read the file's own bytes and the
27
+ * headers that name it, rather than an answer read as JSON. The headers, the token, and the cookie rule an app
28
+ * registered once belong on such a request too (`Services/ExcelApi.ts` is the one caller).
29
+ */
30
+ export function getApiCallSettings() {
31
+ return settings;
32
+ }
18
33
  /** Nexus error code for an expired token. */
19
34
  const TOKEN_EXPIRED = 2003;
20
35
  const TOAST_DURATION = 5000;
@@ -32,6 +47,9 @@ const config = {
32
47
  get credentials() {
33
48
  return settings.credentials;
34
49
  },
50
+ get read() {
51
+ return settings.read;
52
+ },
35
53
  dedupe: true,
36
54
  onStart(call) {
37
55
  const loading = planLoadingToast(call.context, call.options.toast);
@@ -0,0 +1,37 @@
1
+ import { type ChatBackend } from "../Helpers/ChatBackend.ts";
2
+ import { ChatSummaryCache } from "../Helpers/ChatSummaries.ts";
3
+ import type { ApiCallOptions } from "../Interfaces/ApiInterfaces.ts";
4
+ import type { ChatDomainValue, ChatId, ChatPlaceSource, ChatScope, ChatSource, ChatSummary } from "../Interfaces/ChatInterfaces.ts";
5
+ /** Options of one chat's source. */
6
+ export interface ApiChatSourceOptions {
7
+ /** Which conversation at the place, when the record holds more than one. */
8
+ conversationId?: ChatId;
9
+ /** The backend to use, when it is not the app's registered one. */
10
+ backend?: ChatBackend<ChatDomainValue>;
11
+ /** Extras on every call this source makes, such as a header. */
12
+ call?: Omit<ApiCallOptions, "body" | "signal" | "params">;
13
+ }
14
+ /**
15
+ * A conversation's data through the app's API.
16
+ *
17
+ * @example
18
+ * const scope = { domain: EDomainTypes.Leave, parentId: leave.id, userId: me.id };
19
+ * const source = useMemo(() => apiChatSource(scope), [scope.domain, scope.parentId, scope.userId]);
20
+ */
21
+ export declare function apiChatSource<TDomain extends ChatDomainValue>(scope: ChatScope<TDomain>, options?: ApiChatSourceOptions): ChatSource;
22
+ /**
23
+ * The conversations a record holds, and how to start another.
24
+ *
25
+ * @example
26
+ * const place = useMemo(() => apiChatPlace(scope), [scope.domain, scope.parentId, scope.userId]);
27
+ */
28
+ export declare function apiChatPlace<TDomain extends ChatDomainValue>(scope: ChatScope<TDomain>, options?: ApiChatSourceOptions): ChatPlaceSource;
29
+ /** Asks the app's backend for many records' counts in one call. */
30
+ export declare function loadChatSummaries(domain: ChatDomainValue, parentIds: readonly ChatId[], userId: ChatId): Promise<Map<string, ChatSummary>>;
31
+ /**
32
+ * The counts every trigger icon on the page reads, filled in one call per domain however many icons there are. A page
33
+ * that already has the counts in its rows passes them to the icon instead, and never touches this.
34
+ */
35
+ export declare function chatSummaries(): ChatSummaryCache;
36
+ /** Replaces the page's count cache: a test's, or one with a different batching window. */
37
+ export declare function defineChatSummaries(replacement: ChatSummaryCache): ChatSummaryCache;
@@ -0,0 +1,98 @@
1
+ "use client";
2
+ import { getChatBackend } from "../Helpers/ChatBackend.js";
3
+ import { ChatSummaryCache } from "../Helpers/ChatSummaries.js";
4
+ import { unwrapApiResult } from "../Helpers/ApiResponses.js";
5
+ import { api } from "./BrowserApi.js";
6
+ const callOptions = (options, signal) => ({ ...options?.call, ...(signal && { signal }), toast: false });
7
+ /**
8
+ * A conversation's data through the app's API.
9
+ *
10
+ * @example
11
+ * const scope = { domain: EDomainTypes.Leave, parentId: leave.id, userId: me.id };
12
+ * const source = useMemo(() => apiChatSource(scope), [scope.domain, scope.parentId, scope.userId]);
13
+ */
14
+ export function apiChatSource(scope, options) {
15
+ const backend = (options?.backend ?? getChatBackend());
16
+ const chat = options?.conversationId;
17
+ const source = {
18
+ async history(request) {
19
+ const answer = await api.send(backend.endpoint("history"), { ...callOptions(options, request.signal), body: backend.historyRequest(scope, request, chat) });
20
+ return backend.readHistory(unwrapApiResult(answer));
21
+ },
22
+ async send(draft, signal) {
23
+ const answer = await api.send(backend.endpoint("send"), { ...callOptions(options, signal), body: backend.sendRequest(scope, draft, chat) });
24
+ return backend.readMessage(unwrapApiResult(answer), scope, draft);
25
+ },
26
+ };
27
+ if (backend.supports("participants")) {
28
+ source.participants = async (query) => {
29
+ const answer = await api.send(backend.endpoint("participants"), { ...callOptions(options, query.signal), body: backend.participantsRequest(scope, query, chat) });
30
+ return backend.readParticipants(unwrapApiResult(answer));
31
+ };
32
+ }
33
+ if (backend.supports("addParticipants")) {
34
+ source.addParticipants = async (ids, signal) => {
35
+ const answer = await api.send(backend.endpoint("addParticipants"), { ...callOptions(options, signal), body: backend.addParticipantsRequest(scope, ids, chat) });
36
+ return backend.readParticipants(unwrapApiResult(answer)).items;
37
+ };
38
+ }
39
+ if (backend.supports("read")) {
40
+ source.markRead = async (upTo, signal) => {
41
+ unwrapApiResult(await api.send(backend.endpoint("read"), { ...callOptions(options, signal), body: backend.readMarkRequest(scope, upTo, chat) }));
42
+ };
43
+ }
44
+ if (backend.supports("remove")) {
45
+ source.remove = async (messageId, signal) => {
46
+ unwrapApiResult(await api.send(backend.endpoint("remove"), { ...callOptions(options, signal), params: backend.removeParams(scope, messageId) }));
47
+ };
48
+ }
49
+ return source;
50
+ }
51
+ /**
52
+ * The conversations a record holds, and how to start another.
53
+ *
54
+ * @example
55
+ * const place = useMemo(() => apiChatPlace(scope), [scope.domain, scope.parentId, scope.userId]);
56
+ */
57
+ export function apiChatPlace(scope, options) {
58
+ const backend = (options?.backend ?? getChatBackend());
59
+ const place = {
60
+ async conversations(signal) {
61
+ const answer = await api.send(backend.endpoint("conversations"), { ...callOptions(options, signal), body: backend.conversationsRequest(scope) });
62
+ return backend.readConversations(unwrapApiResult(answer));
63
+ },
64
+ };
65
+ if (backend.supports("createConversation")) {
66
+ place.create = async (request, signal) => {
67
+ const answer = await api.send(backend.endpoint("createConversation"), { ...callOptions(options, signal), body: backend.createConversationRequest(scope, request) });
68
+ return backend.readConversation(unwrapApiResult(answer));
69
+ };
70
+ }
71
+ if (backend.supports("participants")) {
72
+ // Without a conversation, this asks who could be put in a new one here.
73
+ place.participants = async (query) => {
74
+ const answer = await api.send(backend.endpoint("participants"), { ...callOptions(options, query.signal), body: backend.participantsRequest(scope, query) });
75
+ return backend.readParticipants(unwrapApiResult(answer));
76
+ };
77
+ }
78
+ return place;
79
+ }
80
+ /** Asks the app's backend for many records' counts in one call. */
81
+ export async function loadChatSummaries(domain, parentIds, userId) {
82
+ const backend = getChatBackend();
83
+ const answer = await api.send(backend.endpoint("summaries"), { body: backend.summariesRequest(domain, parentIds, userId), toast: false });
84
+ return backend.readSummaries(unwrapApiResult(answer));
85
+ }
86
+ let cache;
87
+ /**
88
+ * The counts every trigger icon on the page reads, filled in one call per domain however many icons there are. A page
89
+ * that already has the counts in its rows passes them to the icon instead, and never touches this.
90
+ */
91
+ export function chatSummaries() {
92
+ return (cache ??= new ChatSummaryCache({ load: loadChatSummaries }));
93
+ }
94
+ /** Replaces the page's count cache: a test's, or one with a different batching window. */
95
+ export function defineChatSummaries(replacement) {
96
+ cache = replacement;
97
+ return replacement;
98
+ }
@@ -0,0 +1,46 @@
1
+ import type { ApiCallOptions, ApiResult } from "../Interfaces/ApiInterfaces.ts";
2
+ import type { ExcelDownload, ExcelEndpoints, ExcelExportQuery, ExcelImportAnswer, ExcelSettings, ExcelSheetRow, ExcelVerifyAnswer } from "../Interfaces/ExcelInterfaces.ts";
3
+ /** Options every spreadsheet call takes. */
4
+ export interface ExcelCallOptions extends Pick<ApiCallOptions, "params" | "query" | "headers" | "signal" | "timeout" | "subject" | "read"> {
5
+ /** Shows no toast for this call. Default: a failure is told, a success is not (the panel says what happened). */
6
+ toast?: false;
7
+ }
8
+ /** Options of an export. Its `query` is what the person has on screen, not query-string values. */
9
+ export interface ExcelExportOptions extends Omit<ExcelCallOptions, "query"> {
10
+ /** The search, the sort and the filters the person has on screen. */
11
+ query?: ExcelExportQuery;
12
+ /**
13
+ * Rows the search on screen leaves, when the page knows. Over the backend's limit, the person is asked first and
14
+ * told what will happen; without it, nothing is asked and the answer's own headers say what was left out.
15
+ */
16
+ rows?: number;
17
+ /** What one row is, for the question and the toast: "city". Default: the settings' subject. */
18
+ subject?: string;
19
+ /** Asks nothing, whatever the count. For a page that asked already. */
20
+ confirm?: false;
21
+ }
22
+ /**
23
+ * The limits and the shape of the sheet. It is the same for everybody and changes once a release, so the first answer
24
+ * is kept for as long as the page lives - the backend says the same with a `Cache-Control` of an hour.
25
+ */
26
+ export declare function excelSettings(endpoints: ExcelEndpoints, options?: ExcelCallOptions): Promise<ApiResult<ExcelSettings>>;
27
+ /** Forgets the kept settings, for a test or an app that changed what a sheet holds while it ran. */
28
+ export declare function clearExcelSettings(): void;
29
+ /** The template to fill in, as the backend built it, under the name the answer gives it. */
30
+ export declare function downloadExcelTemplate(endpoints: ExcelEndpoints, options?: ExcelCallOptions): Promise<ApiResult<ExcelDownload>>;
31
+ /**
32
+ * An export of what the person has on screen: the search, the sort and the filters, sent to the model's own action.
33
+ * Not a file written from the rows the browser happens to hold - the backend writes every row the search leaves, up to
34
+ * its own limit, and says in the answer's headers what it left out.
35
+ *
36
+ * Over that limit the person is asked first, because a file that is quietly short is worse than one they chose.
37
+ */
38
+ export declare function exportExcel(endpoints: ExcelEndpoints, options?: ExcelExportOptions): Promise<ApiResult<ExcelDownload> | null>;
39
+ /** The filled file read and judged, with nothing written. The rows it answers with are what an import sends back. */
40
+ export declare function verifyExcelFile(endpoints: ExcelEndpoints, file: File, options?: ExcelCallOptions): Promise<ApiResult<ExcelVerifyAnswer>>;
41
+ /**
42
+ * Writes the rows a verify answered with, as the person left them - the **rows**, never the file again, or every fix
43
+ * they made on screen would be lost. Every check runs again on the way in: what the browser sends is a row, not a
44
+ * decision.
45
+ */
46
+ export declare function importExcelRows(endpoints: ExcelEndpoints, rows: readonly ExcelSheetRow[], options?: ExcelCallOptions): Promise<ApiResult<ExcelImportAnswer>>;
@@ -0,0 +1,196 @@
1
+ "use client";
2
+ import { messageBox } from "../Components/Layouts/MessageBox.js";
3
+ import { toast } from "../Components/Layouts/Toaster.js";
4
+ import { apiCallContext } from "../Helpers/ApiClient.js";
5
+ import { getExcelBackend, readDownloadHeaders, readExcelResponse } from "../Helpers/ExcelBackend.js";
6
+ import { excelCount, excelExportWarning, excelText, saveExcelFile } from "../Helpers/ExcelHelpers.js";
7
+ import { buildMessage } from "../Helpers/MessageBuilder.js";
8
+ import { api, getApiCallSettings, getApiReader } from "./BrowserApi.js";
9
+ /* The settings, kept */
10
+ const settingsCache = new Map();
11
+ const cacheKey = (endpoint, options) => `${String(endpoint.module)}|${endpoint.path}|${JSON.stringify(options.params ?? null)}`;
12
+ /**
13
+ * The limits and the shape of the sheet. It is the same for everybody and changes once a release, so the first answer
14
+ * is kept for as long as the page lives - the backend says the same with a `Cache-Control` of an hour.
15
+ */
16
+ export async function excelSettings(endpoints, options = {}) {
17
+ const key = cacheKey(endpoints.settings, options);
18
+ const kept = settingsCache.get(key);
19
+ if (kept)
20
+ return kept;
21
+ const asked = (async () => {
22
+ const answer = await api.get(endpoints.settings, { ...call(options), toast: options.toast === false ? false : { success: false } });
23
+ if (!answer.ok)
24
+ return answer;
25
+ return { ...answer, result: getExcelBackend().readSettings(answer.result) };
26
+ })();
27
+ settingsCache.set(key, asked);
28
+ // A failure is not worth keeping: the next offer of an import should ask again.
29
+ const answer = await asked;
30
+ if (!answer.ok)
31
+ settingsCache.delete(key);
32
+ return answer;
33
+ }
34
+ /** Forgets the kept settings, for a test or an app that changed what a sheet holds while it ran. */
35
+ export function clearExcelSettings() {
36
+ settingsCache.clear();
37
+ }
38
+ /* The template, and the export: the two downloads */
39
+ /** The template to fill in, as the backend built it, under the name the answer gives it. */
40
+ export async function downloadExcelTemplate(endpoints, options = {}) {
41
+ const limits = await excelSettings(endpoints, { signal: options.signal, params: options.params, toast: options.toast });
42
+ const name = limits.result?.templateFileName ?? "template.xlsx";
43
+ const answer = await download(endpoints.template, options, name);
44
+ if (answer.ok && answer.result)
45
+ saveExcelFile(answer.result);
46
+ return answer;
47
+ }
48
+ /**
49
+ * An export of what the person has on screen: the search, the sort and the filters, sent to the model's own action.
50
+ * Not a file written from the rows the browser happens to hold - the backend writes every row the search leaves, up to
51
+ * its own limit, and says in the answer's headers what it left out.
52
+ *
53
+ * Over that limit the person is asked first, because a file that is quietly short is worse than one they chose.
54
+ */
55
+ export async function exportExcel(endpoints, options = {}) {
56
+ const endpoint = endpoints.export;
57
+ if (!endpoint)
58
+ throw new Error("This model has no export endpoint: `excelEndpoints` was given `exportable: false`, or the app's own endpoints left `export` out.");
59
+ const limits = await excelSettings(endpoints, { signal: options.signal, params: options.params, toast: options.toast });
60
+ const subject = options.subject ?? limits.result?.subject ?? "row";
61
+ const limit = limits.result?.maxExportRows;
62
+ if (options.confirm !== false && limit !== undefined && options.rows !== undefined && options.rows > limit) {
63
+ const yes = await messageBox.confirm({
64
+ title: excelText("exportWarning"),
65
+ message: excelExportWarning(options.rows, limit, subject),
66
+ tone: "warning",
67
+ confirmLabel: `Export ${excelCount(limit, subject)}`,
68
+ });
69
+ if (!yes)
70
+ return null;
71
+ }
72
+ // The request is the model's own list request. A GET export carries it in the URL, a POST one in its body.
73
+ const request = getExcelBackend().exportRequest(options.query ?? {});
74
+ const isGet = endpoint.method === "GET";
75
+ const { query: _screen, ...rest } = options;
76
+ const answer = await download(endpoint, { ...rest, ...(isGet ? { query: toApiQuery(request) } : {}) }, `${subject}.xlsx`, isGet ? undefined : request);
77
+ if (!answer.ok || !answer.result)
78
+ return answer;
79
+ saveExcelFile(answer.result);
80
+ if (options.toast !== false) {
81
+ const note = answer.result.note;
82
+ const written = answer.result.rows === undefined ? undefined : excelCount(answer.result.rows, subject);
83
+ if (note)
84
+ toast.warning(written ? `${written} exported` : "Exported", { message: note });
85
+ else
86
+ toast.success(written ? `${written} exported` : "Exported", { message: answer.result.fileName });
87
+ }
88
+ return answer;
89
+ }
90
+ /* The two legs of an import */
91
+ /** The filled file read and judged, with nothing written. The rows it answers with are what an import sends back. */
92
+ export async function verifyExcelFile(endpoints, file, options = {}) {
93
+ const form = new FormData();
94
+ form.append(getExcelBackend().uploadField, file, file.name);
95
+ const answer = await api.post(endpoints.verify, form, { ...call(options), toast: options.toast === false ? false : { success: false } });
96
+ if (!answer.ok)
97
+ return answer;
98
+ return { ...answer, result: getExcelBackend().readVerify(answer.result) };
99
+ }
100
+ /**
101
+ * Writes the rows a verify answered with, as the person left them - the **rows**, never the file again, or every fix
102
+ * they made on screen would be lost. Every check runs again on the way in: what the browser sends is a row, not a
103
+ * decision.
104
+ */
105
+ export async function importExcelRows(endpoints, rows, options = {}) {
106
+ const backend = getExcelBackend();
107
+ const answer = await api.post(endpoints.import, backend.importRequest(rows), { ...call(options), count: rows.length, toast: options.toast === false ? false : { success: false } });
108
+ // An import that wrote nothing at all is the one failure; it still answers with what it refused, where it says so.
109
+ return { ...answer, result: answer.result === undefined ? undefined : backend.readImport(answer.result) };
110
+ }
111
+ /* Inside */
112
+ /**
113
+ * A call's options. The reader is named here, as the last word only: the call's own comes first and the app's
114
+ * (`configureApi({ read })`) second, so nothing shadows an app that registered one - and if neither did,
115
+ * `readExcelResponse` reads the backend's `errorMessages`, which is where a refused import says why.
116
+ */
117
+ function call(options) {
118
+ const { toast: _toast, ...rest } = options;
119
+ return { ...rest, read: options.read ?? getApiReader() ?? readExcelResponse };
120
+ }
121
+ /** A request object as query values: what a URL can carry, and nothing else. */
122
+ function toApiQuery(request) {
123
+ const query = {};
124
+ if (typeof request !== "object" || request === null)
125
+ return query;
126
+ for (const [key, value] of Object.entries(request)) {
127
+ if (value === undefined || value === null)
128
+ continue;
129
+ if (Array.isArray(value))
130
+ query[key] = value.filter(item => typeof item === "string" || typeof item === "number" || typeof item === "boolean");
131
+ else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date)
132
+ query[key] = value;
133
+ }
134
+ return query;
135
+ }
136
+ /**
137
+ * A download: the bytes, and the headers that say what the file is. It is the one `fetch` here, so it asks the API
138
+ * layer for everything a call of its own would have carried.
139
+ */
140
+ async function download(endpoint, options, fallbackName, body) {
141
+ const settings = getApiCallSettings();
142
+ const described = apiCallContext(endpoint, { ...options, method: endpoint.method });
143
+ const url = await api.url(endpoint, { params: options.params, query: options.query, body });
144
+ const asked = { target: endpoint, ...described, url, options };
145
+ const headers = { ...settings.headers?.(asked), ...options.headers };
146
+ const token = settings.token?.(asked);
147
+ if (token)
148
+ headers.Authorization = /^bearer /i.test(token) ? token : `Bearer ${token}`;
149
+ if (body !== undefined)
150
+ headers["Content-Type"] = "application/json";
151
+ let response;
152
+ try {
153
+ response = await fetch(url, {
154
+ method: endpoint.method,
155
+ headers,
156
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
157
+ ...(settings.credentials ? { credentials: settings.credentials } : {}),
158
+ signal: options.signal,
159
+ });
160
+ }
161
+ catch (failure) {
162
+ // The caller gave up: nothing is said, as a stopped call says nothing anywhere else either.
163
+ if (failure instanceof DOMException && failure.name === "AbortError")
164
+ return { ok: false, aborted: true };
165
+ return fail({ ok: false, network: true }, described, options);
166
+ }
167
+ if (!response.ok)
168
+ return fail(await readFailure(response, options), described, options);
169
+ const blob = await response.blob();
170
+ // A file of nothing is not a file. Some proxies answer 200 with an empty body when the backend went away.
171
+ if (blob.size === 0)
172
+ return fail({ ok: false, status: response.status, message: buildMessage("failure", described.context) || undefined }, described, options);
173
+ return { ok: true, status: response.status, result: readDownloadHeaders(response.headers, blob, fallbackName) };
174
+ }
175
+ /** A refused download answers in the backend's own envelope, not with a file, so it is read as any other answer is. */
176
+ async function readFailure(response, options) {
177
+ const read = options.read ?? getApiReader() ?? readExcelResponse;
178
+ const text = await response.text().catch(() => "");
179
+ let parsed = text;
180
+ if (text) {
181
+ try {
182
+ parsed = JSON.parse(text);
183
+ }
184
+ catch {
185
+ // Not JSON: an HTML error page, or nothing. The reader words it from the status.
186
+ }
187
+ }
188
+ return read(parsed, response.status);
189
+ }
190
+ function fail(result, described, options) {
191
+ if (options.toast !== false && !result.aborted) {
192
+ const why = { ...described.context, status: result.status, network: result.network, timeout: result.timeout };
193
+ toast.error(buildMessage("failure", described.context) || excelText("verifyFailed"), { message: result.message || buildMessage("reason", why) || undefined });
194
+ }
195
+ return result;
196
+ }
@@ -1,4 +1,4 @@
1
- import { type ApiCall } from "../Helpers/ApiClient.ts";
1
+ import { type ApiCall, type ApiResponseReader } from "../Helpers/ApiClient.ts";
2
2
  import type { ApiModuleValue, ApiResult } from "../Interfaces/ApiInterfaces.ts";
3
3
  /** Options of a server call, on top of every call's options. */
4
4
  export interface ServerApiOptions {
@@ -19,6 +19,11 @@ export interface ServerApiConfig {
19
19
  headers?: (call: ApiCall<ServerApiOptions>) => Record<string, string> | undefined | Promise<Record<string, string> | undefined>;
20
20
  /** Milliseconds to wait for an answer. Default 60,000. */
21
21
  timeout?: number;
22
+ /**
23
+ * Reads every answer, for a backend that does not answer as Nexus or ASP.NET Core do. Default `readApiResponse`.
24
+ * The same reader the browser is given in `configureApi`, so `api` and `serverApi` read one envelope.
25
+ */
26
+ read?: ApiResponseReader<ServerApiOptions>;
22
27
  /** After every answer. Default: logs network failures, timeouts, and 5xx answers with `console.error`. */
23
28
  onResult?: (result: ApiResult, call: ApiCall<ServerApiOptions>) => void;
24
29
  }
@@ -62,6 +62,9 @@ export const serverApi = createApiClient({
62
62
  get timeout() {
63
63
  return settings.timeout;
64
64
  },
65
+ get read() {
66
+ return settings.read;
67
+ },
65
68
  prepare(init, call) {
66
69
  const { revalidate, tags } = call.options;
67
70
  // Next.js reads `next` and `cache` on a server fetch; `no-store` with `revalidate` would cancel both.
@@ -9,12 +9,18 @@ export * from "./Helpers/ApiModules.ts";
9
9
  export * from "./Helpers/ApiResponses.ts";
10
10
  export * from "./Helpers/ApiRoutes.ts";
11
11
  export * from "./Helpers/ApiToasts.ts";
12
+ export * from "./Helpers/ChatBackend.ts";
13
+ export * from "./Helpers/ChatHelpers.ts";
14
+ export * from "./Helpers/ChatStore.ts";
15
+ export * from "./Helpers/ChatSummaries.ts";
12
16
  export * from "./Helpers/ClassHelpers.ts";
13
17
  export * from "./Helpers/CrudBackend.ts";
14
18
  export * from "./Helpers/CrudHelpers.ts";
15
19
  export * from "./Helpers/DateHelpers.ts";
16
20
  export * from "./Helpers/DateTimeHelpers.ts";
17
21
  export * from "./Helpers/DropdownHelpers.ts";
22
+ export * from "./Helpers/ExcelBackend.ts";
23
+ export * from "./Helpers/ExcelHelpers.ts";
18
24
  export * from "./Helpers/FormDrafts.ts";
19
25
  export * from "./Helpers/FormStore.ts";
20
26
  export * from "./Helpers/InputParamsHelpers.ts";
@@ -33,8 +39,10 @@ export * from "./Helpers/TimeHelpers.ts";
33
39
  export * from "./Helpers/TreeStore.ts";
34
40
  export * from "./Helpers/ValidationHelpers.ts";
35
41
  export * from "./Interfaces/ApiInterfaces.ts";
42
+ export * from "./Interfaces/ChatInterfaces.ts";
36
43
  export * from "./Interfaces/CrudInterfaces.ts";
37
44
  export * from "./Interfaces/DateInterfaces.ts";
45
+ export * from "./Interfaces/ExcelInterfaces.ts";
38
46
  export * from "./Interfaces/FormInterfaces.ts";
39
47
  export * from "./Interfaces/InputInterfaces.ts";
40
48
  export * from "./Interfaces/MessageInterfaces.ts";
@@ -12,12 +12,18 @@ export * from "./Helpers/ApiModules.js";
12
12
  export * from "./Helpers/ApiResponses.js";
13
13
  export * from "./Helpers/ApiRoutes.js";
14
14
  export * from "./Helpers/ApiToasts.js";
15
+ export * from "./Helpers/ChatBackend.js";
16
+ export * from "./Helpers/ChatHelpers.js";
17
+ export * from "./Helpers/ChatStore.js";
18
+ export * from "./Helpers/ChatSummaries.js";
15
19
  export * from "./Helpers/ClassHelpers.js";
16
20
  export * from "./Helpers/CrudBackend.js";
17
21
  export * from "./Helpers/CrudHelpers.js";
18
22
  export * from "./Helpers/DateHelpers.js";
19
23
  export * from "./Helpers/DateTimeHelpers.js";
20
24
  export * from "./Helpers/DropdownHelpers.js";
25
+ export * from "./Helpers/ExcelBackend.js";
26
+ export * from "./Helpers/ExcelHelpers.js";
21
27
  export * from "./Helpers/FormDrafts.js";
22
28
  export * from "./Helpers/FormStore.js";
23
29
  export * from "./Helpers/InputParamsHelpers.js";
@@ -37,8 +43,10 @@ export * from "./Helpers/TreeStore.js";
37
43
  export * from "./Helpers/ValidationHelpers.js";
38
44
  // Interfaces
39
45
  export * from "./Interfaces/ApiInterfaces.js";
46
+ export * from "./Interfaces/ChatInterfaces.js";
40
47
  export * from "./Interfaces/CrudInterfaces.js";
41
48
  export * from "./Interfaces/DateInterfaces.js";
49
+ export * from "./Interfaces/ExcelInterfaces.js";
42
50
  export * from "./Interfaces/FormInterfaces.js";
43
51
  export * from "./Interfaces/InputInterfaces.js";
44
52
  export * from "./Interfaces/MessageInterfaces.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexus-shared",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "The Nexus component library for React and Next.js: params-driven inputs and forms, a data table with a CRUD on top, popups and toasts, an API layer with permissions, Markdown and rich text editors, and 7 themes in light and dark.",
5
5
  "keywords": [
6
6
  "react",
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "files": [
42
42
  "dist",
43
- "src/Styles"
43
+ "src/Styles",
44
+ "CHANGELOG.md"
44
45
  ],
45
46
  "scripts": {
46
47
  "build": "tsc -p tsconfig.build.json",
@@ -48,9 +49,11 @@
48
49
  "test": "node scripts/test-all.mjs",
49
50
  "check:themes": "node scripts/check-themes.mjs",
50
51
  "test:api": "node scripts/test-api.mts",
52
+ "test:chat": "node scripts/test-chat.mts",
51
53
  "test:dates": "node scripts/test-date-helpers.mts",
52
54
  "test:datetimes": "node scripts/test-datetime-helpers.mts",
53
55
  "test:drag": "node scripts/test-drag-helpers.mts",
56
+ "test:excel": "node scripts/test-excel.mts",
54
57
  "test:dropdown": "node scripts/test-dropdown-helpers.mts",
55
58
  "test:forms": "node scripts/test-form-store.mts",
56
59
  "test:markdown": "node scripts/test-markdown.mts",
@@ -64,7 +67,7 @@
64
67
  "test:types": "tsc -p scripts/type-checks/tsconfig.json"
65
68
  },
66
69
  "dependencies": {
67
- "nexus-icons": "^1.0.0"
70
+ "nexus-icons": "^1.1.0"
68
71
  },
69
72
  "peerDependencies": {
70
73
  "react": ">=19",
@@ -20,6 +20,8 @@
20
20
  font-weight: 500;
21
21
  line-height: 1;
22
22
  white-space: nowrap;
23
+ /* The same classes make a link look like a button, for a call to action that navigates. */
24
+ text-decoration: none;
23
25
  cursor: pointer;
24
26
  user-select: none;
25
27
  box-shadow: var(--nx-shadow-sm);