create-ailk 0.18.0 → 0.19.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/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- package/templates/database/__tests__/build.test.ts +0 -131
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The artifact review (#8234, spec 8234 D6, D7; AC-5, AC-6).
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* - `submitArtifactReview` (the Server Action), with the api-client's
|
|
6
|
+
* `member.reviewArtifact` as the one mocked boundary: the exact body it
|
|
7
|
+
* posts for each verdict, the comment check before any call, each
|
|
8
|
+
* refusal's reason, and logs that never carry the comment or the id.
|
|
9
|
+
* - `ReviewForm` (the client form) over the real action, spied on to read
|
|
10
|
+
* the `FormData` each button sends: the verdict and the comment, a
|
|
11
|
+
* refusal that keeps the typed comment, and a sent review that refreshes
|
|
12
|
+
* the page once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
act,
|
|
17
|
+
fireEvent,
|
|
18
|
+
render,
|
|
19
|
+
screen,
|
|
20
|
+
waitFor,
|
|
21
|
+
} from "@testing-library/react";
|
|
22
|
+
|
|
23
|
+
const mockReviewArtifact = jest.fn();
|
|
24
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
member: {
|
|
27
|
+
reviewArtifact: (...args: unknown[]) => mockReviewArtifact(...args),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
jest.mock("next/headers", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
headers: jest
|
|
34
|
+
.fn()
|
|
35
|
+
.mockResolvedValue(new Headers({ origin: "https://site.example.com" })),
|
|
36
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const mockRefresh = jest.fn();
|
|
40
|
+
jest.mock("next/navigation", () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import messages from "../../../../../../messages/en.json";
|
|
46
|
+
import { ReviewForm, type ReviewFormLabels } from "../[pieceId]/review-form";
|
|
47
|
+
import * as actions from "../artifact-actions";
|
|
48
|
+
import { submitArtifactReview } from "../artifact-actions";
|
|
49
|
+
|
|
50
|
+
const PIECE = "piece_01920000-0000-7000-8000-000000000001";
|
|
51
|
+
const INIT = {
|
|
52
|
+
headers: { cookie: "session=abc", origin: "https://site.example.com" },
|
|
53
|
+
};
|
|
54
|
+
const COMMENT = "Tighten the logo";
|
|
55
|
+
|
|
56
|
+
function form(fields: Record<string, string>): FormData {
|
|
57
|
+
const data = new FormData();
|
|
58
|
+
for (const [key, value] of Object.entries(fields)) data.set(key, value);
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function review(
|
|
63
|
+
fields: Partial<Record<"pieceId" | "version" | "review" | "comment", string>>,
|
|
64
|
+
) {
|
|
65
|
+
return submitArtifactReview(
|
|
66
|
+
{ kind: "idle" },
|
|
67
|
+
form({
|
|
68
|
+
pieceId: PIECE,
|
|
69
|
+
version: "3",
|
|
70
|
+
review: "approved",
|
|
71
|
+
comment: "",
|
|
72
|
+
...fields,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function refusal(status: number, code: string) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
status,
|
|
81
|
+
body: { ok: false, error: { code, message: "x" } },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let errorSpy: jest.SpyInstance;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
mockReviewArtifact
|
|
89
|
+
.mockReset()
|
|
90
|
+
.mockResolvedValue({ ok: true, body: { artifact: {} } });
|
|
91
|
+
mockRefresh.mockReset();
|
|
92
|
+
errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
errorSpy.mockRestore();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("submitArtifactReview (D6, AC-5)", () => {
|
|
100
|
+
it("Approve posts { version, review } once with the caller's init and answers reviewed", async () => {
|
|
101
|
+
await expect(review({})).resolves.toEqual({ kind: "reviewed" });
|
|
102
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(mockReviewArtifact).toHaveBeenCalledWith(
|
|
104
|
+
PIECE,
|
|
105
|
+
{ version: 3, review: "approved" },
|
|
106
|
+
INIT,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("Request changes posts the trimmed comment and no other key", async () => {
|
|
111
|
+
await expect(
|
|
112
|
+
review({ review: "changes-requested", comment: ` ${COMMENT} ` }),
|
|
113
|
+
).resolves.toEqual({ kind: "reviewed" });
|
|
114
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(mockReviewArtifact.mock.calls[0]![1]).toStrictEqual({
|
|
116
|
+
version: 3,
|
|
117
|
+
review: "changes-requested",
|
|
118
|
+
comment: COMMENT,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("an Approve with a comment carries it", async () => {
|
|
123
|
+
await review({ comment: "Lovely" });
|
|
124
|
+
expect(mockReviewArtifact.mock.calls[0]![1]).toStrictEqual({
|
|
125
|
+
version: 3,
|
|
126
|
+
review: "approved",
|
|
127
|
+
comment: "Lovely",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each(["", " "])(
|
|
132
|
+
"Request changes with a blank comment (%j) is comment_required with no call",
|
|
133
|
+
async (comment) => {
|
|
134
|
+
await expect(
|
|
135
|
+
review({ review: "changes-requested", comment }),
|
|
136
|
+
).resolves.toEqual({
|
|
137
|
+
kind: "error",
|
|
138
|
+
reason: "comment_required",
|
|
139
|
+
});
|
|
140
|
+
expect(mockReviewArtifact).not.toHaveBeenCalled();
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it("Request changes with a comment the server would strip to nothing is comment_required with no call", async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
review({ review: "changes-requested", comment: "\u200e\u202e" }),
|
|
147
|
+
).resolves.toEqual({ kind: "error", reason: "comment_required" });
|
|
148
|
+
expect(mockReviewArtifact).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it.each([
|
|
152
|
+
["a malformed piece id", { pieceId: "x" }],
|
|
153
|
+
["a version that is not a whole number", { version: "abc" }],
|
|
154
|
+
["an unknown verdict", { review: "rejected" }],
|
|
155
|
+
])("%s is failed with no call", async (_case, fields) => {
|
|
156
|
+
await expect(review(fields)).resolves.toEqual({
|
|
157
|
+
kind: "error",
|
|
158
|
+
reason: "failed",
|
|
159
|
+
});
|
|
160
|
+
expect(mockReviewArtifact).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it.each([
|
|
164
|
+
["review_closed", refusal(409, "review_closed"), "closed"],
|
|
165
|
+
["version_not_latest", refusal(409, "version_not_latest"), "not_latest"],
|
|
166
|
+
["too_many_requests", refusal(429, "too_many_requests"), "rate_limited"],
|
|
167
|
+
["a 429 rate_limited", refusal(429, "rate_limited"), "rate_limited"],
|
|
168
|
+
["invalid_input (400)", refusal(400, "invalid_input"), "failed"],
|
|
169
|
+
["a status-0 refusal", refusal(0, "unauthorized"), "failed"],
|
|
170
|
+
])("a %s refusal is %s", async (_case, res, reason) => {
|
|
171
|
+
mockReviewArtifact.mockResolvedValue(res);
|
|
172
|
+
await expect(
|
|
173
|
+
review({ review: "changes-requested", comment: COMMENT }),
|
|
174
|
+
).resolves.toEqual({
|
|
175
|
+
kind: "error",
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("a throw is failed, logged by err.name only — never the comment or the piece id", async () => {
|
|
182
|
+
mockReviewArtifact.mockRejectedValue(
|
|
183
|
+
new TypeError(`fetch failed ${COMMENT} ${PIECE}`),
|
|
184
|
+
);
|
|
185
|
+
await expect(
|
|
186
|
+
review({ review: "changes-requested", comment: COMMENT }),
|
|
187
|
+
).resolves.toEqual({
|
|
188
|
+
kind: "error",
|
|
189
|
+
reason: "failed",
|
|
190
|
+
});
|
|
191
|
+
expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
193
|
+
"[account] review failed errName=TypeError",
|
|
194
|
+
);
|
|
195
|
+
const logged = JSON.stringify(errorSpy.mock.calls);
|
|
196
|
+
expect(logged).not.toContain(COMMENT);
|
|
197
|
+
expect(logged).not.toContain(PIECE);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const r = messages.account.artifacts.review;
|
|
202
|
+
const LABELS: ReviewFormLabels = {
|
|
203
|
+
heading: r.heading.replace("{version}", "3"),
|
|
204
|
+
comment: r.comment,
|
|
205
|
+
commentHint: r.commentHint,
|
|
206
|
+
approve: r.approve,
|
|
207
|
+
requestChanges: r.requestChanges,
|
|
208
|
+
submitting: r.submitting,
|
|
209
|
+
sent: r.sent,
|
|
210
|
+
errors: {
|
|
211
|
+
comment_required: r.errors.commentRequired,
|
|
212
|
+
closed: r.errors.closed,
|
|
213
|
+
not_latest: r.errors.notLatest,
|
|
214
|
+
rate_limited: r.errors.rateLimited,
|
|
215
|
+
failed: r.errors.failed,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
describe("ReviewForm (D7, AC-6)", () => {
|
|
220
|
+
// A pass-through spy: the real action runs; the spy reads what it was sent.
|
|
221
|
+
let submitSpy: jest.SpyInstance;
|
|
222
|
+
|
|
223
|
+
beforeEach(() => {
|
|
224
|
+
submitSpy = jest.spyOn(actions, "submitArtifactReview");
|
|
225
|
+
render(<ReviewForm pieceId={PIECE} version={3} labels={LABELS} />);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
submitSpy.mockRestore();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
function sent(): FormData {
|
|
233
|
+
expect(submitSpy).toHaveBeenCalledTimes(1);
|
|
234
|
+
return submitSpy.mock.calls[0]![1] as FormData;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
it("Request changes sends the verdict, the comment, the piece and the version; a refusal shows its message and keeps the comment", async () => {
|
|
238
|
+
mockReviewArtifact.mockResolvedValue(refusal(409, "review_closed"));
|
|
239
|
+
fireEvent.change(screen.getByTestId("artifact-review-comment"), {
|
|
240
|
+
target: { value: COMMENT },
|
|
241
|
+
});
|
|
242
|
+
await act(async () => {
|
|
243
|
+
fireEvent.click(screen.getByTestId("artifact-review-request-changes"));
|
|
244
|
+
});
|
|
245
|
+
const data = sent();
|
|
246
|
+
expect(data.get("review")).toBe("changes-requested");
|
|
247
|
+
expect(data.get("comment")).toBe(COMMENT);
|
|
248
|
+
expect(data.get("pieceId")).toBe(PIECE);
|
|
249
|
+
expect(data.get("version")).toBe("3");
|
|
250
|
+
await waitFor(() =>
|
|
251
|
+
expect(screen.getByTestId("artifact-review-error")).toHaveTextContent(
|
|
252
|
+
"This version can't be reviewed now. It may already be approved.",
|
|
253
|
+
),
|
|
254
|
+
);
|
|
255
|
+
expect(screen.getByTestId("artifact-review-comment")).toHaveValue(COMMENT);
|
|
256
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("Approve sends the approved verdict; a sent review shows the thanks line and refreshes once", async () => {
|
|
260
|
+
await act(async () => {
|
|
261
|
+
fireEvent.click(screen.getByTestId("artifact-review-approve"));
|
|
262
|
+
});
|
|
263
|
+
expect(sent().get("review")).toBe("approved");
|
|
264
|
+
expect(mockReviewArtifact).toHaveBeenCalledWith(
|
|
265
|
+
PIECE,
|
|
266
|
+
{ version: 3, review: "approved" },
|
|
267
|
+
INIT,
|
|
268
|
+
);
|
|
269
|
+
await waitFor(() =>
|
|
270
|
+
expect(screen.getByTestId("artifact-review-sent")).toHaveTextContent(
|
|
271
|
+
"Thanks — your review was sent.",
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("labels the comment box, and gives both buttons a 44px phone target", () => {
|
|
278
|
+
expect(screen.getByLabelText("Comment")).toBe(
|
|
279
|
+
screen.getByTestId("artifact-review-comment"),
|
|
280
|
+
);
|
|
281
|
+
expect(screen.getByTestId("artifact-review-approve")).toHaveClass(
|
|
282
|
+
"max-md:min-h-11",
|
|
283
|
+
);
|
|
284
|
+
expect(screen.getByTestId("artifact-review-request-changes")).toHaveClass(
|
|
285
|
+
"max-md:min-h-11",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* artifacts/artifact-actions.ts — the review write (#8234, spec
|
|
5
|
+
* docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D6; the
|
|
6
|
+
* write is #8170's `POST /v1/member/artifacts/:pieceId/review`).
|
|
7
|
+
*
|
|
8
|
+
* One export: `submitArtifactReview`, the review form's action. It posts
|
|
9
|
+
* Approve or Request changes on a piece's latest version through
|
|
10
|
+
* `member.reviewArtifact` with `forwardedCookieInit()`'s init as built — the
|
|
11
|
+
* session cookie plus the request's `Origin` on a single site, the hub
|
|
12
|
+
* credential on a hub-mode spoke — so nothing here tests the topology. The
|
|
13
|
+
* body is the strict `MemberReviewBody` (`version`, `review`, `comment`), so
|
|
14
|
+
* no title, stage, note or link can be posted (AC-MAA-05). The posted
|
|
15
|
+
* `pieceId` and `version` are untrusted: they are shape-checked here, and the
|
|
16
|
+
* API scopes them to the Person the credential resolves.
|
|
17
|
+
*
|
|
18
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
|
|
19
|
+
* parity test reads that suffix, and the member writes have no MCP twin by
|
|
20
|
+
* decision (8170's header). The reads the pages share live in
|
|
21
|
+
* `artifact-data.ts`, never here — every export of this module is a POST
|
|
22
|
+
* endpoint. A log line carries `err.name` only — never the comment or an id.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { member } from "@working-theory/api-client";
|
|
26
|
+
import {
|
|
27
|
+
memberReviewBodySchema,
|
|
28
|
+
memberReviewParamsSchema,
|
|
29
|
+
type MemberReviewBody,
|
|
30
|
+
} from "@working-theory/validation";
|
|
31
|
+
|
|
32
|
+
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
33
|
+
|
|
34
|
+
/** What the review form shows after a submit. */
|
|
35
|
+
export type ArtifactReviewState =
|
|
36
|
+
| { kind: "idle" }
|
|
37
|
+
| { kind: "reviewed" }
|
|
38
|
+
| {
|
|
39
|
+
kind: "error";
|
|
40
|
+
reason:
|
|
41
|
+
| "comment_required"
|
|
42
|
+
| "closed"
|
|
43
|
+
| "not_latest"
|
|
44
|
+
| "rate_limited"
|
|
45
|
+
| "failed";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ArtifactReviewReason = Extract<
|
|
49
|
+
ArtifactReviewState,
|
|
50
|
+
{ kind: "error" }
|
|
51
|
+
>["reason"];
|
|
52
|
+
|
|
53
|
+
function error(reason: ArtifactReviewReason): ArtifactReviewState {
|
|
54
|
+
return { kind: "error", reason };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A form field as a string; a missing or file field is empty. */
|
|
58
|
+
function field(formData: FormData, name: string): string {
|
|
59
|
+
const value = formData.get(name);
|
|
60
|
+
return typeof value === "string" ? value : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The refusal's reason, from the api-client's `{ ok: false, status, body }`. */
|
|
64
|
+
function refusalReason(status: number, body: unknown): ArtifactReviewReason {
|
|
65
|
+
const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
|
|
66
|
+
if (code === "review_closed") return "closed";
|
|
67
|
+
if (code === "version_not_latest") return "not_latest";
|
|
68
|
+
// The route's own budget answers `too_many_requests`; the hub's
|
|
69
|
+
// per-person budget and the per-IP limiter answer `429 rate_limited`.
|
|
70
|
+
if (code === "too_many_requests" || status === 429) return "rate_limited";
|
|
71
|
+
return "failed";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Approve, or request changes with a comment, on a piece's latest version. */
|
|
75
|
+
export async function submitArtifactReview(
|
|
76
|
+
_prev: ArtifactReviewState,
|
|
77
|
+
formData: FormData,
|
|
78
|
+
): Promise<ArtifactReviewState> {
|
|
79
|
+
const pieceId = field(formData, "pieceId");
|
|
80
|
+
const review = field(formData, "review");
|
|
81
|
+
const comment = field(formData, "comment").trim();
|
|
82
|
+
|
|
83
|
+
if (review === "changes-requested" && comment === "")
|
|
84
|
+
return error("comment_required");
|
|
85
|
+
|
|
86
|
+
const candidate = {
|
|
87
|
+
version: Number(field(formData, "version")),
|
|
88
|
+
review,
|
|
89
|
+
...(comment !== "" ? { comment } : {}),
|
|
90
|
+
};
|
|
91
|
+
if (!memberReviewParamsSchema.safeParse({ pieceId }).success)
|
|
92
|
+
return error("failed");
|
|
93
|
+
const parsed = memberReviewBodySchema.safeParse(candidate);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
// A comment of control or bidi characters only is stripped to nothing
|
|
96
|
+
// by the server's `plainText`; to the person that is a missing comment.
|
|
97
|
+
const onComment = parsed.error.issues.some(
|
|
98
|
+
(issue) => issue.path[0] === "comment",
|
|
99
|
+
);
|
|
100
|
+
return error(
|
|
101
|
+
review === "changes-requested" && onComment
|
|
102
|
+
? "comment_required"
|
|
103
|
+
: "failed",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const body: MemberReviewBody = parsed.data;
|
|
107
|
+
|
|
108
|
+
let res: Awaited<ReturnType<typeof member.reviewArtifact>>;
|
|
109
|
+
try {
|
|
110
|
+
res = await member.reviewArtifact(
|
|
111
|
+
pieceId,
|
|
112
|
+
body,
|
|
113
|
+
await forwardedCookieInit(),
|
|
114
|
+
);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(
|
|
117
|
+
`[account] review failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
118
|
+
);
|
|
119
|
+
return error("failed");
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok) return error(refusalReason(res.status, res.body));
|
|
122
|
+
return { kind: "reviewed" };
|
|
123
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifacts/artifact-data.ts — the Artifacts pages' read and its helpers
|
|
3
|
+
* (#8234, spec docs/specs/8234-member-artifacts-account-artifacts-and-spec.md
|
|
4
|
+
* D2, C6).
|
|
5
|
+
*
|
|
6
|
+
* The list and the artifact page read the same artifacts, so the read lives
|
|
7
|
+
* here — not in `artifact-actions.ts`, where every export of a
|
|
8
|
+
* `"use server"` module is a POST endpoint, and not in
|
|
9
|
+
* `lib/account-config.ts`, whose `read()` stays private. The account area's
|
|
10
|
+
* read rule is restated: a 404 (no tenancy module, or a spoke route the hub
|
|
11
|
+
* does not admit) is "nothing delivered"; a 401 or 403 is the same while the
|
|
12
|
+
* person's email is unverified; anything else throws, so the page shows its
|
|
13
|
+
* error row with Retry (spec 7423 D12), never an empty list.
|
|
14
|
+
*
|
|
15
|
+
* The read serves versions newest first and at least one (8169 D6), so
|
|
16
|
+
* `versions[0]` is the latest version. Items keep the read's order — newest
|
|
17
|
+
* delivery first — and are never re-sorted here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { member } from "@working-theory/api-client";
|
|
21
|
+
import type { AccountContext } from "@working-theory/templates/account";
|
|
22
|
+
import { localeHref } from "@working-theory/ui";
|
|
23
|
+
import type { MemberArtifact } from "@working-theory/validation";
|
|
24
|
+
import { cache } from "react";
|
|
25
|
+
|
|
26
|
+
import { unwrapApiResult } from "../../../../../lib/server-api";
|
|
27
|
+
|
|
28
|
+
/** The member's artifacts, newest delivery first. One read per request. */
|
|
29
|
+
export const loadArtifacts = cache(
|
|
30
|
+
async (ctx: AccountContext): Promise<MemberArtifact[]> => {
|
|
31
|
+
const res = await member.artifacts(ctx.init);
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
if (res.status === 404) return [];
|
|
34
|
+
if (!ctx.user.emailVerified && (res.status === 401 || res.status === 403))
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return (await unwrapApiResult(Promise.resolve(res))).items;
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export type ArtifactState = "approved" | "changes-requested" | "ready";
|
|
42
|
+
|
|
43
|
+
/** The `account` catalog key that names each review state. */
|
|
44
|
+
export const STATE_KEYS = {
|
|
45
|
+
approved: "artifacts.states.approved",
|
|
46
|
+
"changes-requested": "artifacts.states.changesRequested",
|
|
47
|
+
ready: "artifacts.states.ready",
|
|
48
|
+
} as const satisfies Record<ArtifactState, string>;
|
|
49
|
+
|
|
50
|
+
/** The artifact's review state, from its approval and its latest version. */
|
|
51
|
+
export function artifactState(a: MemberArtifact): ArtifactState {
|
|
52
|
+
if (a.approvedAt !== null) return "approved";
|
|
53
|
+
return a.versions[0]?.review === "changes-requested"
|
|
54
|
+
? "changes-requested"
|
|
55
|
+
: "ready";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True while the latest version can be reviewed: the artifact is not
|
|
60
|
+
* approved and that version has no review yet (8170 C7 — a reviewed version
|
|
61
|
+
* is `review_closed` until the provider delivers the next one).
|
|
62
|
+
*/
|
|
63
|
+
export function isReviewable(a: MemberArtifact): boolean {
|
|
64
|
+
return a.approvedAt === null && a.versions[0]?.review === null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The artifact page of one piece. */
|
|
68
|
+
export function artifactHref(locale: string, pieceId: string): string {
|
|
69
|
+
return localeHref(
|
|
70
|
+
locale,
|
|
71
|
+
`/account/artifacts/${encodeURIComponent(pieceId)}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The download route of one version's file. */
|
|
76
|
+
export function versionFileHref(
|
|
77
|
+
locale: string,
|
|
78
|
+
pieceId: string,
|
|
79
|
+
version: number,
|
|
80
|
+
): string {
|
|
81
|
+
return `${artifactHref(locale, pieceId)}/versions/${version}/file`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/artifacts — Library › Artifacts (#8338, spec
|
|
3
|
+
* docs/specs/8338-account-artifacts-structured-like-the-spec.md D3; D3
|
|
4
|
+
* supersedes #8234's D3, which rendered the flat Inbox-style list.
|
|
5
|
+
*
|
|
6
|
+
* RSC, inside the account layout's SignedInShell (the rail's Artifacts row,
|
|
7
|
+
* #8231 D1, is selected here). Renders `AccountListFrame` — the Admin Docs
|
|
8
|
+
* page's structure: a second column with an "All artifacts" count and a
|
|
9
|
+
* Review facet group (Ready for review · Changes requested · Approved),
|
|
10
|
+
* each counted over every artifact with zeros kept, and a table of Title ·
|
|
11
|
+
* Latest · Delivered · Review in the read's order — newest delivery first.
|
|
12
|
+
* Below `md` the column becomes a filter select and the table becomes the
|
|
13
|
+
* phone row list.
|
|
14
|
+
*
|
|
15
|
+
* A 404 read (no tenancy module, or a spoke route the hub does not admit)
|
|
16
|
+
* shows the frame with every count at 0 and the plain empty line; any other
|
|
17
|
+
* failed read renders today's error row with Retry and no frame (spec 7423
|
|
18
|
+
* D12). Nothing here tests the topology: the same file serves a single site
|
|
19
|
+
* and a hub-mode spoke.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
AccountListFrame,
|
|
24
|
+
type AccountListFrameRow,
|
|
25
|
+
} from "@working-theory/templates/account";
|
|
26
|
+
import { Heading, Text } from "@working-theory/ui/primitives";
|
|
27
|
+
import type { MemberArtifact } from "@working-theory/validation";
|
|
28
|
+
import type { Metadata } from "next";
|
|
29
|
+
import { getTranslations } from "next-intl/server";
|
|
30
|
+
import type { ReactElement } from "react";
|
|
31
|
+
|
|
32
|
+
import { accountSession, logReadFailure } from "../account-data";
|
|
33
|
+
import { ErrorRow } from "../error-row";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
artifactHref,
|
|
37
|
+
artifactState,
|
|
38
|
+
loadArtifacts,
|
|
39
|
+
STATE_KEYS,
|
|
40
|
+
type ArtifactState,
|
|
41
|
+
} from "./artifact-data";
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
params: Promise<{ locale: string }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const REVIEW_FACETS: Array<{
|
|
48
|
+
state: ArtifactState;
|
|
49
|
+
glyph: "circle-dashed" | "message-square" | "circle-check";
|
|
50
|
+
}> = [
|
|
51
|
+
{ state: "ready", glyph: "circle-dashed" },
|
|
52
|
+
{ state: "changes-requested", glyph: "message-square" },
|
|
53
|
+
{ state: "approved", glyph: "circle-check" },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
57
|
+
const { locale } = await params;
|
|
58
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
59
|
+
return { title: t("artifacts.title") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function AccountArtifactsPage({
|
|
63
|
+
params,
|
|
64
|
+
}: Props): Promise<ReactElement> {
|
|
65
|
+
const { locale } = await params;
|
|
66
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
67
|
+
const session = await accountSession(locale);
|
|
68
|
+
|
|
69
|
+
const heading = (
|
|
70
|
+
<>
|
|
71
|
+
<Heading level={1} visual={2}>
|
|
72
|
+
{t("artifacts.title")}
|
|
73
|
+
</Heading>
|
|
74
|
+
<Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
|
|
75
|
+
{t("artifacts.lead")}
|
|
76
|
+
</Text>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Unreachable behind the (authed) gate — the layout renders the sign-in
|
|
81
|
+
// gate instead — but the page still renders in parallel with it, so a
|
|
82
|
+
// `notFound()` here streams a 404 digest over the gate (the scaffold render
|
|
83
|
+
// sweep's `/account/artifacts -> streamed not-found`). Mirror
|
|
84
|
+
// `../general/page.tsx`: the heading alone.
|
|
85
|
+
if (!session) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="max-w-3xl py-8" data-testid="artifacts">
|
|
88
|
+
{heading}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let artifacts: MemberArtifact[] | null;
|
|
94
|
+
try {
|
|
95
|
+
artifacts = await loadArtifacts(session.ctx);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logReadFailure("artifacts", err);
|
|
98
|
+
artifacts = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (artifacts === null) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="max-w-3xl py-8" data-testid="artifacts">
|
|
104
|
+
{heading}
|
|
105
|
+
<ErrorRow
|
|
106
|
+
className="mt-block-loose"
|
|
107
|
+
message={t("artifacts.error")}
|
|
108
|
+
retryLabel={t("artifacts.retry")}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const row = (artifact: MemberArtifact): AccountListFrameRow => {
|
|
115
|
+
const state = artifactState(artifact);
|
|
116
|
+
const latest = artifact.versions[0]!;
|
|
117
|
+
const delivered = new Intl.DateTimeFormat(locale, {
|
|
118
|
+
dateStyle: "medium",
|
|
119
|
+
timeZone: "UTC",
|
|
120
|
+
}).format(new Date(latest.deliveredAt));
|
|
121
|
+
const reviewWord = t(STATE_KEYS[state]);
|
|
122
|
+
return {
|
|
123
|
+
id: artifact.id,
|
|
124
|
+
title: artifact.title,
|
|
125
|
+
href: artifactHref(locale, artifact.id),
|
|
126
|
+
facet: state,
|
|
127
|
+
caption: `${t("artifacts.version", { version: latest.version })} · ${reviewWord}`,
|
|
128
|
+
cells: {
|
|
129
|
+
latest: t("artifacts.version", { version: latest.version }),
|
|
130
|
+
delivered,
|
|
131
|
+
review: reviewWord,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<AccountListFrame
|
|
138
|
+
title={t("artifacts.title")}
|
|
139
|
+
lead={t("artifacts.lead")}
|
|
140
|
+
glyph="layers"
|
|
141
|
+
all={{ label: t("artifacts.all") }}
|
|
142
|
+
group={{
|
|
143
|
+
heading: t("artifacts.reviewGroup"),
|
|
144
|
+
facets: REVIEW_FACETS.map(({ state, glyph }) => ({
|
|
145
|
+
id: state,
|
|
146
|
+
label: t(STATE_KEYS[state]),
|
|
147
|
+
glyph,
|
|
148
|
+
})),
|
|
149
|
+
}}
|
|
150
|
+
columns={[
|
|
151
|
+
{ key: "title", header: t("artifacts.columns.title") },
|
|
152
|
+
{ key: "latest", header: t("artifacts.columns.latest"), width: "1/6" },
|
|
153
|
+
{
|
|
154
|
+
key: "delivered",
|
|
155
|
+
header: t("artifacts.columns.delivered"),
|
|
156
|
+
width: "1/6",
|
|
157
|
+
minWidth: "28",
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
key: "review",
|
|
161
|
+
header: t("artifacts.columns.review"),
|
|
162
|
+
width: "1/6",
|
|
163
|
+
minWidth: "32",
|
|
164
|
+
},
|
|
165
|
+
]}
|
|
166
|
+
rows={artifacts.map(row)}
|
|
167
|
+
labels={{
|
|
168
|
+
nav: t("artifacts.facets"),
|
|
169
|
+
table: t("artifacts.title"),
|
|
170
|
+
filter: t("artifacts.filter"),
|
|
171
|
+
empty: t("artifacts.empty"),
|
|
172
|
+
facetEmpty: t("artifacts.facetEmpty"),
|
|
173
|
+
}}
|
|
174
|
+
testIdPrefix="artifacts"
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|