@stapel/forms-react 0.1.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.
- package/CHANGELOG.md +62 -0
- package/MODULE.md +172 -0
- package/README.md +207 -0
- package/dist/api/export.d.ts +66 -0
- package/dist/api/export.d.ts.map +1 -0
- package/dist/api/export.js +138 -0
- package/dist/api/export.js.map +1 -0
- package/dist/api/extensions.d.ts +8 -0
- package/dist/api/extensions.d.ts.map +1 -0
- package/dist/api/extensions.js +8 -0
- package/dist/api/extensions.js.map +1 -0
- package/dist/api/formsApi.d.ts +113 -0
- package/dist/api/formsApi.d.ts.map +1 -0
- package/dist/api/formsApi.js +81 -0
- package/dist/api/formsApi.js.map +1 -0
- package/dist/api/generated/schema.d.ts +987 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/types.d.ts +222 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +14 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/ConfigField.d.ts +32 -0
- package/dist/default/ConfigField.d.ts.map +1 -0
- package/dist/default/ConfigField.js +101 -0
- package/dist/default/ConfigField.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FormBuilderPane.d.ts +36 -0
- package/dist/default/FormBuilderPane.d.ts.map +1 -0
- package/dist/default/FormBuilderPane.js +70 -0
- package/dist/default/FormBuilderPane.js.map +1 -0
- package/dist/default/FormsListPane.d.ts +18 -0
- package/dist/default/FormsListPane.d.ts.map +1 -0
- package/dist/default/FormsListPane.js +28 -0
- package/dist/default/FormsListPane.js.map +1 -0
- package/dist/default/ResponsesPane.d.ts +21 -0
- package/dist/default/ResponsesPane.d.ts.map +1 -0
- package/dist/default/ResponsesPane.js +118 -0
- package/dist/default/ResponsesPane.js.map +1 -0
- package/dist/default/StapelForm.d.ts +76 -0
- package/dist/default/StapelForm.d.ts.map +1 -0
- package/dist/default/StapelForm.js +118 -0
- package/dist/default/StapelForm.js.map +1 -0
- package/dist/default/fields.d.ts +11 -0
- package/dist/default/fields.d.ts.map +1 -0
- package/dist/default/fields.js +273 -0
- package/dist/default/fields.js.map +1 -0
- package/dist/default/index.d.ts +42 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +37 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/slots.d.ts +59 -0
- package/dist/default/slots.d.ts.map +1 -0
- package/dist/default/slots.js +25 -0
- package/dist/default/slots.js.map +1 -0
- package/dist/default/theme.d.ts +23 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +38 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/flows/errors.d.ts +12 -0
- package/dist/flows/errors.d.ts.map +1 -0
- package/dist/flows/errors.js +13 -0
- package/dist/flows/errors.js.map +1 -0
- package/dist/flows/registry.d.ts +22 -0
- package/dist/flows/registry.d.ts.map +1 -0
- package/dist/flows/registry.js +20 -0
- package/dist/flows/registry.js.map +1 -0
- package/dist/headless/FormBuilder.d.ts +100 -0
- package/dist/headless/FormBuilder.d.ts.map +1 -0
- package/dist/headless/FormBuilder.js +278 -0
- package/dist/headless/FormBuilder.js.map +1 -0
- package/dist/headless/FormFill.d.ts +88 -0
- package/dist/headless/FormFill.d.ts.map +1 -0
- package/dist/headless/FormFill.js +279 -0
- package/dist/headless/FormFill.js.map +1 -0
- package/dist/headless/FormList.d.ts +34 -0
- package/dist/headless/FormList.d.ts.map +1 -0
- package/dist/headless/FormList.js +37 -0
- package/dist/headless/FormList.js.map +1 -0
- package/dist/headless/FormsProvider.d.ts +19 -0
- package/dist/headless/FormsProvider.d.ts.map +1 -0
- package/dist/headless/FormsProvider.js +15 -0
- package/dist/headless/FormsProvider.js.map +1 -0
- package/dist/headless/ResponsesTable.d.ts +82 -0
- package/dist/headless/ResponsesTable.d.ts.map +1 -0
- package/dist/headless/ResponsesTable.js +195 -0
- package/dist/headless/ResponsesTable.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +10 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +20 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +7 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +118 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +79 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +485 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +246 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +16 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +79 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +141 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +233 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +7 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +127 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/mutations.d.ts +135 -0
- package/dist/model/mutations.d.ts.map +1 -0
- package/dist/model/mutations.js +223 -0
- package/dist/model/mutations.js.map +1 -0
- package/dist/model/queries.d.ts +59 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +117 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +28 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +23 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +28 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +14 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/widgets/configForms.d.ts +112 -0
- package/dist/widgets/configForms.d.ts.map +1 -0
- package/dist/widgets/configForms.js +211 -0
- package/dist/widgets/configForms.js.map +1 -0
- package/dist/widgets/registry.d.ts +83 -0
- package/dist/widgets/registry.d.ts.map +1 -0
- package/dist/widgets/registry.js +27 -0
- package/dist/widgets/registry.js.map +1 -0
- package/dist/widgets/validate.d.ts +57 -0
- package/dist/widgets/validate.d.ts.map +1 -0
- package/dist/widgets/validate.js +173 -0
- package/dist/widgets/validate.js.map +1 -0
- package/llms.txt +124 -0
- package/manifest.json +1038 -0
- package/package.json +108 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/export.ts +195 -0
- package/src/api/extensions.ts +7 -0
- package/src/api/formsApi.ts +329 -0
- package/src/api/generated/schema.ts +989 -0
- package/src/api/types.ts +276 -0
- package/src/default/ConfigField.tsx +227 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FormBuilderPane.tsx +414 -0
- package/src/default/FormsListPane.tsx +144 -0
- package/src/default/ResponsesPane.tsx +332 -0
- package/src/default/StapelForm.tsx +377 -0
- package/src/default/fields.tsx +454 -0
- package/src/default/index.ts +71 -0
- package/src/default/slots.ts +78 -0
- package/src/default/theme.tsx +65 -0
- package/src/default/types.ts +12 -0
- package/src/flows/errors.ts +16 -0
- package/src/flows/registry.ts +32 -0
- package/src/headless/FormBuilder.tsx +453 -0
- package/src/headless/FormFill.tsx +396 -0
- package/src/headless/FormList.tsx +74 -0
- package/src/headless/FormsProvider.tsx +20 -0
- package/src/headless/ResponsesTable.tsx +341 -0
- package/src/i18n/errorsMap.ts +31 -0
- package/src/i18n/es.ts +140 -0
- package/src/i18n/generated/errors.es.gen.ts +85 -0
- package/src/i18n/generated/errors.gen.ts +277 -0
- package/src/i18n/generated/errors.json +583 -0
- package/src/i18n/generated/errors.ru.gen.ts +85 -0
- package/src/i18n/keys.ts +264 -0
- package/src/i18n/ru.ts +150 -0
- package/src/index.ts +184 -0
- package/src/model/context.tsx +28 -0
- package/src/model/mutations.ts +387 -0
- package/src/model/queries.ts +154 -0
- package/src/model/queryKeys.ts +61 -0
- package/src/model/runtime.ts +48 -0
- package/src/widgets/registry.ts +101 -0
- package/src/widgets/validate.ts +224 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
actionAvailable,
|
|
5
|
+
actionBlocked,
|
|
6
|
+
bothLoaded,
|
|
7
|
+
isStapelApiError,
|
|
8
|
+
loadStateFromQuery,
|
|
9
|
+
mapLoad,
|
|
10
|
+
requireLoaded,
|
|
11
|
+
} from "@stapel/core";
|
|
12
|
+
import type {
|
|
13
|
+
ActionAvailability,
|
|
14
|
+
LoadState,
|
|
15
|
+
StapelApiError,
|
|
16
|
+
} from "@stapel/core";
|
|
17
|
+
import type {
|
|
18
|
+
FormFieldDef,
|
|
19
|
+
FormVersion,
|
|
20
|
+
Submission,
|
|
21
|
+
} from "../api/types.js";
|
|
22
|
+
import { useFormVersions, useSubmissions } from "../model/queries.js";
|
|
23
|
+
import {
|
|
24
|
+
useCsvExport,
|
|
25
|
+
useDeleteSubmission,
|
|
26
|
+
useResendSubmission,
|
|
27
|
+
} from "../model/mutations.js";
|
|
28
|
+
import type { ResendRequest } from "../api/types.js";
|
|
29
|
+
import { FORMS_I18N_KEYS } from "../i18n/keys.js";
|
|
30
|
+
|
|
31
|
+
/** Default page size for the keyset list. */
|
|
32
|
+
const DEFAULT_LIMIT = 50;
|
|
33
|
+
|
|
34
|
+
/** One column of the responses grid — a field of the version being reviewed. */
|
|
35
|
+
export interface ResponseColumn {
|
|
36
|
+
readonly slug: string;
|
|
37
|
+
/** The admin-authored label, or the slug when the field was never named. */
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly kind: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The rows and the columns that describe them, loaded together. */
|
|
43
|
+
export interface ResponsesView {
|
|
44
|
+
readonly rows: readonly Submission[];
|
|
45
|
+
/**
|
|
46
|
+
* Columns for the CURRENT version filter. Per-version column sets are what
|
|
47
|
+
* the version FK buys: a submission records which schema it answered, so
|
|
48
|
+
* reviewing an old response shows the questions that were actually asked,
|
|
49
|
+
* not today's.
|
|
50
|
+
*
|
|
51
|
+
* With no version filter this is the union of every version's fields, in
|
|
52
|
+
* newest-version-first order — the honest superset, because rows from
|
|
53
|
+
* different versions genuinely have different keys.
|
|
54
|
+
*/
|
|
55
|
+
readonly columns: readonly ResponseColumn[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The bag `<ResponsesTable>` hands its render prop (spec §8.2). */
|
|
59
|
+
export interface ResponsesTableBag {
|
|
60
|
+
readonly state: LoadState<ResponsesView>;
|
|
61
|
+
/** Every published version, for the filter control. */
|
|
62
|
+
readonly versions: LoadState<readonly FormVersion[]>;
|
|
63
|
+
/** The active version filter, or `null` for "all versions". */
|
|
64
|
+
readonly version: number | null;
|
|
65
|
+
setVersion(version: number | null): void;
|
|
66
|
+
|
|
67
|
+
/** Go to the next keyset page. Blocked with a reason at the end of the
|
|
68
|
+
* list, while loading, and when the load failed. */
|
|
69
|
+
readonly nextPage: ActionAvailability;
|
|
70
|
+
goNextPage(): void;
|
|
71
|
+
/** Go back one page. Blocked with a reason on the first page. */
|
|
72
|
+
readonly prevPage: ActionAvailability;
|
|
73
|
+
goPrevPage(): void;
|
|
74
|
+
/** 0-based index of the page on screen. */
|
|
75
|
+
readonly pageIndex: number;
|
|
76
|
+
|
|
77
|
+
/** The row opened in a detail drawer, or `null`. */
|
|
78
|
+
readonly selected: Submission | null;
|
|
79
|
+
select(submission: Submission | null): void;
|
|
80
|
+
|
|
81
|
+
/** `forms.responses.manage`. */
|
|
82
|
+
remove(submissionId: string): void;
|
|
83
|
+
readonly isRemoving: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Re-deliver one response. An `override` REPLACES the form's configured
|
|
86
|
+
* targets rather than adding to them (backend delta note 7).
|
|
87
|
+
*/
|
|
88
|
+
resend(submissionId: string, override?: ResendRequest): void;
|
|
89
|
+
readonly isResending: boolean;
|
|
90
|
+
/** How many deliveries the last resend reported, or `null`. */
|
|
91
|
+
readonly lastResendCount: number | null;
|
|
92
|
+
|
|
93
|
+
/** Drive the CSV export to completion; resolves to the whole file's text. */
|
|
94
|
+
exportCsv(): Promise<string>;
|
|
95
|
+
readonly isExporting: boolean;
|
|
96
|
+
readonly exportPages: number;
|
|
97
|
+
|
|
98
|
+
/** The last mutation refusal (delete / resend / export). */
|
|
99
|
+
readonly error: StapelApiError | null;
|
|
100
|
+
refetch(): void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A caught value → the API dialect, or `null`.
|
|
105
|
+
*
|
|
106
|
+
* `caught as StapelApiError` is a lie for a network fault (no `.code`, no
|
|
107
|
+
* `.status`), and `stapel/no-raw-error-shape` is right to refuse it. A bag
|
|
108
|
+
* that reports `error: null` for a fault it cannot describe is honest; the
|
|
109
|
+
* mutation's own `isError` still says something went wrong.
|
|
110
|
+
*/
|
|
111
|
+
function asApiError(caught: unknown): StapelApiError | null {
|
|
112
|
+
return isStapelApiError(caught) ? caught : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function columnsOf(fields: readonly FormFieldDef[]): ResponseColumn[] {
|
|
116
|
+
return fields
|
|
117
|
+
// A header is a caption, not a question: it has no answer, so it is not a
|
|
118
|
+
// column. (The engine regenerates its DAO from config and rejects an
|
|
119
|
+
// answer to one, so a header column would always be empty.)
|
|
120
|
+
.filter((field) => field.kind !== "header")
|
|
121
|
+
.map((field) => ({
|
|
122
|
+
slug: field.slug,
|
|
123
|
+
title:
|
|
124
|
+
typeof field.name === "string" && field.name.length > 0
|
|
125
|
+
? field.name
|
|
126
|
+
: field.slug,
|
|
127
|
+
kind: field.kind,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Headless response review — keyset paging, per-version columns, detail
|
|
133
|
+
* selection, delete, resend and CSV export, renderless.
|
|
134
|
+
*
|
|
135
|
+
* Live-updating counts are refetch-only by design: a `forms:ws:<workspace_id>`
|
|
136
|
+
* Signal stream is RESERVED naming for when the stapel-realtime substrate
|
|
137
|
+
* lands (spec §8.2), and forms does not build a socket of its own — that is a
|
|
138
|
+
* lint boundary, not an omission.
|
|
139
|
+
*/
|
|
140
|
+
export function ResponsesTable(props: {
|
|
141
|
+
workspaceId: string;
|
|
142
|
+
formId: string;
|
|
143
|
+
/** Rows per page. Default 50. */
|
|
144
|
+
limit?: number;
|
|
145
|
+
children: (bag: ResponsesTableBag) => ReactNode;
|
|
146
|
+
}): ReactNode {
|
|
147
|
+
const limit = props.limit ?? DEFAULT_LIMIT;
|
|
148
|
+
const [version, setVersionState] = useState<number | null>(null);
|
|
149
|
+
// The keyset trail: one cursor per page boundary crossed. Index 0 is the
|
|
150
|
+
// first page (no cursor). Keeping the whole trail — rather than one
|
|
151
|
+
// "current" cursor — is what makes "previous" possible at all: a keyset
|
|
152
|
+
// cursor only ever walks forward.
|
|
153
|
+
const [trail, setTrail] = useState<readonly (string | undefined)[]>([
|
|
154
|
+
undefined,
|
|
155
|
+
]);
|
|
156
|
+
const [pageIndex, setPageIndex] = useState(0);
|
|
157
|
+
const [selected, setSelected] = useState<Submission | null>(null);
|
|
158
|
+
const [error, setError] = useState<StapelApiError | null>(null);
|
|
159
|
+
const [lastResendCount, setLastResendCount] = useState<number | null>(null);
|
|
160
|
+
|
|
161
|
+
const before = trail[pageIndex];
|
|
162
|
+
|
|
163
|
+
const submissionsQuery = useSubmissions({
|
|
164
|
+
workspaceId: props.workspaceId,
|
|
165
|
+
formId: props.formId,
|
|
166
|
+
limit,
|
|
167
|
+
...(before !== undefined ? { before } : {}),
|
|
168
|
+
...(version !== null ? { version } : {}),
|
|
169
|
+
});
|
|
170
|
+
const versionsQuery = useFormVersions(props.workspaceId, props.formId);
|
|
171
|
+
|
|
172
|
+
const rowsState = loadStateFromQuery(submissionsQuery);
|
|
173
|
+
const versionsState = loadStateFromQuery(versionsQuery);
|
|
174
|
+
|
|
175
|
+
const state: LoadState<ResponsesView> = useMemo(
|
|
176
|
+
() =>
|
|
177
|
+
mapLoad(bothLoaded(rowsState, versionsState), ([rows, versions]) => {
|
|
178
|
+
const chosen =
|
|
179
|
+
version !== null
|
|
180
|
+
? versions.filter((v) => v.version === version)
|
|
181
|
+
: [...versions].sort((a, b) => b.version - a.version);
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
const columns: ResponseColumn[] = [];
|
|
184
|
+
for (const v of chosen) {
|
|
185
|
+
for (const column of columnsOf(v.schema.fields ?? [])) {
|
|
186
|
+
if (seen.has(column.slug)) continue;
|
|
187
|
+
seen.add(column.slug);
|
|
188
|
+
columns.push(column);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { rows, columns };
|
|
192
|
+
}),
|
|
193
|
+
[rowsState, versionsState, version]
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const deleteMutation = useDeleteSubmission();
|
|
197
|
+
const resendMutation = useResendSubmission();
|
|
198
|
+
const csv = useCsvExport();
|
|
199
|
+
|
|
200
|
+
const setVersion = useCallback((next: number | null): void => {
|
|
201
|
+
setVersionState(next);
|
|
202
|
+
// A version filter is a different list, so the keyset trail from the old
|
|
203
|
+
// one is meaningless — carrying it over would page into cursors that
|
|
204
|
+
// belong to rows this filter excludes.
|
|
205
|
+
setTrail([undefined]);
|
|
206
|
+
setPageIndex(0);
|
|
207
|
+
setSelected(null);
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
// Memoized, not a bare conditional: `goNextPage` closes over `rows`, and a
|
|
211
|
+
// fresh `[]` on every render would re-create that callback every time.
|
|
212
|
+
const rows = useMemo(
|
|
213
|
+
() => (state.status === "ready" ? state.data.rows : []),
|
|
214
|
+
[state]
|
|
215
|
+
);
|
|
216
|
+
// A short page is the end of the list: the server returns at most `limit`.
|
|
217
|
+
const atEnd = state.status === "ready" && rows.length < limit;
|
|
218
|
+
|
|
219
|
+
const nextPage: ActionAvailability = useMemo(
|
|
220
|
+
() =>
|
|
221
|
+
requireLoaded(state, (view) => {
|
|
222
|
+
if (view.rows.length === 0 || view.rows.length < limit) {
|
|
223
|
+
return actionBlocked(FORMS_I18N_KEYS.responsesAtEnd);
|
|
224
|
+
}
|
|
225
|
+
return actionAvailable();
|
|
226
|
+
}),
|
|
227
|
+
[state, limit]
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const prevPage: ActionAvailability = useMemo(
|
|
231
|
+
() =>
|
|
232
|
+
pageIndex === 0
|
|
233
|
+
? actionBlocked(FORMS_I18N_KEYS.responsesAtStart)
|
|
234
|
+
: actionAvailable(),
|
|
235
|
+
[pageIndex]
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const goNextPage = useCallback((): void => {
|
|
239
|
+
if (atEnd || rows.length === 0) return;
|
|
240
|
+
const last = rows[rows.length - 1];
|
|
241
|
+
if (last === undefined) return;
|
|
242
|
+
setTrail((current) => {
|
|
243
|
+
const next = current.slice(0, pageIndex + 1);
|
|
244
|
+
next.push(last.submitted_at);
|
|
245
|
+
return next;
|
|
246
|
+
});
|
|
247
|
+
setPageIndex((index) => index + 1);
|
|
248
|
+
setSelected(null);
|
|
249
|
+
}, [atEnd, rows, pageIndex]);
|
|
250
|
+
|
|
251
|
+
const goPrevPage = useCallback((): void => {
|
|
252
|
+
setPageIndex((index) => (index > 0 ? index - 1 : 0));
|
|
253
|
+
setSelected(null);
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const onError = useCallback((caught: unknown): void => {
|
|
257
|
+
setError(asApiError(caught));
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const remove = useCallback(
|
|
261
|
+
(submissionId: string): void => {
|
|
262
|
+
setError(null);
|
|
263
|
+
deleteMutation.mutate(
|
|
264
|
+
{ workspaceId: props.workspaceId, submissionId },
|
|
265
|
+
{
|
|
266
|
+
onError,
|
|
267
|
+
onSuccess: () => {
|
|
268
|
+
setSelected((current) =>
|
|
269
|
+
current !== null && current.id === submissionId ? null : current
|
|
270
|
+
);
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
[deleteMutation, props.workspaceId, onError]
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const resend = useCallback(
|
|
279
|
+
(submissionId: string, override?: ResendRequest): void => {
|
|
280
|
+
setError(null);
|
|
281
|
+
setLastResendCount(null);
|
|
282
|
+
resendMutation.mutate(
|
|
283
|
+
{
|
|
284
|
+
workspaceId: props.workspaceId,
|
|
285
|
+
submissionId,
|
|
286
|
+
...(override !== undefined ? { override } : {}),
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
onError,
|
|
290
|
+
onSuccess: (result) => {
|
|
291
|
+
setLastResendCount(result.sent);
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
[resendMutation, props.workspaceId, onError]
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const exportCsv = useCallback(async (): Promise<string> => {
|
|
300
|
+
setError(null);
|
|
301
|
+
try {
|
|
302
|
+
return await csv.run({
|
|
303
|
+
workspaceId: props.workspaceId,
|
|
304
|
+
formId: props.formId,
|
|
305
|
+
...(version !== null ? { version } : {}),
|
|
306
|
+
});
|
|
307
|
+
} catch (caught) {
|
|
308
|
+
setError(asApiError(caught));
|
|
309
|
+
throw caught;
|
|
310
|
+
}
|
|
311
|
+
}, [csv, props.workspaceId, props.formId, version]);
|
|
312
|
+
|
|
313
|
+
const refetch = useCallback((): void => {
|
|
314
|
+
void submissionsQuery.refetch();
|
|
315
|
+
void versionsQuery.refetch();
|
|
316
|
+
}, [submissionsQuery, versionsQuery]);
|
|
317
|
+
|
|
318
|
+
return props.children({
|
|
319
|
+
state,
|
|
320
|
+
versions: versionsState,
|
|
321
|
+
version,
|
|
322
|
+
setVersion,
|
|
323
|
+
nextPage,
|
|
324
|
+
goNextPage,
|
|
325
|
+
prevPage,
|
|
326
|
+
goPrevPage,
|
|
327
|
+
pageIndex,
|
|
328
|
+
selected,
|
|
329
|
+
select: setSelected,
|
|
330
|
+
remove,
|
|
331
|
+
isRemoving: deleteMutation.isPending,
|
|
332
|
+
resend,
|
|
333
|
+
isResending: resendMutation.isPending,
|
|
334
|
+
lastResendCount,
|
|
335
|
+
exportCsv,
|
|
336
|
+
isExporting: csv.isExporting,
|
|
337
|
+
exportPages: csv.pagesFetched,
|
|
338
|
+
error,
|
|
339
|
+
refetch,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pair's error map (frontend-standard §4 checklist #7, frontend-core §2.5):
|
|
3
|
+
* the generated `code → { status, params, remediation, en }` catalog plus a
|
|
4
|
+
* tiny `explain()` lookup. Backs the manifest `errors` block and gives hosts a
|
|
5
|
+
* mechanical UX branch beside `t(code, params)`. The map itself is generated
|
|
6
|
+
* from the backend registry (`pnpm gen:errors`); this file only adds the lookup
|
|
7
|
+
* helper and re-exports the public surface.
|
|
8
|
+
*/
|
|
9
|
+
import { FORMS_ERRORS } from "./generated/errors.gen.js";
|
|
10
|
+
import type { Remediation } from "./generated/errors.gen.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
FORMS_ERRORS,
|
|
14
|
+
FORMS_ERROR_CODES,
|
|
15
|
+
formsErrorBundleEn,
|
|
16
|
+
} from "./generated/errors.gen.js";
|
|
17
|
+
export type {
|
|
18
|
+
FormsErrorCode,
|
|
19
|
+
FormsErrorSpec,
|
|
20
|
+
Remediation,
|
|
21
|
+
} from "./generated/errors.gen.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a backend error code to its remediation hint, or `undefined` for a
|
|
25
|
+
* code this module doesn't know (e.g. a cross-cutting `stapel.http.*` fallback).
|
|
26
|
+
* Zero guessing at runtime — a static lookup over the generated map.
|
|
27
|
+
*/
|
|
28
|
+
export function explainFormsError(code: string): Remediation | undefined {
|
|
29
|
+
return (FORMS_ERRORS as Record<string, { remediation: Remediation }>)[code]
|
|
30
|
+
?.remediation;
|
|
31
|
+
}
|
package/src/i18n/es.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { formsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
3
|
+
|
|
4
|
+
export { formsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Spanish bundle for forms-react — the pair's `es` locale, shipped as the
|
|
8
|
+
* `@stapel/forms-react/i18n/es` subpath (i18n-shipping.md §2) so the locale is
|
|
9
|
+
* opt-in: hosts that don't register it never carry these strings (the main
|
|
10
|
+
* entry does not import this module — gated by size-limit + the bundle-purity
|
|
11
|
+
* test).
|
|
12
|
+
*
|
|
13
|
+
* Composition mirrors `formsI18nBundleEn`; see `ru.ts` for the same structure
|
|
14
|
+
* and the same provenance statement — stapel-forms' es catalogue ships
|
|
15
|
+
* `origin=seed:authored` and is UNREVIEWED (backend delta note 10), and the
|
|
16
|
+
* pair-authored strings below are the same grade.
|
|
17
|
+
*/
|
|
18
|
+
const ATTRIBUTES_ERRORS_ES: Readonly<Record<string, string>> = {
|
|
19
|
+
"error.400.feature_below_minimum": "El valor es inferior al mínimo de «{feature}»",
|
|
20
|
+
"error.400.feature_above_maximum": "El valor supera el máximo de «{feature}»",
|
|
21
|
+
"error.400.feature_not_in_options":
|
|
22
|
+
"El valor no está entre las opciones permitidas de «{feature}»",
|
|
23
|
+
"error.400.feature_invalid_type": "Tipo de valor no válido para «{feature}»",
|
|
24
|
+
"error.400.feature_invalid_format": "Formato no válido para «{feature}»",
|
|
25
|
+
"error.400.feature_mandatory_missing": "El campo «{feature}» es obligatorio",
|
|
26
|
+
"error.400.feature_unknown_type": "Tipo de campo desconocido para «{feature}»",
|
|
27
|
+
"error.400.feature_not_allowed": "El campo «{feature}» no se permite aquí",
|
|
28
|
+
"error.400.feature_unknown": "Campo desconocido «{feature}»",
|
|
29
|
+
"error.400.feature_invalid_config": "Configuración no válida para «{feature}»",
|
|
30
|
+
"error.400.description_too_short": "La descripción debe tener al menos {min_length} caracteres",
|
|
31
|
+
"error.400.description_too_long": "La descripción debe tener como máximo {max_length} caracteres",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const formsI18nBundleEs: I18nDictionary = {
|
|
35
|
+
...formsErrorBundleEs,
|
|
36
|
+
...ATTRIBUTES_ERRORS_ES,
|
|
37
|
+
|
|
38
|
+
"forms.error.unknown": "Algo salió mal. Inténtalo de nuevo.",
|
|
39
|
+
|
|
40
|
+
"forms.fill.loading": "Cargando el formulario…",
|
|
41
|
+
"forms.fill.retry": "Reintentar",
|
|
42
|
+
"forms.fill.load_failed":
|
|
43
|
+
"No pudimos cargar este formulario. Es un problema nuestro, no de tu enlace.",
|
|
44
|
+
"forms.fill.not_found": "Este enlace de formulario no es válido.",
|
|
45
|
+
"forms.fill.closed": "Este formulario está cerrado y ya no acepta respuestas.",
|
|
46
|
+
"forms.fill.superseded":
|
|
47
|
+
"El formulario cambió mientras lo rellenabas. Revisa tus respuestas y envíalo de nuevo.",
|
|
48
|
+
"forms.fill.unsupported_field":
|
|
49
|
+
"Este tipo de campo ({kind}) no se puede mostrar en esta versión de la aplicación.",
|
|
50
|
+
"forms.fill.required": "Obligatorio",
|
|
51
|
+
"forms.fill.submit": "Enviar",
|
|
52
|
+
"forms.fill.submitting": "Enviando…",
|
|
53
|
+
"forms.fill.thanks": "Gracias, hemos registrado tu respuesta.",
|
|
54
|
+
"forms.fill.optional_hint": "Opcional",
|
|
55
|
+
"forms.fill.bool_yes": "Sí",
|
|
56
|
+
"forms.fill.bool_no": "No",
|
|
57
|
+
"forms.fill.select_placeholder": "Elige…",
|
|
58
|
+
"forms.fill.unlimited": "Sin límite",
|
|
59
|
+
|
|
60
|
+
"forms.submit.blocked.done": "Ya has enviado este formulario.",
|
|
61
|
+
"forms.submit.blocked.in_flight": "Enviando tu respuesta…",
|
|
62
|
+
"forms.submit.blocked.unsupported_kind":
|
|
63
|
+
"Este formulario usa un tipo de campo que la aplicación no puede mostrar ({kinds}), así que no se puede enviar con seguridad.",
|
|
64
|
+
|
|
65
|
+
"forms.builder.title": "Editor de formularios",
|
|
66
|
+
"forms.builder.add_field": "Añadir campo",
|
|
67
|
+
"forms.builder.remove_field": "Eliminar campo",
|
|
68
|
+
"forms.builder.move_up": "Subir",
|
|
69
|
+
"forms.builder.move_down": "Bajar",
|
|
70
|
+
"forms.builder.field_slug": "Clave",
|
|
71
|
+
"forms.builder.field_label": "Etiqueta",
|
|
72
|
+
"forms.builder.field_required": "Obligatorio",
|
|
73
|
+
"forms.builder.field_kind": "Tipo",
|
|
74
|
+
"forms.builder.save": "Guardar borrador",
|
|
75
|
+
"forms.builder.publish": "Publicar",
|
|
76
|
+
"forms.builder.blocked.saving": "Guardando el borrador…",
|
|
77
|
+
"forms.builder.blocked.publishing": "Publicando…",
|
|
78
|
+
"forms.builder.blocked.no_changes": "Nada ha cambiado desde el último guardado.",
|
|
79
|
+
"forms.builder.blocked.empty_schema": "Añade al menos un campo antes de publicar.",
|
|
80
|
+
"forms.builder.blocked.unsaved_draft":
|
|
81
|
+
"Guarda el borrador primero: publicar ahora lanzaría la versión guardada anteriormente.",
|
|
82
|
+
"forms.builder.builder_less":
|
|
83
|
+
"Este tipo de campo no tiene opciones editables aquí. Su configuración se define mediante la API de borradores.",
|
|
84
|
+
"forms.builder.kind_unregistered":
|
|
85
|
+
"Esta instalación no reconoce este tipo de campo, así que no se puede configurar ni mostrar aquí. El campo se conserva para que no desaparezca del esquema sin avisar.",
|
|
86
|
+
"forms.builder.kinds_failed":
|
|
87
|
+
"No pudimos cargar la lista de tipos de campo, así que ahora no se pueden añadir campos.",
|
|
88
|
+
"forms.builder.no_kinds":
|
|
89
|
+
"Esta instalación no tiene tipos de campo configurables disponibles.",
|
|
90
|
+
"forms.builder.unsupported_config":
|
|
91
|
+
"Algunas opciones de este campo ({keys}) aún no se pueden editar aquí.",
|
|
92
|
+
"forms.builder.empty": "Este formulario aún no tiene campos.",
|
|
93
|
+
"forms.builder.meta_title": "Título del formulario",
|
|
94
|
+
"forms.builder.meta_description": "Descripción",
|
|
95
|
+
"forms.builder.meta_submit_label": "Texto del botón de envío",
|
|
96
|
+
"forms.builder.meta_confirmation": "Mensaje de confirmación",
|
|
97
|
+
"forms.builder.state_open": "Abierto",
|
|
98
|
+
"forms.builder.state_closed": "Cerrado",
|
|
99
|
+
"forms.builder.state_draft": "Borrador",
|
|
100
|
+
"forms.builder.rotate_link": "Renovar el enlace público",
|
|
101
|
+
"forms.builder.public_link": "Enlace público",
|
|
102
|
+
|
|
103
|
+
"forms.responses.title": "Respuestas",
|
|
104
|
+
"forms.responses.empty": "Todavía no hay respuestas.",
|
|
105
|
+
"forms.responses.load_failed": "No pudimos cargar las respuestas.",
|
|
106
|
+
"forms.responses.submitted_at": "Enviada",
|
|
107
|
+
"forms.responses.respondent": "Participante",
|
|
108
|
+
"forms.responses.anonymous": "Anónimo",
|
|
109
|
+
"forms.responses.version": "Versión",
|
|
110
|
+
"forms.responses.all_versions": "Todas las versiones",
|
|
111
|
+
"forms.responses.next": "Siguiente",
|
|
112
|
+
"forms.responses.prev": "Anterior",
|
|
113
|
+
"forms.responses.blocked.at_end": "Esta es la última página.",
|
|
114
|
+
"forms.responses.blocked.at_start": "Esta es la primera página.",
|
|
115
|
+
"forms.responses.delete": "Eliminar",
|
|
116
|
+
"forms.responses.delete_confirm": "¿Eliminar esta respuesta de forma permanente?",
|
|
117
|
+
"forms.responses.resend": "Reenviar",
|
|
118
|
+
"forms.responses.resend_sent": "Enviado a {count} destino(s).",
|
|
119
|
+
"forms.responses.resend_override": "Enviar a direcciones concretas",
|
|
120
|
+
"forms.responses.resend_override_hint":
|
|
121
|
+
"Sustituyen a los destinatarios configurados del formulario para este envío.",
|
|
122
|
+
"forms.responses.export": "Exportar CSV",
|
|
123
|
+
"forms.responses.exporting": "Exportando… ({pages} página(s))",
|
|
124
|
+
"forms.responses.erased": "Borrada",
|
|
125
|
+
"forms.responses.detail": "Detalle de la respuesta",
|
|
126
|
+
"forms.responses.close": "Cerrar",
|
|
127
|
+
|
|
128
|
+
"forms.list.title": "Formularios",
|
|
129
|
+
"forms.list.empty": "Todavía no hay formularios en este espacio de trabajo.",
|
|
130
|
+
"forms.list.load_failed": "No pudimos cargar los formularios.",
|
|
131
|
+
"forms.list.create": "Nuevo formulario",
|
|
132
|
+
"forms.list.new_title": "Formulario sin título",
|
|
133
|
+
"forms.list.submission_count": "{count} respuesta(s)",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Register the es bundle. Call AFTER `registerFormsI18n` so it layers over
|
|
137
|
+
* the en floor (merge priority = registration order). */
|
|
138
|
+
export function registerFormsI18nEs(engine: I18nEngine): void {
|
|
139
|
+
engine.registerBundle("es", formsI18nBundleEs);
|
|
140
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/gen-errors.mjs — do not edit by hand.
|
|
2
|
+
// Source: stapel-auth canonical errors.json (backend codegen artifact).
|
|
3
|
+
// Regenerate: pnpm gen:errors · Drift gate: pnpm gen:errors:check
|
|
4
|
+
// Locale source: backend translations/errors.es.json (locale catalog
|
|
5
|
+
// beside the canonical errors.json — i18n-shipping.md §1).
|
|
6
|
+
import type { FormsErrorCode } from "./errors.gen.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `es` texts for the backend error codes this catalog carries.
|
|
10
|
+
*
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 12 key(s) owned by
|
|
12
|
+
* stapel_attributes are absent, because that owner ships no locale catalog
|
|
13
|
+
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
|
+
* artifact via the en bundle; the pair layers its own authored `es`
|
|
15
|
+
* strings over this one until upstream ships translations. `Partial` is what
|
|
16
|
+
* makes the gap visible to TypeScript instead of a silent English fallback.
|
|
17
|
+
*
|
|
18
|
+
* Import from the pair's `./i18n/es` subpath, NOT the main entry, so
|
|
19
|
+
* the locale stays out of hosts that don't ship it.
|
|
20
|
+
*/
|
|
21
|
+
export const formsErrorBundleEs: Partial<Record<FormsErrorCode, string>> = {
|
|
22
|
+
"error.400.bad_request": "Solicitud incorrecta",
|
|
23
|
+
"error.400.captcha_invalid": "La verificación del captcha ha fallado. Inténtalo de nuevo.",
|
|
24
|
+
"error.400.captcha_required": "Se requiere el token del captcha.",
|
|
25
|
+
"error.400.expected_list": "Se esperaba una lista de elementos",
|
|
26
|
+
"error.400.field.blank": "{field} no puede estar vacío",
|
|
27
|
+
"error.400.field.does_not_exist": "{field} no existe",
|
|
28
|
+
"error.400.field.invalid": "{field} no es válido",
|
|
29
|
+
"error.400.field.invalid_choice": "{field} no es una opción válida",
|
|
30
|
+
"error.400.field.max_length": "{field} debe tener como máximo {max_length} caracteres",
|
|
31
|
+
"error.400.field.max_value": "{field} debe ser como máximo {max_value}",
|
|
32
|
+
"error.400.field.min_length": "{field} debe tener al menos {min_length} caracteres",
|
|
33
|
+
"error.400.field.min_value": "{field} debe ser como mínimo {min_value}",
|
|
34
|
+
"error.400.field.null": "{field} no puede ser nulo",
|
|
35
|
+
"error.400.field.required": "{field} es obligatorio",
|
|
36
|
+
"error.400.field.unique": "{field} debe ser único",
|
|
37
|
+
"error.400.forms_answers_not_object": "Las respuestas deben ser un objeto indexado por identificador de campo",
|
|
38
|
+
"error.400.forms_duplicate_slug": "El identificador de campo {slug} aparece más de una vez",
|
|
39
|
+
"error.400.forms_empty_schema": "Un formulario debe tener al menos un campo para publicarse",
|
|
40
|
+
"error.400.forms_invalid_retention": "La retención por formulario solo puede acortar el periodo del módulo",
|
|
41
|
+
"error.400.forms_invalid_schema": "El esquema del formulario no es válido",
|
|
42
|
+
"error.400.forms_invalid_state": "Estado de formulario desconocido",
|
|
43
|
+
"error.400.forms_kind_not_allowed": "El tipo de campo {kind} no puede usarse en un formulario",
|
|
44
|
+
"error.400.forms_no_draft": "No hay ningún borrador que publicar",
|
|
45
|
+
"error.400.forms_no_recipients": "Este formulario no tiene ningún destinatario de notificaciones configurado",
|
|
46
|
+
"error.400.forms_not_published": "El formulario aún no tiene ninguna versión publicada",
|
|
47
|
+
"error.400.forms_too_many_fields": "El formulario tiene más campos de los que permite el límite",
|
|
48
|
+
"error.400.forms_too_many_open": "Este espacio de trabajo ya tiene el número máximo de formularios abiertos",
|
|
49
|
+
"error.400.forms_unknown_field": "La respuesta contiene un campo que el formulario no tiene",
|
|
50
|
+
"error.400.invalid_ad_id": "ID de anuncio no válido",
|
|
51
|
+
"error.400.validation_error": "Error de validación",
|
|
52
|
+
"error.400.verification_failed": "La verificación ha fallado",
|
|
53
|
+
"error.400.verification_invalid_factor": "Este factor de verificación no está disponible",
|
|
54
|
+
"error.401.unauthorized": "Se requiere autenticación",
|
|
55
|
+
"error.402.payment_required": "Se requiere pago",
|
|
56
|
+
"error.403.forbidden": "No tienes permiso para realizar esta acción",
|
|
57
|
+
"error.403.forms_forbidden": "No tienes acceso a este formulario",
|
|
58
|
+
"error.403.network_blocked": "No se permiten solicitudes desde esta red.",
|
|
59
|
+
"error.403.verification_enrollment_required": "Es necesario registrar un factor de verificación.",
|
|
60
|
+
"error.403.verification_required": "Se requiere verificación adicional",
|
|
61
|
+
"error.404.ad_not_found": "Anuncio no encontrado",
|
|
62
|
+
"error.404.forms_not_found": "Formulario no encontrado",
|
|
63
|
+
"error.404.forms_submission_not_found": "Respuesta no encontrada",
|
|
64
|
+
"error.404.not_found": "Recurso solicitado no encontrado",
|
|
65
|
+
"error.404.verification_challenge_not_found": "Desafío de verificación no encontrado o caducado",
|
|
66
|
+
"error.405.method_not_allowed": "Método no permitido",
|
|
67
|
+
"error.406.not_acceptable": "No aceptable",
|
|
68
|
+
"error.408.request_timeout": "Tiempo de espera de la solicitud agotado",
|
|
69
|
+
"error.409.conflict": "El recurso ya existe",
|
|
70
|
+
"error.409.forms_submission_cap": "Este formulario ha alcanzado su límite de respuestas",
|
|
71
|
+
"error.409.forms_version_superseded": "El formulario cambió mientras lo rellenabas",
|
|
72
|
+
"error.410.forms_closed": "Este formulario está cerrado",
|
|
73
|
+
"error.410.gone": "El recurso se ha eliminado permanentemente",
|
|
74
|
+
"error.413.forms_body_too_large": "La respuesta supera el tamaño máximo",
|
|
75
|
+
"error.413.payload_too_large": "El cuerpo de la solicitud es demasiado grande",
|
|
76
|
+
"error.415.unsupported_media_type": "Tipo de contenido no compatible",
|
|
77
|
+
"error.422.unprocessable_entity": "Entidad no procesable",
|
|
78
|
+
"error.423.locked": "El recurso está bloqueado",
|
|
79
|
+
"error.423.verification_locked": "Demasiados intentos fallidos — verificación bloqueada",
|
|
80
|
+
"error.429.rate_limit": "Demasiados intentos. Inténtalo de nuevo en {retry_after_minutes} minutos.",
|
|
81
|
+
"error.429.too_many_requests": "Demasiadas solicitudes. Inténtalo de nuevo más tarde.",
|
|
82
|
+
"error.500.internal": "Algo salió mal",
|
|
83
|
+
"error.503.forms_workspaces_unavailable": "El servicio de espacios de trabajo no está disponible",
|
|
84
|
+
"error.503.mandate_unavailable": "No se puede verificar el mandato del espacio de trabajo",
|
|
85
|
+
};
|