@unson/brainbase-mcp 0.6.0 → 0.8.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/contracts/foundation/graph-history.sql +634 -0
- package/contracts/judgment-value-proof/schema.json +43 -1
- package/dist/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +820 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +107 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +63 -0
- package/dist/company-os-objectives.js +234 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-authenticated-tools.d.ts +56 -0
- package/dist/foundation-authenticated-tools.js +347 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-graph-http.d.ts +43 -0
- package/dist/foundation-graph-http.js +243 -0
- package/dist/foundation-graph-write.d.ts +90 -0
- package/dist/foundation-graph-write.js +447 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-public-provider.d.ts +140 -0
- package/dist/foundation-public-provider.js +192 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/graph-corrections.d.ts +157 -0
- package/dist/graph-corrections.js +620 -0
- package/dist/graph-foundation-reader.d.ts +60 -0
- package/dist/graph-foundation-reader.js +494 -0
- package/dist/graph-retrieval.d.ts +6 -1
- package/dist/graph-revision-reader.d.ts +80 -0
- package/dist/graph-revision-reader.js +246 -0
- package/dist/graph-web-http.d.ts +26 -0
- package/dist/graph-web-http.js +215 -0
- package/dist/graph-web.d.ts +364 -0
- package/dist/graph-web.js +639 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +213 -0
- package/dist/judgment-problem-snapshot.js +1073 -0
- package/dist/judgment-value-proof-review.d.ts +166 -0
- package/dist/judgment-value-proof-review.js +437 -0
- package/dist/judgment-value-proof.d.ts +28 -1
- package/dist/judgment-value-proof.js +53 -8
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/knowledge-continuation.d.ts +127 -0
- package/dist/knowledge-continuation.js +446 -0
- package/dist/knowledge-lookup.d.ts +178 -0
- package/dist/knowledge-lookup.js +758 -0
- package/dist/local-web-fetch-bridge.d.ts +5 -0
- package/dist/local-web-fetch-bridge.js +37 -0
- package/dist/local-web-host.d.ts +109 -0
- package/dist/local-web-host.js +688 -0
- package/dist/local-web-security.d.ts +25 -0
- package/dist/local-web-security.js +95 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/organization-graph.d.ts +15 -0
- package/dist/organization-graph.js +24 -4
- package/dist/personal-knowledge.d.ts +132 -0
- package/dist/personal-knowledge.js +779 -0
- package/dist/personal-memory-handover.d.ts +108 -0
- package/dist/personal-memory-handover.js +369 -0
- package/dist/philosophy-revision-reader.d.ts +85 -0
- package/dist/philosophy-revision-reader.js +241 -0
- package/dist/portable-graph.d.ts +24 -0
- package/dist/portable-graph.js +77 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +104 -2
- package/dist/server.js +186 -4
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/value-proof-review-http.d.ts +25 -0
- package/dist/value-proof-review-http.js +185 -0
- package/dist/world-model.d.ts +199 -0
- package/dist/world-model.js +955 -0
- package/package.json +230 -3
- package/ui/brainbase-tokens.css +56 -0
- package/ui/graph-own-share.js +367 -0
- package/ui/graph-projects-view.css +23 -0
- package/ui/graph-projects-view.js +730 -0
- package/ui/graph-registry-view.css +101 -0
- package/ui/graph-registry-view.js +804 -0
- package/ui/graph-view-shared.css +522 -0
- package/ui/graph-view-shared.js +1227 -0
- package/ui/judgment-view.css +312 -0
- package/ui/judgment-view.js +508 -0
- package/ui/local-web-shell.css +396 -0
- package/ui/local-web-shell.js +378 -0
- package/ui/objective-editor-http-port.js +154 -0
- package/ui/objective-editor.css +286 -0
- package/ui/objective-editor.js +1586 -0
- package/ui/outcome-knowledge.css +50 -14
- package/ui/outcome-knowledge.js +15 -1
- package/ui/outcome-mana.css +91 -14
- package/ui/outcome-mana.js +958 -61
- package/ui/value-proof-review.css +504 -0
- package/ui/value-proof-review.js +1104 -0
- package/ui/workspace-kit.css +485 -0
- package/ui/workspace-kit.js +229 -0
- package/ui/world-model-view.css +188 -0
- package/ui/world-model-view.js +486 -0
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Brainbase Objective editor.
|
|
3
|
+
*
|
|
4
|
+
* The OSS package owns this screen and its browser-side state only. A host
|
|
5
|
+
* supplies the ObjectiveEditorPort below; the module never fetches a BFF,
|
|
6
|
+
* opens a database, or decides organization membership/RACI. An organization
|
|
7
|
+
* application can compose the same screen by adapting its authenticated
|
|
8
|
+
* FoundationRevisionStore boundary to the port.
|
|
9
|
+
*
|
|
10
|
+
* Two layouts: without `rail` the screen keeps its tabs (list, editor, Story
|
|
11
|
+
* links) as the organization edition composes it. With a `rail` element the
|
|
12
|
+
* screen follows the organization edition's pattern (workspace-kit): page
|
|
13
|
+
* head, metrics and a ledger of objectives in the workspace, and the selected
|
|
14
|
+
* objective (or its edit form) in the rail.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
workspaceActions,
|
|
19
|
+
workspaceButton,
|
|
20
|
+
workspaceDefinition,
|
|
21
|
+
workspaceDetailEmpty,
|
|
22
|
+
workspaceHostActions,
|
|
23
|
+
workspaceHostNotice,
|
|
24
|
+
workspaceLedger,
|
|
25
|
+
workspaceMetrics,
|
|
26
|
+
workspaceNotice,
|
|
27
|
+
workspacePageHeader,
|
|
28
|
+
workspaceRailBlock,
|
|
29
|
+
workspaceRailHead,
|
|
30
|
+
} from './workspace-kit.js';
|
|
31
|
+
|
|
32
|
+
export const OBJECTIVE_EDITOR_CONTRACT_VERSION = 'brainbase.objective-editor.v1';
|
|
33
|
+
|
|
34
|
+
export const OBJECTIVE_EDITOR_STORY_IDS = Object.freeze([
|
|
35
|
+
'story-company-os-objective-editor-v1',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export const OBJECTIVE_EDITOR_RELATION_TYPES = Object.freeze([
|
|
39
|
+
['contributes_to', '目的への貢献'],
|
|
40
|
+
['execution_depends_on', '実行の依存'],
|
|
41
|
+
['time_condition', '時間の条件'],
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const UNKNOWN = 'unknown';
|
|
45
|
+
|
|
46
|
+
const STATUS_LABELS = Object.freeze({
|
|
47
|
+
idle: '未取得',
|
|
48
|
+
loading: '読み込み中',
|
|
49
|
+
saving: '保存中',
|
|
50
|
+
ready: '取得済み',
|
|
51
|
+
empty: '登録なし',
|
|
52
|
+
draft: '下書き',
|
|
53
|
+
proposed: '提案中',
|
|
54
|
+
approved: '承認済み',
|
|
55
|
+
retired: '終了',
|
|
56
|
+
judgment_available: '判断に利用可能',
|
|
57
|
+
judgment_unknown: '判断利用可否 未確認',
|
|
58
|
+
permission_denied: '権限不足',
|
|
59
|
+
api_unavailable: 'API未提供',
|
|
60
|
+
missing: '欠損',
|
|
61
|
+
conflict: '版の競合',
|
|
62
|
+
saved_unverified: '保存済み・未確認',
|
|
63
|
+
verified: '正本と一致',
|
|
64
|
+
error_retryable: '取得失敗',
|
|
65
|
+
unknown: '確認できません',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const RELATION_LABELS = Object.freeze(Object.fromEntries(OBJECTIVE_EDITOR_RELATION_TYPES));
|
|
69
|
+
|
|
70
|
+
function getDocument() {
|
|
71
|
+
if (typeof document === 'undefined' || !document?.createElement) throw new Error('document_unavailable');
|
|
72
|
+
return document;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function makeElement(tag, options = {}) {
|
|
76
|
+
const element = getDocument().createElement(tag);
|
|
77
|
+
if (options.className) element.className = options.className;
|
|
78
|
+
if (options.text !== undefined && options.text !== null) element.textContent = String(options.text);
|
|
79
|
+
for (const [name, value] of Object.entries(options.attrs ?? {})) {
|
|
80
|
+
if (value === undefined || value === null || value === false) continue;
|
|
81
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
82
|
+
}
|
|
83
|
+
if (options.value !== undefined && 'value' in element) element.value = String(options.value);
|
|
84
|
+
if (options.checked !== undefined && 'checked' in element) element.checked = Boolean(options.checked);
|
|
85
|
+
if (options.disabled !== undefined) element.disabled = Boolean(options.disabled);
|
|
86
|
+
if (options.hidden !== undefined) element.hidden = Boolean(options.hidden);
|
|
87
|
+
return element;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function append(parent, ...children) {
|
|
91
|
+
parent.append(...children.filter((child) => child !== null && child !== undefined));
|
|
92
|
+
return parent;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clear(parent) {
|
|
96
|
+
parent.replaceChildren();
|
|
97
|
+
return parent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function optionalText(value) {
|
|
101
|
+
if (value === null || value === undefined) return '';
|
|
102
|
+
return typeof value === 'string' ? value : String(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nonEmptyText(value) {
|
|
106
|
+
const result = optionalText(value).trim();
|
|
107
|
+
return result || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function text(value, fallback = '未確認') {
|
|
111
|
+
const result = nonEmptyText(value);
|
|
112
|
+
return result ?? fallback;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function asArray(value) {
|
|
116
|
+
return Array.isArray(value) ? value : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function objectValue(value) {
|
|
120
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function firstValue(source, ...keys) {
|
|
124
|
+
const object = objectValue(source);
|
|
125
|
+
if (!object) return undefined;
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
if (Object.hasOwn(object, key) && object[key] !== undefined && object[key] !== null) return object[key];
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function payloadValue(payload, ...keys) {
|
|
133
|
+
const object = objectValue(payload);
|
|
134
|
+
if (!object) return undefined;
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
if (Object.hasOwn(object, key) && object[key] !== undefined && object[key] !== null) return object[key];
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeState(value, fallback = UNKNOWN) {
|
|
142
|
+
const state = optionalText(value).trim().toLowerCase();
|
|
143
|
+
if (!state) return fallback;
|
|
144
|
+
if (['forbidden', 'authorization_denied', 'permission_insufficient', 'not_allowed'].includes(state)) return 'permission_denied';
|
|
145
|
+
if (['unavailable', 'upstream_unavailable', 'api_not_implemented', 'not_implemented'].includes(state)) return 'api_unavailable';
|
|
146
|
+
if (['version_conflict', 'revision_conflict', 'cas_conflict'].includes(state)) return 'conflict';
|
|
147
|
+
if (['persisted', 'saved'].includes(state)) return 'saved_unverified';
|
|
148
|
+
if (['readback_verified', 'committed_verified'].includes(state)) return 'verified';
|
|
149
|
+
return state;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const EXPLICIT_COLLECTION_FAILURES = new Set(['permission_denied', 'api_unavailable', 'missing', 'conflict']);
|
|
153
|
+
|
|
154
|
+
function explicitCollectionFailure(state, key) {
|
|
155
|
+
if (!EXPLICIT_COLLECTION_FAILURES.has(state)) return null;
|
|
156
|
+
return { state, [key]: null, absence_confirmed: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeRevision(value) {
|
|
160
|
+
const revision = nonEmptyText(value);
|
|
161
|
+
return revision && /^[1-9]\d*$/.test(revision) ? revision : revision;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeReference(value, defaultType = null) {
|
|
165
|
+
const object = objectValue(value);
|
|
166
|
+
if (!object) return null;
|
|
167
|
+
const id = nonEmptyText(firstValue(object, 'id', 'resourceId', 'resource_id'));
|
|
168
|
+
const type = nonEmptyText(firstValue(object, 'type', 'resourceType', 'resource_type')) ?? defaultType;
|
|
169
|
+
const revision = normalizeRevision(firstValue(object, 'revision', 'version'));
|
|
170
|
+
if (!id || !type || !revision) return null;
|
|
171
|
+
return {
|
|
172
|
+
id,
|
|
173
|
+
type,
|
|
174
|
+
revision,
|
|
175
|
+
digest: nonEmptyText(firstValue(object, 'digest', 'hash')),
|
|
176
|
+
meaning: nonEmptyText(firstValue(object, 'meaning', 'label', 'name')),
|
|
177
|
+
raw: object,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeVariableRef(value) {
|
|
182
|
+
const reference = normalizeReference(value, 'variable');
|
|
183
|
+
if (!reference || reference.type !== 'variable') return null;
|
|
184
|
+
return { id: reference.id, type: 'variable', revision: reference.revision };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizeCriterion(value) {
|
|
188
|
+
const object = objectValue(value);
|
|
189
|
+
if (!object) return null;
|
|
190
|
+
const variableRef = normalizeVariableRef(firstValue(object, 'variableRef', 'variable_ref', 'variable'));
|
|
191
|
+
const operator = nonEmptyText(firstValue(object, 'operator', 'comparison'));
|
|
192
|
+
if (!variableRef || !operator) return null;
|
|
193
|
+
const criterion = { variableRef, operator };
|
|
194
|
+
if (Object.hasOwn(object, 'target')) criterion.target = object.target;
|
|
195
|
+
return criterion;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizePeriod(value) {
|
|
199
|
+
const object = objectValue(value);
|
|
200
|
+
if (!object) return null;
|
|
201
|
+
return {
|
|
202
|
+
from: nonEmptyText(firstValue(object, 'from', 'validFrom', 'valid_from', 'start')),
|
|
203
|
+
until: nonEmptyText(firstValue(object, 'until', 'validUntil', 'valid_until', 'end')),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function dateTimeLocalValue(value) {
|
|
208
|
+
const input = nonEmptyText(value);
|
|
209
|
+
if (!input) return '';
|
|
210
|
+
const date = new Date(input);
|
|
211
|
+
if (Number.isNaN(date.getTime())) return input;
|
|
212
|
+
const pad = (part) => String(part).padStart(2, '0');
|
|
213
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function canonicalDateTimeValue(value) {
|
|
217
|
+
const input = nonEmptyText(value);
|
|
218
|
+
if (!input) return '';
|
|
219
|
+
const date = new Date(input);
|
|
220
|
+
return Number.isNaN(date.getTime()) ? input : date.toISOString();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeReadiness(value) {
|
|
224
|
+
const object = objectValue(value);
|
|
225
|
+
if (!object || typeof object.ready !== 'boolean') return { state: 'unknown', ready: null, issues: null };
|
|
226
|
+
const issues = asArray(object.issues);
|
|
227
|
+
return {
|
|
228
|
+
state: 'ready',
|
|
229
|
+
ready: object.ready,
|
|
230
|
+
issues: issues ? issues.map((issue) => ({
|
|
231
|
+
code: text(firstValue(issue, 'code'), 'UNKNOWN'),
|
|
232
|
+
path: text(firstValue(issue, 'path'), 'definition'),
|
|
233
|
+
message: text(firstValue(issue, 'message'), '利用条件を確認できません。'),
|
|
234
|
+
})) : [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function recordPayload(payload) {
|
|
239
|
+
const object = objectValue(payload);
|
|
240
|
+
if (!object) return null;
|
|
241
|
+
const nestedRecord = objectValue(payloadValue(object, 'objective', 'record', 'data'));
|
|
242
|
+
if (nestedRecord) return nestedRecord;
|
|
243
|
+
// A canonical FoundationCatalogRecord carries metadata (digest/readiness)
|
|
244
|
+
// beside `definition`; keep the record wrapper so that metadata is not
|
|
245
|
+
// mistaken for an absent readback.
|
|
246
|
+
if (objectValue(object.definition)) return object;
|
|
247
|
+
return objectValue(payloadValue(object, 'definition')) ?? object;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Converts a canonical FoundationCatalogRecord (or a host projection of it)
|
|
252
|
+
* without filling missing fields with guessed values.
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeObjectiveRecord(payload) {
|
|
255
|
+
const source = recordPayload(payload);
|
|
256
|
+
if (!source) return null;
|
|
257
|
+
const definition = objectValue(source.definition) ?? source;
|
|
258
|
+
const type = nonEmptyText(firstValue(definition, 'type')) ?? nonEmptyText(firstValue(source, 'type'));
|
|
259
|
+
const id = nonEmptyText(firstValue(definition, 'id')) ?? nonEmptyText(firstValue(source, 'id'));
|
|
260
|
+
const revision = normalizeRevision(firstValue(definition, 'revision', 'version')) ?? normalizeRevision(firstValue(source, 'revision', 'version'));
|
|
261
|
+
if (!id || !revision) return null;
|
|
262
|
+
const criteriaValue = firstValue(definition, 'criteria');
|
|
263
|
+
const criteria = asArray(criteriaValue);
|
|
264
|
+
// A malformed criterion must invalidate the whole Objective readback. It
|
|
265
|
+
// is unsafe to drop a broken Variable reference and present the remaining
|
|
266
|
+
// criteria as a usable (or empty) set for saving or judgment.
|
|
267
|
+
if (criteriaValue !== undefined && criteria === null) return null;
|
|
268
|
+
const normalizedCriteria = criteria ? criteria.map(normalizeCriterion) : null;
|
|
269
|
+
if (normalizedCriteria?.some((criterion) => !criterion)) return null;
|
|
270
|
+
const readiness = normalizeReadiness(firstValue(source, 'readiness', 'judgmentReadiness', 'judgment_readiness'));
|
|
271
|
+
const constraintRefsValue = firstValue(source, 'constraintRefs', 'constraint_refs', 'constraints');
|
|
272
|
+
const constraintRefs = asArray(constraintRefsValue);
|
|
273
|
+
const normalized = {
|
|
274
|
+
id,
|
|
275
|
+
type: type ?? 'objective',
|
|
276
|
+
revision,
|
|
277
|
+
digest: nonEmptyText(firstValue(source, 'digest', 'hash')),
|
|
278
|
+
meaning: nonEmptyText(firstValue(definition, 'meaning')),
|
|
279
|
+
beneficiaryIds: asArray(firstValue(definition, 'beneficiaryIds', 'beneficiary_ids')),
|
|
280
|
+
desiredState: nonEmptyText(firstValue(definition, 'desiredState', 'desired_state')),
|
|
281
|
+
criteria: normalizedCriteria,
|
|
282
|
+
evaluationPeriod: normalizePeriod(firstValue(definition, 'evaluationPeriod', 'evaluation_period')),
|
|
283
|
+
accountableId: nonEmptyText(firstValue(definition, 'accountableId', 'accountable_id')),
|
|
284
|
+
epistemicState: nonEmptyText(firstValue(definition, 'epistemicState', 'epistemic_state')),
|
|
285
|
+
adoptionState: nonEmptyText(firstValue(definition, 'adoptionState', 'adoption_state')),
|
|
286
|
+
authorizedUses: asArray(firstValue(definition, 'authorizedUses', 'authorized_uses')),
|
|
287
|
+
acl: objectValue(firstValue(definition, 'acl')),
|
|
288
|
+
storage: nonEmptyText(firstValue(definition, 'storage')),
|
|
289
|
+
provenance: asArray(firstValue(definition, 'provenance')),
|
|
290
|
+
scope: objectValue(firstValue(definition, 'scope')),
|
|
291
|
+
readiness,
|
|
292
|
+
constraintRefs: constraintRefs ? constraintRefs.map((ref) => normalizeReference(ref, 'constraint')).filter(Boolean) : null,
|
|
293
|
+
raw: source,
|
|
294
|
+
definition,
|
|
295
|
+
};
|
|
296
|
+
return normalized;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function collectionRecords(payload, keys) {
|
|
300
|
+
if (Array.isArray(payload)) return payload;
|
|
301
|
+
const object = objectValue(payload);
|
|
302
|
+
if (!object) return null;
|
|
303
|
+
for (const key of keys) {
|
|
304
|
+
if (Array.isArray(object[key])) return object[key];
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Keeps an absent records array distinct from a confirmed empty result.
|
|
311
|
+
*/
|
|
312
|
+
export function normalizeObjectiveCollection(payload) {
|
|
313
|
+
const recordsValue = collectionRecords(payload, ['records', 'objectives', 'items', 'data']);
|
|
314
|
+
const explicitState = normalizeState(payloadValue(payload, 'state', 'status'), 'unknown');
|
|
315
|
+
const explicitFailure = explicitCollectionFailure(explicitState, 'records');
|
|
316
|
+
if (explicitFailure) return explicitFailure;
|
|
317
|
+
if (recordsValue === null) {
|
|
318
|
+
return { state: explicitState === 'empty' ? 'unknown' : explicitState, records: null, absence_confirmed: false };
|
|
319
|
+
}
|
|
320
|
+
const records = recordsValue.map(normalizeObjectiveRecord);
|
|
321
|
+
if (records.some((record) => !record || record.type !== 'objective')) {
|
|
322
|
+
return { state: 'unknown', records: null, absence_confirmed: false };
|
|
323
|
+
}
|
|
324
|
+
const absenceConfirmed = payloadValue(payload, 'absence_confirmed', 'absenceConfirmed') === true;
|
|
325
|
+
if (records.length === 0 && !absenceConfirmed) {
|
|
326
|
+
return { state: 'unknown', records: null, absence_confirmed: false };
|
|
327
|
+
}
|
|
328
|
+
const state = explicitState === 'permission_denied' || explicitState === 'api_unavailable' || explicitState === 'conflict'
|
|
329
|
+
? explicitState
|
|
330
|
+
: records.length ? 'ready' : 'empty';
|
|
331
|
+
return {
|
|
332
|
+
state,
|
|
333
|
+
records,
|
|
334
|
+
absence_confirmed: absenceConfirmed,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function normalizeReferenceCollection(payload, keys = ['refs', 'references', 'constraints', 'records', 'items', 'data']) {
|
|
339
|
+
const recordsValue = collectionRecords(payload, keys);
|
|
340
|
+
const explicitState = normalizeState(payloadValue(payload, 'state', 'status'), 'unknown');
|
|
341
|
+
const explicitFailure = explicitCollectionFailure(explicitState, 'refs');
|
|
342
|
+
if (explicitFailure) return explicitFailure;
|
|
343
|
+
if (recordsValue === null) return { state: explicitState, refs: null, absence_confirmed: false };
|
|
344
|
+
const refs = recordsValue.map((value) => normalizeReference(value));
|
|
345
|
+
if (refs.some((ref) => !ref)) {
|
|
346
|
+
return { state: 'unknown', refs: null, absence_confirmed: false };
|
|
347
|
+
}
|
|
348
|
+
const absenceConfirmed = payloadValue(payload, 'absence_confirmed', 'absenceConfirmed') === true;
|
|
349
|
+
if (refs.length === 0 && !absenceConfirmed) {
|
|
350
|
+
return { state: 'unknown', refs: null, absence_confirmed: false };
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
state: explicitState === 'permission_denied' || explicitState === 'api_unavailable' || explicitState === 'conflict'
|
|
354
|
+
? explicitState : refs.length ? 'ready' : 'empty',
|
|
355
|
+
refs,
|
|
356
|
+
absence_confirmed: absenceConfirmed,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function normalizeStoryObjectiveLinks(payload) {
|
|
361
|
+
const recordsValue = collectionRecords(payload, ['links', 'relations', 'records', 'items', 'data']);
|
|
362
|
+
const explicitState = normalizeState(payloadValue(payload, 'state', 'status'), 'unknown');
|
|
363
|
+
const explicitFailure = explicitCollectionFailure(explicitState, 'links');
|
|
364
|
+
if (explicitFailure) return explicitFailure;
|
|
365
|
+
if (recordsValue === null) return { state: explicitState, links: null, absence_confirmed: false };
|
|
366
|
+
const links = recordsValue.map((value) => {
|
|
367
|
+
const object = objectValue(value);
|
|
368
|
+
if (!object) return null;
|
|
369
|
+
const relation = nonEmptyText(firstValue(object, 'relation', 'linkKind', 'link_kind', 'kind'));
|
|
370
|
+
const objective = normalizeReference(firstValue(object, 'objective', 'target', 'objectiveRef', 'objective_ref'));
|
|
371
|
+
const story = normalizeReference(firstValue(object, 'story', 'source', 'storyRef', 'story_ref'), 'story');
|
|
372
|
+
// The relation target is the canonical Objective. A model, Story, or
|
|
373
|
+
// other resource must never be rendered as an Objective by defaulting its
|
|
374
|
+
// type from the relation position.
|
|
375
|
+
if (!relation || !objective || objective.type !== 'objective') return null;
|
|
376
|
+
return {
|
|
377
|
+
relation,
|
|
378
|
+
relationLabel: RELATION_LABELS[relation] ?? relation,
|
|
379
|
+
objective,
|
|
380
|
+
story,
|
|
381
|
+
timeCondition: firstValue(object, 'timeCondition', 'time_condition') ?? null,
|
|
382
|
+
raw: object,
|
|
383
|
+
};
|
|
384
|
+
}).filter(Boolean);
|
|
385
|
+
if (links.length !== recordsValue.length) {
|
|
386
|
+
return { state: 'unknown', links: null, absence_confirmed: false };
|
|
387
|
+
}
|
|
388
|
+
const absenceConfirmed = payloadValue(payload, 'absence_confirmed', 'absenceConfirmed') === true;
|
|
389
|
+
if (links.length === 0 && !absenceConfirmed) {
|
|
390
|
+
return { state: 'unknown', links: null, absence_confirmed: false };
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
state: explicitState === 'permission_denied' || explicitState === 'api_unavailable' || explicitState === 'conflict'
|
|
394
|
+
? explicitState : links.length ? 'ready' : 'empty',
|
|
395
|
+
links,
|
|
396
|
+
absence_confirmed: absenceConfirmed,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function objectiveJudgmentState(objective) {
|
|
401
|
+
const normalized = normalizeObjectiveRecord(objective) ?? objective;
|
|
402
|
+
const readiness = normalized?.readiness;
|
|
403
|
+
if (readiness?.state === 'ready' && readiness.ready === true && normalized?.adoptionState !== 'retired') return 'judgment_available';
|
|
404
|
+
if (readiness?.state === 'ready' && readiness.ready === false) return 'draft';
|
|
405
|
+
if (['draft', 'proposed'].includes(normalized?.adoptionState)) return 'draft';
|
|
406
|
+
return 'judgment_unknown';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function objectiveJudgmentLabel(objective) {
|
|
410
|
+
return STATUS_LABELS[objectiveJudgmentState(objective)] ?? STATUS_LABELS.unknown;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function statusLabel(value) {
|
|
414
|
+
const state = normalizeState(value);
|
|
415
|
+
return STATUS_LABELS[state] ?? text(value);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function normalizeObjectiveError(error) {
|
|
419
|
+
const code = optionalText(error?.code ?? error?.status ?? error?.name).trim().toLowerCase();
|
|
420
|
+
const status = Number(error?.statusCode ?? error?.status);
|
|
421
|
+
let state = normalizeState(code);
|
|
422
|
+
if (state === UNKNOWN || !state) {
|
|
423
|
+
if (status === 401 || status === 403) state = 'permission_denied';
|
|
424
|
+
else if (status === 404) state = 'missing';
|
|
425
|
+
else if (status === 409) state = 'conflict';
|
|
426
|
+
else if (status >= 500) state = 'api_unavailable';
|
|
427
|
+
else state = 'error_retryable';
|
|
428
|
+
}
|
|
429
|
+
if (!['permission_denied', 'api_unavailable', 'missing', 'conflict', 'saved_unverified', 'error_retryable'].includes(state)) state = 'error_retryable';
|
|
430
|
+
return {
|
|
431
|
+
state,
|
|
432
|
+
code: code || state,
|
|
433
|
+
currentRevision: nonEmptyText(error?.currentRevision ?? error?.current_revision),
|
|
434
|
+
message: nonEmptyText(error?.message) ?? STATUS_LABELS[state],
|
|
435
|
+
error,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function statusNotice(state, callbacks = {}) {
|
|
440
|
+
const normalized = typeof state === 'string' ? { state } : state ?? {};
|
|
441
|
+
const status = normalizeState(normalized.state);
|
|
442
|
+
const className = ['permission_denied', 'api_unavailable', 'conflict', 'missing'].includes(status)
|
|
443
|
+
? `objective-editor-notice objective-editor-notice-${status}`
|
|
444
|
+
: 'objective-editor-notice';
|
|
445
|
+
const notice = makeElement('div', { className, attrs: { role: status === 'error_retryable' ? 'alert' : 'status' } });
|
|
446
|
+
append(notice,
|
|
447
|
+
makeElement('strong', { text: STATUS_LABELS[status] ?? STATUS_LABELS.unknown }),
|
|
448
|
+
makeElement('p', { text: normalized.message ?? STATUS_LABELS[status] ?? '状態を確認できません。' }),
|
|
449
|
+
);
|
|
450
|
+
if (status === 'conflict' && normalized.currentRevision) {
|
|
451
|
+
notice.append(makeElement('small', { text: `現在の版: ${normalized.currentRevision}。入力を保持したまま再読込してください。` }));
|
|
452
|
+
}
|
|
453
|
+
if (callbacks.onRetry) notice.append(makeButton('再読込', callbacks.onRetry, 'objective-editor-button-secondary'));
|
|
454
|
+
return notice;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function makeButton(label, onClick, className = 'objective-editor-button') {
|
|
458
|
+
const button = makeElement('button', { className, text: label, attrs: { type: 'button' } });
|
|
459
|
+
if (onClick) button.addEventListener('click', onClick);
|
|
460
|
+
return button;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function statusBadge(state, label = null) {
|
|
464
|
+
const normalized = normalizeState(state);
|
|
465
|
+
return makeElement('span', {
|
|
466
|
+
className: `objective-editor-badge objective-editor-badge-${normalized.replace(/[^a-z0-9_-]/g, '-')}`,
|
|
467
|
+
text: label ?? (STATUS_LABELS[normalized] ?? text(state)),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function field(labelText, name, value, options = {}) {
|
|
472
|
+
const label = makeElement('label', { className: 'objective-editor-field' });
|
|
473
|
+
label.append(makeElement('span', { className: 'objective-editor-label', text: labelText }));
|
|
474
|
+
// A field with choices is a select; an input cannot hold option elements.
|
|
475
|
+
const input = makeElement(options.tag ?? (options.options ? 'select' : 'input'), {
|
|
476
|
+
className: 'objective-editor-input',
|
|
477
|
+
value: value ?? '',
|
|
478
|
+
attrs: { name, ...(options.attrs ?? {}) },
|
|
479
|
+
disabled: options.disabled,
|
|
480
|
+
});
|
|
481
|
+
if (options.tag === 'textarea') input.textContent = value ?? '';
|
|
482
|
+
if (options.options) {
|
|
483
|
+
clear(input);
|
|
484
|
+
for (const option of options.options) {
|
|
485
|
+
input.append(makeElement('option', { text: option.label, value: option.value, attrs: { value: option.value } }));
|
|
486
|
+
}
|
|
487
|
+
input.value = value ?? '';
|
|
488
|
+
}
|
|
489
|
+
label.append(input);
|
|
490
|
+
if (options.help) label.append(makeElement('small', { className: 'objective-editor-help', text: options.help }));
|
|
491
|
+
return { label, input };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function findAll(node, predicate, result = []) {
|
|
495
|
+
if (!node) return result;
|
|
496
|
+
if (predicate(node)) result.push(node);
|
|
497
|
+
for (const child of node.children ?? []) findAll(child, predicate, result);
|
|
498
|
+
return result;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function nodeAttribute(node, name) {
|
|
502
|
+
if (!node) return undefined;
|
|
503
|
+
if (typeof node.getAttribute === 'function') return node.getAttribute(name) ?? undefined;
|
|
504
|
+
const attributes = node.attributes;
|
|
505
|
+
if (!attributes) return undefined;
|
|
506
|
+
if (typeof attributes.getNamedItem === 'function') return attributes.getNamedItem(name)?.value;
|
|
507
|
+
return attributes[name];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function findField(root, name) {
|
|
511
|
+
return findAll(root, (node) => nodeAttribute(node, 'name') === name)[0] ?? null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parseTarget(value) {
|
|
515
|
+
const input = optionalText(value).trim();
|
|
516
|
+
if (!input) return undefined;
|
|
517
|
+
if (input === 'true') return true;
|
|
518
|
+
if (input === 'false') return false;
|
|
519
|
+
if (/^-?(?:\d+|\d*\.\d+)$/.test(input)) return Number(input);
|
|
520
|
+
return input;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function copyDefinition(normalized) {
|
|
524
|
+
if (!normalized) return null;
|
|
525
|
+
const source = objectValue(normalized.definition) ?? objectValue(normalized.raw) ?? {};
|
|
526
|
+
return JSON.parse(JSON.stringify(source));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function editorDraftFromObjective(objective, options = {}) {
|
|
530
|
+
const definition = copyDefinition(objective) ?? {};
|
|
531
|
+
return {
|
|
532
|
+
...definition,
|
|
533
|
+
id: objective?.id ?? definition.id ?? '',
|
|
534
|
+
type: 'objective',
|
|
535
|
+
revision: objective?.revision ?? definition.revision ?? '1',
|
|
536
|
+
meaning: objective?.meaning ?? definition.meaning ?? '',
|
|
537
|
+
beneficiaryIds: [...(objective?.beneficiaryIds ?? definition.beneficiaryIds ?? [])],
|
|
538
|
+
desiredState: objective?.desiredState ?? definition.desiredState ?? '',
|
|
539
|
+
criteria: JSON.parse(JSON.stringify(objective?.criteria ?? definition.criteria ?? [])),
|
|
540
|
+
evaluationPeriod: {
|
|
541
|
+
from: objective?.evaluationPeriod?.from ?? definition.evaluationPeriod?.from ?? '',
|
|
542
|
+
until: objective?.evaluationPeriod?.until ?? definition.evaluationPeriod?.until ?? '',
|
|
543
|
+
},
|
|
544
|
+
accountableId: objective?.accountableId ?? definition.accountableId ?? '',
|
|
545
|
+
adoptionState: objective?.adoptionState ?? definition.adoptionState ?? 'draft',
|
|
546
|
+
authorizedUses: [...(objective?.authorizedUses ?? definition.authorizedUses ?? ['draft'])],
|
|
547
|
+
...options,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function shortDate(value) {
|
|
552
|
+
const input = nonEmptyText(value);
|
|
553
|
+
if (!input) return null;
|
|
554
|
+
const date = new Date(input);
|
|
555
|
+
if (Number.isNaN(date.getTime())) return input;
|
|
556
|
+
const pad = (part) => String(part).padStart(2, '0');
|
|
557
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Readiness in the list: known readiness is named as such, not as the adoption state. */
|
|
561
|
+
function readinessBadge(objective) {
|
|
562
|
+
const readiness = objective.readiness;
|
|
563
|
+
if (readiness?.state === 'ready' && readiness.ready === false) {
|
|
564
|
+
const count = readiness.issues?.length ?? 0;
|
|
565
|
+
return statusBadge('draft', count > 0 ? `判断に使えない(不足${count}件)` : '判断に使えない');
|
|
566
|
+
}
|
|
567
|
+
return statusBadge(objectiveJudgmentState(objective), objectiveJudgmentLabel(objective));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** One line per Objective: desired state, evaluation period, accountable person and criteria count. */
|
|
571
|
+
function objectiveSummary(objective) {
|
|
572
|
+
const from = shortDate(objective.evaluationPeriod?.from);
|
|
573
|
+
const until = shortDate(objective.evaluationPeriod?.until);
|
|
574
|
+
return [
|
|
575
|
+
`評価期間 ${from || until ? `${from ?? '未設定'}〜${until ?? '未設定'}` : '未設定'}`,
|
|
576
|
+
`責任者 ${objective.accountableId ?? '未設定'}`,
|
|
577
|
+
`評価基準 ${objective.criteria ? `${objective.criteria.length}件` : '未確認'}`,
|
|
578
|
+
].join(' ・ ');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function renderObjectiveList(root, state, callbacks) {
|
|
582
|
+
const section = makeElement('section', { className: 'objective-editor-panel', attrs: { 'aria-label': '目的一覧' } });
|
|
583
|
+
append(section,
|
|
584
|
+
makeElement('div', { className: 'objective-editor-panel-heading', text: '目的一覧' }),
|
|
585
|
+
makeElement('p', { className: 'objective-editor-muted', text: '目的本文、評価基準、利用可能な版を正本から確認します。' }),
|
|
586
|
+
);
|
|
587
|
+
if (state.objectives.state === 'loading') {
|
|
588
|
+
for (let index = 0; index < 3; index += 1) section.append(makeElement('div', { className: 'objective-editor-skeleton', text: '目的を読み込んでいます。' }));
|
|
589
|
+
return section;
|
|
590
|
+
}
|
|
591
|
+
if (['permission_denied', 'api_unavailable', 'error_retryable', 'unknown'].includes(state.objectives.state)) {
|
|
592
|
+
section.append(statusNotice(state.objectives, { onRetry: callbacks.onReload }));
|
|
593
|
+
return section;
|
|
594
|
+
}
|
|
595
|
+
if (state.objectives.state === 'empty') {
|
|
596
|
+
section.append(makeElement('div', { className: 'objective-editor-empty', text: '目的はまだ登録されていません。' }));
|
|
597
|
+
if (callbacks.onCreate) section.append(makeButton('目的を作成', callbacks.onCreate, 'objective-editor-button-primary'));
|
|
598
|
+
return section;
|
|
599
|
+
}
|
|
600
|
+
const list = makeElement('div', { className: 'objective-editor-list' });
|
|
601
|
+
for (const objective of state.objectives.records ?? []) {
|
|
602
|
+
const row = makeButton('', () => callbacks.onSelect?.(objective), 'objective-editor-list-row');
|
|
603
|
+
const copy = makeElement('span', { className: 'objective-editor-list-copy' });
|
|
604
|
+
append(copy,
|
|
605
|
+
makeElement('strong', { text: text(objective.meaning, objective.id) }),
|
|
606
|
+
objective.desiredState ? makeElement('span', { className: 'objective-editor-muted', text: `実現したい状態: ${objective.desiredState}` }) : null,
|
|
607
|
+
makeElement('small', { text: objectiveSummary(objective) }),
|
|
608
|
+
makeElement('small', { text: `${objective.id}@${objective.revision}` }),
|
|
609
|
+
);
|
|
610
|
+
const badges = makeElement('span', { className: 'objective-editor-list-badges' });
|
|
611
|
+
badges.append(statusBadge(objective.adoptionState ?? 'unknown'), readinessBadge(objective));
|
|
612
|
+
row.append(copy, badges);
|
|
613
|
+
list.append(row);
|
|
614
|
+
}
|
|
615
|
+
section.append(list);
|
|
616
|
+
return section;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function renderReadiness(root, readiness) {
|
|
620
|
+
const section = makeElement('section', { className: 'objective-editor-readiness', attrs: { 'aria-label': '判断利用可能性' } });
|
|
621
|
+
const normalized = normalizeReadiness(readiness);
|
|
622
|
+
section.append(makeElement('div', { className: 'objective-editor-subheading', text: '判断への利用' }));
|
|
623
|
+
if (normalized.state !== 'ready') {
|
|
624
|
+
section.append(statusBadge('judgment_unknown'), makeElement('p', { className: 'objective-editor-muted', text: '判断に使える状態かどうかを、保存先から確認できていません。' }));
|
|
625
|
+
return section;
|
|
626
|
+
}
|
|
627
|
+
section.append(normalized.ready ? statusBadge('judgment_available') : statusBadge('draft', '判断に使えない'));
|
|
628
|
+
if (!normalized.ready && normalized.issues?.length) {
|
|
629
|
+
const list = makeElement('ul', { className: 'objective-editor-issue-list' });
|
|
630
|
+
for (const issue of normalized.issues) list.append(makeElement('li', { text: `${issue.path}: ${issue.message}` }));
|
|
631
|
+
section.append(list);
|
|
632
|
+
} else {
|
|
633
|
+
section.append(makeElement('p', { className: 'objective-editor-muted', text: '必要な定義・基準・参照が揃っています。観測値の存在は別途確認します。' }));
|
|
634
|
+
}
|
|
635
|
+
return section;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function renderConstraintRefs(root, state, callbacks) {
|
|
639
|
+
const section = makeElement('section', { className: 'objective-editor-reference-section', attrs: { 'aria-label': '制約参照' } });
|
|
640
|
+
section.append(makeElement('div', { className: 'objective-editor-subheading', text: '適用する制約参照' }));
|
|
641
|
+
if (state.constraints.state === 'loading') {
|
|
642
|
+
section.append(makeElement('p', { className: 'objective-editor-muted', text: '制約参照を読み込んでいます。' }));
|
|
643
|
+
return section;
|
|
644
|
+
}
|
|
645
|
+
if (['permission_denied', 'api_unavailable', 'error_retryable', 'unknown'].includes(state.constraints.state)) {
|
|
646
|
+
section.append(statusNotice(state.constraints, { onRetry: callbacks.onReloadConstraints }));
|
|
647
|
+
return section;
|
|
648
|
+
}
|
|
649
|
+
const refs = state.editor.constraintRefs ?? state.constraints.refs ?? [];
|
|
650
|
+
const list = makeElement('ul', { className: 'objective-editor-reference-list' });
|
|
651
|
+
if (!refs.length) list.append(makeElement('li', { className: 'objective-editor-muted', text: 'この目的に紐づく制約参照はありません。' }));
|
|
652
|
+
for (const ref of refs) {
|
|
653
|
+
const item = makeElement('li', { className: 'objective-editor-reference-item' });
|
|
654
|
+
item.append(makeElement('span', { text: `${ref.id}@${ref.revision}` }));
|
|
655
|
+
if (ref.meaning) item.append(makeElement('small', { text: ref.meaning }));
|
|
656
|
+
if (callbacks.canEdit && callbacks.constraintsEditable !== false && callbacks.onRemoveConstraintRef) item.append(makeButton('外す', () => callbacks.onRemoveConstraintRef(ref), 'objective-editor-button-danger'));
|
|
657
|
+
list.append(item);
|
|
658
|
+
}
|
|
659
|
+
section.append(list);
|
|
660
|
+
if (callbacks.constraintsEditable === false) {
|
|
661
|
+
section.append(makeElement('p', { className: 'objective-editor-muted', text: '制約の参照はここでは表示だけです。' }));
|
|
662
|
+
} else if (callbacks.canEdit && callbacks.onAddConstraintRef) {
|
|
663
|
+
const addForm = makeElement('form', { className: 'objective-editor-inline-form' });
|
|
664
|
+
const idField = field('制約ID', 'constraint_id', '', { attrs: { placeholder: 'constraint-id' } });
|
|
665
|
+
const revisionField = field('版', 'constraint_revision', '', { attrs: { placeholder: '1' } });
|
|
666
|
+
const submit = makeElement('button', { className: 'objective-editor-button-secondary', text: '参照を追加', attrs: { type: 'submit' } });
|
|
667
|
+
addForm.append(idField.label, revisionField.label, submit);
|
|
668
|
+
addForm.addEventListener('submit', (event) => {
|
|
669
|
+
event.preventDefault();
|
|
670
|
+
callbacks.onAddConstraintRef({ id: idField.input.value, type: 'constraint', revision: revisionField.input.value });
|
|
671
|
+
});
|
|
672
|
+
section.append(addForm);
|
|
673
|
+
}
|
|
674
|
+
return section;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderCriteria(root, draft, callbacks) {
|
|
678
|
+
const fieldset = makeElement('fieldset', { className: 'objective-editor-criteria' });
|
|
679
|
+
fieldset.append(makeElement('legend', { text: '評価基準' }));
|
|
680
|
+
const criteria = Array.isArray(draft.criteria) ? draft.criteria : [];
|
|
681
|
+
if (!criteria.length) fieldset.append(makeElement('p', { className: 'objective-editor-muted', text: '評価基準はまだありません。判断に使うには、測る変数の参照が必要です。' }));
|
|
682
|
+
criteria.forEach((criterion, index) => {
|
|
683
|
+
const row = makeElement('div', { className: 'objective-editor-criterion-row' });
|
|
684
|
+
const variableRef = criterion?.variableRef ?? {};
|
|
685
|
+
const variableId = field('変数ID', `criteria.${index}.variable_id`, variableRef.id ?? '', { attrs: { required: true } });
|
|
686
|
+
const variableRevision = field('変数の版', `criteria.${index}.variable_revision`, variableRef.revision ?? '', { attrs: { required: true } });
|
|
687
|
+
const operator = field('比較', `criteria.${index}.operator`, criterion?.operator ?? 'equals', { options: [
|
|
688
|
+
{ value: 'at_least', label: '以上' },
|
|
689
|
+
{ value: 'at_most', label: '以下' },
|
|
690
|
+
{ value: 'equals', label: '一致' },
|
|
691
|
+
] });
|
|
692
|
+
const target = field('基準値', `criteria.${index}.target`, criterion?.target ?? '', { attrs: { placeholder: '未指定は条件のみ' } });
|
|
693
|
+
const remove = callbacks.canEdit ? makeButton('削除', () => callbacks.onRemoveCriterion?.(index), 'objective-editor-button-danger') : null;
|
|
694
|
+
row.append(variableId.label, variableRevision.label, operator.label, target.label, remove);
|
|
695
|
+
fieldset.append(row);
|
|
696
|
+
});
|
|
697
|
+
if (callbacks.canEdit) fieldset.append(makeButton('基準を追加', callbacks.onAddCriterion, 'objective-editor-button-secondary'));
|
|
698
|
+
return fieldset;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function collectDefinition(form, baseDraft) {
|
|
702
|
+
const draft = JSON.parse(JSON.stringify(baseDraft ?? {}));
|
|
703
|
+
const value = (name) => findField(form, name)?.value ?? '';
|
|
704
|
+
draft.id = optionalText(value('id')).trim();
|
|
705
|
+
draft.meaning = value('meaning');
|
|
706
|
+
draft.desiredState = value('desired_state');
|
|
707
|
+
draft.beneficiaryIds = optionalText(value('beneficiary_ids')).split(',').map((item) => item.trim()).filter(Boolean);
|
|
708
|
+
draft.accountableId = optionalText(value('accountable_id')).trim() || undefined;
|
|
709
|
+
draft.adoptionState = value('adoption_state') || draft.adoptionState || 'draft';
|
|
710
|
+
draft.evaluationPeriod = {
|
|
711
|
+
from: canonicalDateTimeValue(value('evaluation_from')),
|
|
712
|
+
until: canonicalDateTimeValue(value('evaluation_until')),
|
|
713
|
+
};
|
|
714
|
+
draft.criteria = [];
|
|
715
|
+
const variableIds = findAll(form, (node) => /^criteria\.\d+\.variable_id$/.test(nodeAttribute(node, 'name') ?? ''));
|
|
716
|
+
for (const variableIdInput of variableIds) {
|
|
717
|
+
const match = nodeAttribute(variableIdInput, 'name').match(/^criteria\.(\d+)\.variable_id$/);
|
|
718
|
+
const index = Number(match[1]);
|
|
719
|
+
const variableRevision = value(`criteria.${index}.variable_revision`).trim();
|
|
720
|
+
const operator = value(`criteria.${index}.operator`) || 'equals';
|
|
721
|
+
const target = parseTarget(value(`criteria.${index}.target`));
|
|
722
|
+
if (!variableIdInput.value.trim() && !variableRevision) continue;
|
|
723
|
+
const criterion = {
|
|
724
|
+
variableRef: { id: variableIdInput.value.trim(), type: 'variable', revision: variableRevision },
|
|
725
|
+
operator,
|
|
726
|
+
};
|
|
727
|
+
if (target !== undefined) criterion.target = target;
|
|
728
|
+
draft.criteria.push(criterion);
|
|
729
|
+
}
|
|
730
|
+
return draft;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function renderObjectiveForm(root, state, callbacks) {
|
|
734
|
+
const section = makeElement('section', { className: 'objective-editor-panel', attrs: { 'aria-label': '目的を編集' } });
|
|
735
|
+
const draft = state.editor.draft;
|
|
736
|
+
if (!draft) {
|
|
737
|
+
section.append(makeElement('div', { className: 'objective-editor-empty', text: '編集する目的を選択してください。' }));
|
|
738
|
+
return section;
|
|
739
|
+
}
|
|
740
|
+
const title = state.editor.mode === 'create' ? '目的を作成' : '目的を編集';
|
|
741
|
+
append(section,
|
|
742
|
+
makeElement('div', { className: 'objective-editor-panel-heading', text: title }),
|
|
743
|
+
makeElement('p', { className: 'objective-editor-muted', text: '保存すると新しい版になり、保存した版を読み戻して表示します。' }),
|
|
744
|
+
);
|
|
745
|
+
if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, { onRetry: callbacks.onRetrySave }));
|
|
746
|
+
section.append(buildObjectiveForm(state, callbacks));
|
|
747
|
+
return section;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The edit form. The tabs layout ends it with 保存 and 一覧へ戻る; the
|
|
752
|
+
* workspace layout (in the rail) ends it with 保存 and キャンセル.
|
|
753
|
+
*/
|
|
754
|
+
function buildObjectiveForm(state, callbacks, { workspace = false } = {}) {
|
|
755
|
+
const draft = state.editor.draft;
|
|
756
|
+
const form = makeElement('form', { className: 'objective-editor-form' });
|
|
757
|
+
const identity = makeElement('div', { className: 'objective-editor-identity' });
|
|
758
|
+
const idField = field('目的ID', 'id', draft.id, { disabled: state.editor.mode !== 'create', attrs: { required: true } });
|
|
759
|
+
const revision = makeElement('div', { className: 'objective-editor-readonly-value' });
|
|
760
|
+
append(revision, makeElement('span', { className: 'objective-editor-label', text: '使用する版' }), makeElement('strong', { text: `${draft.revision ?? '1'}` }));
|
|
761
|
+
identity.append(idField.label, revision);
|
|
762
|
+
form.append(identity);
|
|
763
|
+
form.append(field('目的の意味', 'meaning', draft.meaning, { tag: 'textarea', attrs: { required: true, rows: '3' } }).label);
|
|
764
|
+
form.append(field('実現したい状態', 'desired_state', draft.desiredState, { tag: 'textarea', attrs: { required: true, rows: '3' } }).label);
|
|
765
|
+
form.append(field('対象者ID(カンマ区切り)', 'beneficiary_ids', (draft.beneficiaryIds ?? []).join(', '), { help: '対象者を推測して補完しません。' }).label);
|
|
766
|
+
const period = makeElement('div', { className: 'objective-editor-inline-fields' });
|
|
767
|
+
period.append(field('評価期間の開始', 'evaluation_from', dateTimeLocalValue(draft.evaluationPeriod?.from ?? ''), { attrs: { type: 'datetime-local' } }).label);
|
|
768
|
+
period.append(field('評価期間の終了', 'evaluation_until', dateTimeLocalValue(draft.evaluationPeriod?.until ?? ''), { attrs: { type: 'datetime-local' } }).label);
|
|
769
|
+
form.append(period);
|
|
770
|
+
const governance = makeElement('div', { className: 'objective-editor-inline-fields' });
|
|
771
|
+
governance.append(field('責任者ID', 'accountable_id', draft.accountableId ?? '', { help: '認可主体やRACIをここで決めません。' }).label);
|
|
772
|
+
governance.append(field('採用状態', 'adoption_state', draft.adoptionState ?? 'draft', { options: [
|
|
773
|
+
{ value: 'draft', label: '下書き' },
|
|
774
|
+
{ value: 'proposed', label: '提案中' },
|
|
775
|
+
{ value: 'approved', label: '承認済み' },
|
|
776
|
+
{ value: 'retired', label: '終了' },
|
|
777
|
+
] }).label);
|
|
778
|
+
form.append(governance);
|
|
779
|
+
form.append(renderCriteria(form, draft, callbacks));
|
|
780
|
+
form.append(renderReadiness(form, state.readiness));
|
|
781
|
+
form.append(renderConstraintRefs(form, state, callbacks));
|
|
782
|
+
const actions = makeElement('div', { className: 'objective-editor-actions' });
|
|
783
|
+
actions.append(makeElement('span', { className: 'objective-editor-muted', text: callbacks.canEdit ? '保存後に同じID・新版を再取得して確認します。' : '読み取り専用です。書込み権限はホストから提供してください。' }));
|
|
784
|
+
const saving = state.save.state === 'saving';
|
|
785
|
+
if (callbacks.canEdit) actions.append(makeElement('button', { className: workspace ? 'bb-ws-button is-primary' : 'objective-editor-button-primary', text: saving ? '保存中…' : '保存', attrs: { type: 'submit' }, disabled: saving }));
|
|
786
|
+
if (workspace) actions.append(makeButton('キャンセル', callbacks.onCancelEdit, 'bb-ws-button'));
|
|
787
|
+
else actions.append(makeButton('一覧へ戻る', callbacks.onBack, 'objective-editor-button-secondary'));
|
|
788
|
+
form.append(actions);
|
|
789
|
+
form.addEventListener('submit', (event) => {
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
callbacks.onSubmit?.(collectDefinition(form, draft));
|
|
792
|
+
});
|
|
793
|
+
return form;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function renderStoryLinks(root, state, callbacks) {
|
|
797
|
+
const section = makeElement('section', { className: 'objective-editor-panel', attrs: { 'aria-label': 'ストーリーから参照される目的' } });
|
|
798
|
+
append(section,
|
|
799
|
+
makeElement('div', { className: 'objective-editor-panel-heading', text: 'ストーリーから参照される目的' }),
|
|
800
|
+
makeElement('p', { className: 'objective-editor-muted', text: 'ストーリーの本文は複製せず、ストーリーが参照する目的とその版を表示します。' }),
|
|
801
|
+
);
|
|
802
|
+
const form = makeElement('form', { className: 'objective-editor-inline-form' });
|
|
803
|
+
const storyField = field('ストーリーID', 'story_id', state.story.storyId ?? '', { attrs: { required: true, placeholder: 'story-...' } });
|
|
804
|
+
form.append(storyField.label, makeElement('button', { className: 'objective-editor-button-secondary', text: '参照を取得', attrs: { type: 'submit' } }));
|
|
805
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); callbacks.onLoadStoryLinks?.(storyField.input.value.trim()); });
|
|
806
|
+
section.append(form);
|
|
807
|
+
if (state.story.state === 'loading') section.append(makeElement('p', { className: 'objective-editor-muted', text: 'ストーリーとの関係を読み込んでいます。' }));
|
|
808
|
+
else if (['permission_denied', 'api_unavailable', 'error_retryable', 'unknown'].includes(state.story.state)) section.append(statusNotice(state.story, { onRetry: callbacks.onRetryStory }));
|
|
809
|
+
else if (state.story.state === 'empty') section.append(makeElement('p', { className: 'objective-editor-empty', text: 'このストーリーから参照される目的はありません。' }));
|
|
810
|
+
else if (state.story.links) {
|
|
811
|
+
const list = makeElement('ul', { className: 'objective-editor-link-list' });
|
|
812
|
+
for (const link of state.story.links) {
|
|
813
|
+
const item = makeElement('li', { className: 'objective-editor-link-item' });
|
|
814
|
+
append(item,
|
|
815
|
+
statusBadge(link.relation, link.relationLabel),
|
|
816
|
+
makeElement('strong', { text: `${link.objective.id}@${link.objective.revision}` }),
|
|
817
|
+
makeElement('span', { text: text(link.objective.meaning, '目的の意味 未確認') }),
|
|
818
|
+
);
|
|
819
|
+
if (link.timeCondition) item.append(makeElement('small', { text: `時間条件: ${JSON.stringify(link.timeCondition)}` }));
|
|
820
|
+
list.append(item);
|
|
821
|
+
}
|
|
822
|
+
section.append(list);
|
|
823
|
+
}
|
|
824
|
+
return section;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Render the common UI from a controller state. Hosts may use this pure
|
|
829
|
+
* renderer when their own application owns navigation and lifecycle.
|
|
830
|
+
*/
|
|
831
|
+
export function renderObjectiveEditor(root, state, callbacks = {}) {
|
|
832
|
+
clear(root);
|
|
833
|
+
const wrapper = makeElement('div', { className: 'objective-editor-root', attrs: { 'data-contract-version': OBJECTIVE_EDITOR_CONTRACT_VERSION, 'data-view': state.view ?? 'list' } });
|
|
834
|
+
const header = makeElement('header', { className: 'objective-editor-header' });
|
|
835
|
+
const copy = makeElement('div', { className: 'objective-editor-header-copy' });
|
|
836
|
+
append(copy,
|
|
837
|
+
makeElement('div', { className: 'objective-editor-eyebrow', text: 'BRAINBASE / OBJECTIVES' }),
|
|
838
|
+
makeElement('h1', { text: '目的と評価基準' }),
|
|
839
|
+
makeElement('p', { text: '目指す状態と評価基準を、版つきで編集・確認します。現状の理解(観測や仮説)とは分けて扱います。' }),
|
|
840
|
+
);
|
|
841
|
+
const headerStatus = state.save.state !== 'idle' ? state.save.state : state.objectives.state;
|
|
842
|
+
header.append(copy, statusBadge(headerStatus));
|
|
843
|
+
wrapper.append(header);
|
|
844
|
+
const toolbar = makeElement('div', { className: 'objective-editor-toolbar' });
|
|
845
|
+
const tabs = [
|
|
846
|
+
['list', '目的一覧'],
|
|
847
|
+
['editor', '目的を編集'],
|
|
848
|
+
...(callbacks.storyLinks === false ? [] : [['story', 'Story参照']]),
|
|
849
|
+
];
|
|
850
|
+
for (const [id, label] of tabs) toolbar.append(makeButton(label, () => callbacks.onView?.(id), `objective-editor-tab${state.view === id ? ' is-active' : ''}`));
|
|
851
|
+
toolbar.append(makeButton('再読込', callbacks.onReload, 'objective-editor-button-secondary'));
|
|
852
|
+
if (callbacks.canEdit) toolbar.append(makeButton('新しい目的', callbacks.onCreate, 'objective-editor-button-primary'));
|
|
853
|
+
wrapper.append(toolbar);
|
|
854
|
+
const connectionState = state.connection;
|
|
855
|
+
if (connectionState && connectionState !== 'ready') wrapper.append(statusNotice({ state: connectionState, message: state.connectionMessage }, { onRetry: callbacks.onReload }));
|
|
856
|
+
const content = makeElement('main', { className: 'objective-editor-content' });
|
|
857
|
+
const renderer = state.view === 'editor' ? renderObjectiveForm
|
|
858
|
+
: state.view === 'story' && callbacks.storyLinks !== false ? renderStoryLinks
|
|
859
|
+
: renderObjectiveList;
|
|
860
|
+
content.append(renderer(content, state, callbacks));
|
|
861
|
+
wrapper.append(content);
|
|
862
|
+
root.append(wrapper);
|
|
863
|
+
return wrapper;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* ---- Workspace layout (the organization edition's pattern; used when the host gives a rail) ---- */
|
|
867
|
+
|
|
868
|
+
const OPERATOR_LABELS = Object.freeze({ at_least: '以上', at_most: '以下', equals: '一致' });
|
|
869
|
+
const FAILED_STATES = Object.freeze(['permission_denied', 'api_unavailable', 'missing', 'conflict', 'error_retryable', 'unknown']);
|
|
870
|
+
|
|
871
|
+
/** `2026/7/1`, or `7/1` when the year is `sameYearAs`'s; text that is not a date stays as it is. */
|
|
872
|
+
function ledgerDate(value, sameYearAs = null) {
|
|
873
|
+
const input = nonEmptyText(value);
|
|
874
|
+
if (!input) return null;
|
|
875
|
+
const date = new Date(input);
|
|
876
|
+
if (Number.isNaN(date.getTime())) return input;
|
|
877
|
+
const day = `${date.getMonth() + 1}/${date.getDate()}`;
|
|
878
|
+
return sameYearAs !== null && sameYearAs === date.getFullYear() ? day : `${date.getFullYear()}/${day}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function periodText(period, { compact = false } = {}) {
|
|
882
|
+
const from = shortDate(period?.from);
|
|
883
|
+
const until = shortDate(period?.until);
|
|
884
|
+
if (!from && !until) return null;
|
|
885
|
+
if (!compact) return `${from ?? '未設定'}〜${until ?? '未設定'}`;
|
|
886
|
+
// In the ledger the dates are short and the end drops its year when it is the start's year.
|
|
887
|
+
const start = new Date(nonEmptyText(period?.from) ?? '');
|
|
888
|
+
const startYear = Number.isNaN(start.getTime()) ? null : start.getFullYear();
|
|
889
|
+
return `${ledgerDate(period?.from) ?? '未設定'}〜${ledgerDate(period?.until, startYear) ?? '未設定'}`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** A state as a kit notice: failures are alerts, and never read as zero items. */
|
|
893
|
+
function workspaceStatusNotice(doc, value, { label, onRetry, retryLabel = '再試行' } = {}) {
|
|
894
|
+
const normalized = typeof value === 'string' ? { state: value } : value ?? {};
|
|
895
|
+
const status = normalizeState(normalized.state);
|
|
896
|
+
const tone = status === 'verified' ? 'info'
|
|
897
|
+
: ['unknown', 'saved_unverified'].includes(status) ? 'warning'
|
|
898
|
+
: 'danger';
|
|
899
|
+
const body = makeElement('div', { className: 'bb-objective-notice-body' });
|
|
900
|
+
body.append(makeElement('p', { text: `${STATUS_LABELS[status] ?? STATUS_LABELS.unknown}:${normalized.message ?? STATUS_LABELS[status] ?? '状態を確認できません。'}` }));
|
|
901
|
+
if (status === 'conflict' && normalized.currentRevision) {
|
|
902
|
+
body.append(makeElement('p', { text: `現在の版: ${normalized.currentRevision}。入力は残しています。現在の版を確かめてから直してください。` }));
|
|
903
|
+
}
|
|
904
|
+
if (onRetry) body.append(workspaceButton(doc, { text: retryLabel, variant: 'quiet', onClick: onRetry }));
|
|
905
|
+
return workspaceNotice(doc, { label, text: body, tone });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function objectiveReadinessCell(objective) {
|
|
909
|
+
const readiness = objective.readiness;
|
|
910
|
+
if (readiness?.state === 'ready' && readiness.ready === false) {
|
|
911
|
+
const count = readiness.issues?.length ?? 0;
|
|
912
|
+
return { text: count > 0 ? `使えない・不足${count}件` : '使えない', className: 'bb-objective-judgment is-draft' };
|
|
913
|
+
}
|
|
914
|
+
const state = objectiveJudgmentState(objective);
|
|
915
|
+
if (state === 'judgment_available') return { text: '使える', className: 'bb-objective-judgment is-available' };
|
|
916
|
+
if (state === 'draft') return { text: '使えない・下書き', className: 'bb-objective-judgment is-draft' };
|
|
917
|
+
return { text: '未確認', className: 'bb-objective-judgment is-unresolved' };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function renderObjectiveMetrics(doc, state) {
|
|
921
|
+
const records = ['ready', 'empty'].includes(state.objectives.state) && Array.isArray(state.objectives.records) ? state.objectives.records : null;
|
|
922
|
+
const judged = records ? records.map((objective) => objectiveJudgmentState(objective)) : [];
|
|
923
|
+
const unknown = judged.filter((value) => value === 'judgment_unknown').length;
|
|
924
|
+
return workspaceMetrics(doc, [
|
|
925
|
+
{ label: '目的', value: records ? records.length : null, note: '読めた目的' },
|
|
926
|
+
{
|
|
927
|
+
label: '判断に使える',
|
|
928
|
+
value: records ? judged.filter((value) => value === 'judgment_available').length : null,
|
|
929
|
+
note: unknown > 0 ? `使えるか未確認 ${unknown}件` : '評価基準と参照が揃った目的',
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
label: '下書き',
|
|
933
|
+
value: records ? records.filter((objective) => ['draft', 'proposed'].includes(objective.adoptionState)).length : null,
|
|
934
|
+
note: '下書き・提案中',
|
|
935
|
+
},
|
|
936
|
+
], { ariaLabel: '目的の集計' });
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const OBJECTIVE_COLUMNS = Object.freeze(['目的', '望ましい状態', '状態', '判断に使えるか', '評価期間']);
|
|
940
|
+
|
|
941
|
+
function renderObjectiveLedger(doc, state, callbacks) {
|
|
942
|
+
const objectives = state.objectives;
|
|
943
|
+
if (objectives.state === 'idle' && state.connection !== 'ready') {
|
|
944
|
+
return workspaceStatusNotice(doc, { state: state.connection, message: state.connectionMessage }, { label: '目的', onRetry: callbacks.onReload, retryLabel: '再読込' });
|
|
945
|
+
}
|
|
946
|
+
if (FAILED_STATES.includes(objectives.state)) {
|
|
947
|
+
return workspaceStatusNotice(doc, objectives, { label: '目的', onRetry: callbacks.onReload, retryLabel: '再読込' });
|
|
948
|
+
}
|
|
949
|
+
const loading = objectives.state === 'loading' || objectives.state === 'idle';
|
|
950
|
+
const records = loading ? [] : objectives.records ?? [];
|
|
951
|
+
return workspaceLedger(doc, {
|
|
952
|
+
className: 'bb-objective-ledger',
|
|
953
|
+
ariaLabel: '目的の一覧',
|
|
954
|
+
columns: OBJECTIVE_COLUMNS,
|
|
955
|
+
empty: loading ? '目的を読み込んでいます。' : '目的はまだ登録されていません。',
|
|
956
|
+
rows: records.map((objective) => {
|
|
957
|
+
const period = periodText(objective.evaluationPeriod, { compact: true });
|
|
958
|
+
return {
|
|
959
|
+
key: objective.id,
|
|
960
|
+
selected: objective.id === state.selectedId,
|
|
961
|
+
onSelect: () => callbacks.onSelect?.(objective),
|
|
962
|
+
cells: [
|
|
963
|
+
{ primary: text(objective.meaning, objective.id), secondary: `${objective.id}@${objective.revision}` },
|
|
964
|
+
objective.desiredState ?? { text: '未設定', className: 'is-unresolved' },
|
|
965
|
+
objective.adoptionState ? (STATUS_LABELS[objective.adoptionState] ?? objective.adoptionState) : { text: '未記録', className: 'is-unresolved' },
|
|
966
|
+
objectiveReadinessCell(objective),
|
|
967
|
+
period ?? { text: '未設定', className: 'is-unresolved' },
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
}),
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Render the workspace (page head, metrics, ledger) and the rail. `host` is
|
|
976
|
+
* what the host adds to the page head: `{ title, lead, sourceNotice, pageActions }`
|
|
977
|
+
* (see `createObjectiveEditorController`).
|
|
978
|
+
*/
|
|
979
|
+
export function renderObjectiveWorkspace(root, rail, state, callbacks = {}, page = {}, host = {}) {
|
|
980
|
+
const doc = getDocument();
|
|
981
|
+
clear(root);
|
|
982
|
+
const wrapper = makeElement('div', {
|
|
983
|
+
className: 'bb-objective-workspace',
|
|
984
|
+
attrs: { 'data-contract-version': OBJECTIVE_EDITOR_CONTRACT_VERSION, 'data-layout': 'workspace' },
|
|
985
|
+
});
|
|
986
|
+
const actions = [{ text: '再読込', onClick: callbacks.onReload }];
|
|
987
|
+
if (callbacks.canEdit) actions.push({ text: '新しい目的', variant: 'primary', onClick: callbacks.onCreate });
|
|
988
|
+
actions.push(...workspaceHostActions(host?.pageActions));
|
|
989
|
+
wrapper.append(workspacePageHeader(doc, {
|
|
990
|
+
crumbs: Array.isArray(page?.crumbs) ? page.crumbs : [],
|
|
991
|
+
title: nonEmptyText(host?.title) ?? '目的と現状',
|
|
992
|
+
lead: nonEmptyText(host?.lead) ?? '目指す状態と評価基準を、版つきで確かめて直します。下の「現状と見通し」は、目的とは分けた、いまの理解(観測と仮説)です。',
|
|
993
|
+
source: nonEmptyText(page?.source),
|
|
994
|
+
actions,
|
|
995
|
+
}));
|
|
996
|
+
const notice = workspaceHostNotice(doc, host?.sourceNotice);
|
|
997
|
+
if (notice) wrapper.append(notice);
|
|
998
|
+
wrapper.append(renderObjectiveMetrics(doc, state), renderObjectiveLedger(doc, state, callbacks));
|
|
999
|
+
if (callbacks.storyLinks !== false) {
|
|
1000
|
+
const story = makeElement('div', { className: 'bb-objective-story' });
|
|
1001
|
+
story.append(renderStoryLinks(story, state, callbacks));
|
|
1002
|
+
wrapper.append(story);
|
|
1003
|
+
}
|
|
1004
|
+
root.append(wrapper);
|
|
1005
|
+
if (rail) renderObjectiveRail(doc, rail, state, callbacks);
|
|
1006
|
+
return wrapper;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function criteriaContent(doc, criteria) {
|
|
1010
|
+
if (criteria === null || criteria === undefined) return makeElement('p', { className: 'is-unrecorded', text: '評価基準を確認できません。' });
|
|
1011
|
+
if (!criteria.length) return makeElement('p', { text: '評価基準はまだありません。判断に使うには、測る変数の参照が必要です。' });
|
|
1012
|
+
const list = makeElement('ul', { className: 'bb-objective-list' });
|
|
1013
|
+
for (const criterion of criteria) {
|
|
1014
|
+
const item = makeElement('li');
|
|
1015
|
+
const operator = OPERATOR_LABELS[criterion.operator] ?? criterion.operator;
|
|
1016
|
+
const target = criterion.target === undefined ? null : optionalText(criterion.target);
|
|
1017
|
+
item.append(
|
|
1018
|
+
makeElement('code', { text: `${criterion.variableRef.id}@${criterion.variableRef.revision}` }),
|
|
1019
|
+
makeElement('span', { text: target === null ? `基準値なし(${operator})` : criterion.operator === 'equals' ? `${target} と一致` : `${target} ${operator}` }),
|
|
1020
|
+
);
|
|
1021
|
+
list.append(item);
|
|
1022
|
+
}
|
|
1023
|
+
return list;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function constraintContent(doc, state, callbacks) {
|
|
1027
|
+
if (state.constraints.state === 'loading') return makeElement('p', { text: '制約の参照を読み込んでいます。' });
|
|
1028
|
+
if (['permission_denied', 'api_unavailable', 'error_retryable', 'unknown'].includes(state.constraints.state)) {
|
|
1029
|
+
return workspaceStatusNotice(doc, state.constraints, { label: '制約', onRetry: callbacks.onReloadConstraints, retryLabel: '再読込' });
|
|
1030
|
+
}
|
|
1031
|
+
const refs = state.editor.constraintRefs ?? state.constraints.refs ?? [];
|
|
1032
|
+
const content = [];
|
|
1033
|
+
if (!refs.length) content.push(makeElement('p', { text: 'この目的に紐づく制約の参照はありません。' }));
|
|
1034
|
+
else {
|
|
1035
|
+
const list = makeElement('ul', { className: 'bb-objective-list' });
|
|
1036
|
+
for (const ref of refs) {
|
|
1037
|
+
const item = makeElement('li');
|
|
1038
|
+
item.append(makeElement('code', { text: `${ref.id}@${ref.revision}` }));
|
|
1039
|
+
if (ref.meaning) item.append(makeElement('span', { text: ref.meaning }));
|
|
1040
|
+
list.append(item);
|
|
1041
|
+
}
|
|
1042
|
+
content.push(list);
|
|
1043
|
+
}
|
|
1044
|
+
content.push(makeElement('p', {
|
|
1045
|
+
className: 'bb-objective-note',
|
|
1046
|
+
text: callbacks.constraintsEditable === false || !callbacks.canEdit
|
|
1047
|
+
? '制約の参照はここでは表示だけです。'
|
|
1048
|
+
: '参照の追加と外しは「目的を直す」から行えます。',
|
|
1049
|
+
}));
|
|
1050
|
+
return content;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function readinessContent(doc, readiness) {
|
|
1054
|
+
const normalized = normalizeReadiness(readiness);
|
|
1055
|
+
if (normalized.state !== 'ready') {
|
|
1056
|
+
return [statusBadge('judgment_unknown'), makeElement('p', { text: '判断に使える状態かどうかを、保存先から確認できていません。' })];
|
|
1057
|
+
}
|
|
1058
|
+
if (normalized.ready) {
|
|
1059
|
+
return [statusBadge('judgment_available'), makeElement('p', { text: '必要な定義・基準・参照が揃っています。観測値の存在は別途確認します。' })];
|
|
1060
|
+
}
|
|
1061
|
+
const content = [statusBadge('draft', '判断に使えない')];
|
|
1062
|
+
if (normalized.issues?.length) {
|
|
1063
|
+
const list = makeElement('ul', { className: 'bb-objective-issues' });
|
|
1064
|
+
for (const issue of normalized.issues) list.append(makeElement('li', { text: `${issue.path}: ${issue.message}` }));
|
|
1065
|
+
content.push(list);
|
|
1066
|
+
}
|
|
1067
|
+
return content;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function renderObjectiveDetail(doc, state, callbacks) {
|
|
1071
|
+
const objective = state.selected;
|
|
1072
|
+
const parts = [workspaceRailHead(doc, { kicker: '目的', title: text(objective.meaning, objective.id), sub: `${objective.id}@${objective.revision}` })];
|
|
1073
|
+
if (state.save.state === 'verified' && state.save.reference?.id === objective.id) {
|
|
1074
|
+
parts.push(workspaceNotice(doc, {
|
|
1075
|
+
label: '保存',
|
|
1076
|
+
text: `${STATUS_LABELS.verified}:保存した版(${state.save.reference.id}@${state.save.reference.revision})を読み戻して確かめました。`,
|
|
1077
|
+
}));
|
|
1078
|
+
}
|
|
1079
|
+
parts.push(workspaceRailBlock(doc, {
|
|
1080
|
+
title: '概要',
|
|
1081
|
+
content: workspaceDefinition(doc, [
|
|
1082
|
+
['望ましい状態', objective.desiredState],
|
|
1083
|
+
['対象者', objective.beneficiaryIds?.length ? objective.beneficiaryIds.join('、') : null],
|
|
1084
|
+
['責任者', objective.accountableId],
|
|
1085
|
+
['採用状態', objective.adoptionState ? (STATUS_LABELS[objective.adoptionState] ?? objective.adoptionState) : null],
|
|
1086
|
+
['評価期間', periodText(objective.evaluationPeriod)],
|
|
1087
|
+
]),
|
|
1088
|
+
}));
|
|
1089
|
+
parts.push(workspaceRailBlock(doc, { title: `評価基準${objective.criteria ? ` ${objective.criteria.length}件` : ''}`, className: 'bb-objective-criteria-block', content: criteriaContent(doc, objective.criteria) }));
|
|
1090
|
+
parts.push(workspaceRailBlock(doc, { title: '制約の参照', className: 'bb-objective-constraints-block', content: constraintContent(doc, state, callbacks) }));
|
|
1091
|
+
parts.push(workspaceRailBlock(doc, { title: '判断に使えるか', className: 'bb-objective-readiness-block', content: readinessContent(doc, state.readiness) }));
|
|
1092
|
+
if (callbacks.canEdit) {
|
|
1093
|
+
parts.push(workspaceActions(doc, [{ text: '目的を直す', variant: 'primary', onClick: callbacks.onEdit }]));
|
|
1094
|
+
}
|
|
1095
|
+
return parts;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function renderObjectiveFormRail(doc, state, callbacks) {
|
|
1099
|
+
const creating = state.editor.mode === 'create';
|
|
1100
|
+
const draft = state.editor.draft;
|
|
1101
|
+
const parts = [workspaceRailHead(doc, {
|
|
1102
|
+
kicker: '目的',
|
|
1103
|
+
title: creating ? '新しい目的' : '目的を直す',
|
|
1104
|
+
sub: creating ? '保存すると版1になります。' : `${draft.id}@${state.editor.expectedRevision ?? draft.revision} を元に新しい版を作ります。`,
|
|
1105
|
+
})];
|
|
1106
|
+
if (state.save.state !== 'idle' && state.save.state !== 'saving') {
|
|
1107
|
+
// A conflict keeps the input; saving again with the same revision would fail again, so no retry there.
|
|
1108
|
+
parts.push(workspaceStatusNotice(doc, state.save, {
|
|
1109
|
+
label: '保存',
|
|
1110
|
+
onRetry: state.save.state === 'conflict' ? null : callbacks.onRetrySave,
|
|
1111
|
+
retryLabel: 'もう一度保存',
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1114
|
+
parts.push(buildObjectiveForm(state, callbacks, { workspace: true }));
|
|
1115
|
+
return parts;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function renderObjectiveRail(doc, rail, state, callbacks) {
|
|
1119
|
+
const panel = makeElement('div', { className: 'bb-objective-rail', attrs: { 'aria-label': '選択中の目的' } });
|
|
1120
|
+
const pending = state.selectedId ? (state.objectives.records ?? []).find((objective) => objective.id === state.selectedId) : null;
|
|
1121
|
+
if (state.railMode === 'form' && state.editor.draft) {
|
|
1122
|
+
panel.setAttribute('data-rail-mode', 'form');
|
|
1123
|
+
panel.append(...renderObjectiveFormRail(doc, state, callbacks));
|
|
1124
|
+
} else if (state.editor.state === 'loading') {
|
|
1125
|
+
panel.setAttribute('data-rail-mode', 'loading');
|
|
1126
|
+
panel.append(
|
|
1127
|
+
workspaceRailHead(doc, { kicker: '目的', title: pending ? text(pending.meaning, pending.id) : '目的', sub: pending ? `${pending.id}@${pending.revision}` : null }),
|
|
1128
|
+
workspaceNotice(doc, { label: '目的', text: '目的を読み込んでいます。' }),
|
|
1129
|
+
);
|
|
1130
|
+
} else if (FAILED_STATES.includes(state.editor.state)) {
|
|
1131
|
+
panel.setAttribute('data-rail-mode', 'error');
|
|
1132
|
+
panel.append(
|
|
1133
|
+
workspaceRailHead(doc, { kicker: '目的', title: pending ? text(pending.meaning, pending.id) : '目的', sub: pending ? `${pending.id}@${pending.revision}` : null }),
|
|
1134
|
+
workspaceStatusNotice(doc, state.editor, { label: '目的', onRetry: pending ? () => callbacks.onSelect?.(pending) : null, retryLabel: '再読込' }),
|
|
1135
|
+
);
|
|
1136
|
+
} else if (state.selected) {
|
|
1137
|
+
panel.setAttribute('data-rail-mode', 'detail');
|
|
1138
|
+
panel.append(...renderObjectiveDetail(doc, state, callbacks));
|
|
1139
|
+
} else {
|
|
1140
|
+
panel.setAttribute('data-rail-mode', 'empty');
|
|
1141
|
+
panel.append(workspaceDetailEmpty(doc, {
|
|
1142
|
+
mark: '目',
|
|
1143
|
+
title: '目的を選択',
|
|
1144
|
+
text: '一覧から選ぶと、評価基準・制約の参照・判断に使えるかを表示します。',
|
|
1145
|
+
}));
|
|
1146
|
+
}
|
|
1147
|
+
rail.replaceChildren(panel);
|
|
1148
|
+
return panel;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function extractMutationRef(payload) {
|
|
1152
|
+
const object = objectValue(payload);
|
|
1153
|
+
if (!object) return null;
|
|
1154
|
+
return normalizeReference(payloadValue(object, 'ref', 'reference', 'objective', 'record', 'data') ?? object, 'objective');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function definitionsEqual(left, right) {
|
|
1158
|
+
if (!left || !right) return false;
|
|
1159
|
+
return left.id === right.id && left.type === right.type && left.revision === right.revision
|
|
1160
|
+
&& (!left.digest || !right.digest || left.digest === right.digest);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function referencesEqual(left, right) {
|
|
1164
|
+
const lhs = (left ?? []).map((ref) => `${ref.type}:${ref.id}@${ref.revision}`).sort();
|
|
1165
|
+
const rhs = (right ?? []).map((ref) => `${ref.type}:${ref.id}@${ref.revision}`).sort();
|
|
1166
|
+
return JSON.stringify(lhs) === JSON.stringify(rhs);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function portMethod(port, name) {
|
|
1170
|
+
return port && typeof port[name] === 'function' ? port[name].bind(port) : null;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Creates a host-injected Objective editor controller.
|
|
1175
|
+
*
|
|
1176
|
+
* ObjectiveEditorPort methods use the same argument order as
|
|
1177
|
+
* CompanyOsObjectives where possible. The host owns FoundationStore,
|
|
1178
|
+
* authentication and Story/Constraint adapters; the UI only supplies the
|
|
1179
|
+
* trusted context it was given and never accepts owner/scope from form data.
|
|
1180
|
+
*
|
|
1181
|
+
* Host options used only in the workspace layout (when `rail` is given); the
|
|
1182
|
+
* tabs layout without `rail` ignores them:
|
|
1183
|
+
* - `title` / `lead`: replace the page head's 目的と現状 and its lead.
|
|
1184
|
+
* - `sourceNotice: { label, text }`: a notice right after the page head
|
|
1185
|
+
* (`text` is a string or an element). Default: none.
|
|
1186
|
+
* - `pageActions: [{ text, variant, onClick, disabled }]`: host-owned buttons in
|
|
1187
|
+
* the page head after 再読込 / 新しい目的.
|
|
1188
|
+
* All four are read from `options` on every render, so a host may change them
|
|
1189
|
+
* and call `render()`.
|
|
1190
|
+
*/
|
|
1191
|
+
export function createObjectiveEditorController(options = {}) {
|
|
1192
|
+
const root = options.root ?? null;
|
|
1193
|
+
// With a rail the screen uses the workspace layout; without one it keeps its tabs.
|
|
1194
|
+
const rail = options.rail ?? null;
|
|
1195
|
+
const page = options.page ?? {};
|
|
1196
|
+
const port = options.port ?? null;
|
|
1197
|
+
const context = options.context ?? {};
|
|
1198
|
+
const canEdit = options.canEdit === true;
|
|
1199
|
+
// Hosts without a constraint-link writer show constraints read-only.
|
|
1200
|
+
const constraintsEditable = options.constraintsEditable !== false;
|
|
1201
|
+
// Hosts without a Story provider hide the Story tab instead of showing an unavailable API.
|
|
1202
|
+
const storyLinks = options.storyLinks !== false;
|
|
1203
|
+
const createDefaults = options.createDefaults ?? {};
|
|
1204
|
+
const state = {
|
|
1205
|
+
view: options.initialView ?? 'list',
|
|
1206
|
+
connection: port ? 'ready' : 'api_unavailable',
|
|
1207
|
+
connectionMessage: port ? null : '目的を読み書きする経路が、この画面に渡されていません。',
|
|
1208
|
+
objectives: { state: 'idle', records: null, absence_confirmed: false },
|
|
1209
|
+
selected: null,
|
|
1210
|
+
readiness: { state: 'unknown', ready: null, issues: null },
|
|
1211
|
+
constraints: { state: 'idle', refs: null, absence_confirmed: false },
|
|
1212
|
+
story: { state: 'idle', storyId: '', links: null, absence_confirmed: false },
|
|
1213
|
+
editor: { state: 'idle', mode: 'edit', draft: null, expectedRevision: null, constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false },
|
|
1214
|
+
save: { state: 'idle' },
|
|
1215
|
+
/** The objective the owner picked (workspace layout: the selected ledger row). */
|
|
1216
|
+
selectedId: null,
|
|
1217
|
+
/** What the rail shows in the workspace layout: the selected objective, or the edit form. */
|
|
1218
|
+
railMode: 'detail',
|
|
1219
|
+
};
|
|
1220
|
+
// The objective shown before 新しい目的, to return to when the owner cancels.
|
|
1221
|
+
let beforeCreate = null;
|
|
1222
|
+
|
|
1223
|
+
function render() {
|
|
1224
|
+
if (!root) return null;
|
|
1225
|
+
const callbacks = {
|
|
1226
|
+
canEdit,
|
|
1227
|
+
constraintsEditable,
|
|
1228
|
+
storyLinks,
|
|
1229
|
+
onView: (view) => { state.view = view; render(); },
|
|
1230
|
+
onReload: loadObjectives,
|
|
1231
|
+
onCreate: beginCreate,
|
|
1232
|
+
onSelect: selectObjective,
|
|
1233
|
+
onBack: () => { state.view = 'list'; render(); },
|
|
1234
|
+
onSubmit: saveObjective,
|
|
1235
|
+
onRetrySave: () => state.editor.mode === 'create' ? saveObjective(state.editor.draft) : saveObjective(state.editor.draft),
|
|
1236
|
+
onAddCriterion: () => { state.editor.draft.criteria = [...(state.editor.draft.criteria ?? []), { variableRef: { id: '', type: 'variable', revision: '' }, operator: 'equals' }]; render(); },
|
|
1237
|
+
onRemoveCriterion: (index) => { state.editor.draft.criteria = (state.editor.draft.criteria ?? []).filter((_, itemIndex) => itemIndex !== index); render(); },
|
|
1238
|
+
onAddConstraintRef: addConstraintRef,
|
|
1239
|
+
onRemoveConstraintRef: removeConstraintRef,
|
|
1240
|
+
onReloadConstraints: () => loadConstraintRefs(state.selected),
|
|
1241
|
+
onLoadStoryLinks: loadStoryLinks,
|
|
1242
|
+
onRetryStory: () => loadStoryLinks(state.story.storyId),
|
|
1243
|
+
onEdit: beginEdit,
|
|
1244
|
+
onCancelEdit: cancelEdit,
|
|
1245
|
+
};
|
|
1246
|
+
return rail ? renderObjectiveWorkspace(root, rail, state, callbacks, page, options) : renderObjectiveEditor(root, state, callbacks);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function setPortState(error, target, fallbackMessage = null) {
|
|
1250
|
+
const normalized = normalizeObjectiveError(error);
|
|
1251
|
+
target.state = normalized.state;
|
|
1252
|
+
target.message = normalized.message ?? fallbackMessage;
|
|
1253
|
+
target.code = normalized.code;
|
|
1254
|
+
target.currentRevision = normalized.currentRevision;
|
|
1255
|
+
return normalized;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function loadObjectives() {
|
|
1259
|
+
state.objectives = { state: 'loading', records: null, absence_confirmed: false };
|
|
1260
|
+
state.connection = port ? 'ready' : 'api_unavailable';
|
|
1261
|
+
render();
|
|
1262
|
+
const method = portMethod(port, 'listObjectives');
|
|
1263
|
+
if (!method) {
|
|
1264
|
+
state.objectives = { state: 'api_unavailable', records: null, absence_confirmed: false, message: '目的の一覧を読む経路がありません。' };
|
|
1265
|
+
state.connection = 'api_unavailable';
|
|
1266
|
+
render();
|
|
1267
|
+
return state.objectives;
|
|
1268
|
+
}
|
|
1269
|
+
try {
|
|
1270
|
+
state.objectives = normalizeObjectiveCollection(await method(context));
|
|
1271
|
+
state.connection = state.objectives.state === 'ready' || state.objectives.state === 'empty' ? 'ready' : state.objectives.state;
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
state.objectives = { state: normalizeObjectiveError(error).state, records: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
|
|
1274
|
+
state.connection = state.objectives.state;
|
|
1275
|
+
}
|
|
1276
|
+
render();
|
|
1277
|
+
// As on the organization screens, the workspace opens with the first row selected.
|
|
1278
|
+
const first = state.objectives.records?.[0];
|
|
1279
|
+
if (rail && first && !state.selectedId && state.railMode !== 'form') await selectObjective(first);
|
|
1280
|
+
return state.objectives;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async function loadConstraintRefs(objective) {
|
|
1284
|
+
state.constraints = { state: 'loading', refs: null, absence_confirmed: false };
|
|
1285
|
+
render();
|
|
1286
|
+
if (!objective) {
|
|
1287
|
+
state.constraints = { state: 'unknown', refs: null, absence_confirmed: false, message: '目的が選ばれていません。' };
|
|
1288
|
+
render();
|
|
1289
|
+
return state.constraints;
|
|
1290
|
+
}
|
|
1291
|
+
const method = portMethod(port, 'listObjectiveConstraintRefs') ?? portMethod(port, 'listConstraintReferences');
|
|
1292
|
+
if (!method) {
|
|
1293
|
+
state.constraints = { state: 'api_unavailable', refs: null, absence_confirmed: false, message: '目的と制約の参照を読む経路がありません。' };
|
|
1294
|
+
state.editor.constraintRefs = null;
|
|
1295
|
+
render();
|
|
1296
|
+
return state.constraints;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const payload = await method({ id: objective.id, type: 'objective', revision: objective.revision }, context);
|
|
1300
|
+
state.constraints = normalizeReferenceCollection(payload, ['refs', 'references', 'constraints', 'records', 'items', 'data']);
|
|
1301
|
+
if (state.constraints.refs) {
|
|
1302
|
+
state.editor.constraintRefs = state.constraints.refs;
|
|
1303
|
+
state.editor.originalConstraintRefs = JSON.parse(JSON.stringify(state.constraints.refs));
|
|
1304
|
+
state.editor.constraintRefsChanged = false;
|
|
1305
|
+
}
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
state.constraints = { state: normalizeObjectiveError(error).state, refs: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
|
|
1308
|
+
}
|
|
1309
|
+
render();
|
|
1310
|
+
return state.constraints;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function loadReadiness(objective) {
|
|
1314
|
+
if (!objective) return state.readiness;
|
|
1315
|
+
const method = portMethod(port, 'checkObjectiveReadiness');
|
|
1316
|
+
if (!method) {
|
|
1317
|
+
state.readiness = { state: 'unknown', ready: null, issues: null, message: '判断に使える状態を確かめる経路がありません。' };
|
|
1318
|
+
render();
|
|
1319
|
+
return state.readiness;
|
|
1320
|
+
}
|
|
1321
|
+
try {
|
|
1322
|
+
state.readiness = normalizeReadiness(await method(objective.id, context, objective.revision));
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
state.readiness = { state: normalizeObjectiveError(error).state, ready: null, issues: null, ...normalizeObjectiveError(error) };
|
|
1325
|
+
}
|
|
1326
|
+
if (state.selected) state.selected.readiness = state.readiness;
|
|
1327
|
+
render();
|
|
1328
|
+
return state.readiness;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function selectObjective(objectiveOrReference) {
|
|
1332
|
+
const reference = normalizeReference(objectiveOrReference, 'objective') ?? objectiveOrReference;
|
|
1333
|
+
const id = reference?.id;
|
|
1334
|
+
const revision = reference?.revision;
|
|
1335
|
+
if (!id) return null;
|
|
1336
|
+
state.selectedId = id;
|
|
1337
|
+
if (rail) {
|
|
1338
|
+
state.railMode = 'detail';
|
|
1339
|
+
state.save = { state: 'idle' };
|
|
1340
|
+
beforeCreate = null;
|
|
1341
|
+
}
|
|
1342
|
+
state.view = 'editor';
|
|
1343
|
+
state.editor = { state: 'loading', mode: 'edit', draft: null, expectedRevision: revision ?? null, constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false };
|
|
1344
|
+
state.readiness = { state: 'unknown', ready: null, issues: null };
|
|
1345
|
+
state.constraints = { state: 'loading', refs: null, absence_confirmed: false };
|
|
1346
|
+
render();
|
|
1347
|
+
const method = portMethod(port, 'readObjective');
|
|
1348
|
+
if (!method) {
|
|
1349
|
+
state.editor = { ...state.editor, state: 'api_unavailable', message: '目的を読む経路がありません。' };
|
|
1350
|
+
state.connection = 'api_unavailable';
|
|
1351
|
+
render();
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
const payload = await method(id, context, revision);
|
|
1356
|
+
const objective = normalizeObjectiveRecord(payload);
|
|
1357
|
+
if (!objective) {
|
|
1358
|
+
state.editor = { ...state.editor, state: 'missing', message: '指定した目的を保存先から確認できません。' };
|
|
1359
|
+
state.connection = 'missing';
|
|
1360
|
+
render();
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
state.selected = objective;
|
|
1364
|
+
state.editor = {
|
|
1365
|
+
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(objective), expectedRevision: objective.revision,
|
|
1366
|
+
constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false,
|
|
1367
|
+
};
|
|
1368
|
+
state.connection = 'ready';
|
|
1369
|
+
render();
|
|
1370
|
+
await Promise.allSettled([loadReadiness(objective), loadConstraintRefs(objective)]);
|
|
1371
|
+
return objective;
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
const normalized = normalizeObjectiveError(error);
|
|
1374
|
+
state.editor = { ...state.editor, state: normalized.state, message: normalized.message, error: normalized.error };
|
|
1375
|
+
state.connection = normalized.state;
|
|
1376
|
+
render();
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function beginCreate() {
|
|
1382
|
+
if (rail && state.railMode !== 'form') beforeCreate = state.selected;
|
|
1383
|
+
state.railMode = 'form';
|
|
1384
|
+
state.view = 'editor';
|
|
1385
|
+
state.selected = null;
|
|
1386
|
+
state.readiness = { state: 'unknown', ready: null, issues: null };
|
|
1387
|
+
state.constraints = { state: 'empty', refs: [], absence_confirmed: true };
|
|
1388
|
+
state.editor = {
|
|
1389
|
+
state: 'ready', mode: 'create', draft: editorDraftFromObjective({
|
|
1390
|
+
id: '', revision: '1', meaning: '', beneficiaryIds: [], desiredState: '', criteria: [],
|
|
1391
|
+
evaluationPeriod: { from: '', until: '' }, adoptionState: 'draft', authorizedUses: ['draft'],
|
|
1392
|
+
definition: { type: 'objective', revision: '1', ...createDefaults },
|
|
1393
|
+
}), expectedRevision: null, constraintRefs: [], originalConstraintRefs: [], constraintRefsChanged: false,
|
|
1394
|
+
};
|
|
1395
|
+
state.save = { state: 'idle' };
|
|
1396
|
+
render();
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Workspace layout: open the edit form for the selected objective in the rail. */
|
|
1400
|
+
function beginEdit() {
|
|
1401
|
+
if (!state.selected) return;
|
|
1402
|
+
state.editor = {
|
|
1403
|
+
...state.editor,
|
|
1404
|
+
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(state.selected), expectedRevision: state.selected.revision,
|
|
1405
|
+
constraintRefs: state.constraints.refs ?? state.editor.constraintRefs ?? null,
|
|
1406
|
+
originalConstraintRefs: state.constraints.refs ? JSON.parse(JSON.stringify(state.constraints.refs)) : state.editor.originalConstraintRefs,
|
|
1407
|
+
constraintRefsChanged: false,
|
|
1408
|
+
};
|
|
1409
|
+
state.save = { state: 'idle' };
|
|
1410
|
+
state.railMode = 'form';
|
|
1411
|
+
render();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Workspace layout: leave the form without saving and show the objective again. */
|
|
1415
|
+
function cancelEdit() {
|
|
1416
|
+
const creating = state.editor.mode === 'create';
|
|
1417
|
+
state.railMode = 'detail';
|
|
1418
|
+
state.save = { state: 'idle' };
|
|
1419
|
+
if (creating) {
|
|
1420
|
+
const previous = beforeCreate;
|
|
1421
|
+
beforeCreate = null;
|
|
1422
|
+
state.editor = { state: 'idle', mode: 'edit', draft: null, expectedRevision: null, constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false };
|
|
1423
|
+
state.selectedId = null;
|
|
1424
|
+
if (previous) {
|
|
1425
|
+
void selectObjective(previous);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
render();
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (state.selected) {
|
|
1432
|
+
state.editor = {
|
|
1433
|
+
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(state.selected), expectedRevision: state.selected.revision,
|
|
1434
|
+
constraintRefs: state.constraints.refs ?? null, originalConstraintRefs: state.constraints.refs ?? null, constraintRefsChanged: false,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
render();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function addConstraintRef(reference) {
|
|
1441
|
+
if (!constraintsEditable) return;
|
|
1442
|
+
const normalized = normalizeReference(reference, 'constraint');
|
|
1443
|
+
if (!normalized || normalized.type !== 'constraint') return;
|
|
1444
|
+
const refs = state.editor.constraintRefs ?? [];
|
|
1445
|
+
if (refs.some((item) => item.id === normalized.id && item.revision === normalized.revision)) return;
|
|
1446
|
+
state.editor.constraintRefs = [...refs, normalized];
|
|
1447
|
+
state.editor.constraintRefsChanged = true;
|
|
1448
|
+
render();
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function removeConstraintRef(reference) {
|
|
1452
|
+
if (!constraintsEditable) return;
|
|
1453
|
+
state.editor.constraintRefs = (state.editor.constraintRefs ?? []).filter((item) => !(item.id === reference.id && item.revision === reference.revision));
|
|
1454
|
+
state.editor.constraintRefsChanged = true;
|
|
1455
|
+
render();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async function saveObjective(definition) {
|
|
1459
|
+
if (!definition) return null;
|
|
1460
|
+
const mode = state.editor.mode;
|
|
1461
|
+
const updateMethod = mode === 'create' ? null : portMethod(port, 'updateObjective');
|
|
1462
|
+
const createMethod = mode === 'create' ? portMethod(port, 'createObjective') : null;
|
|
1463
|
+
if ((mode === 'create' && !createMethod) || (mode !== 'create' && !updateMethod)) {
|
|
1464
|
+
state.save = { state: 'api_unavailable', message: '目的を保存する経路がありません。' };
|
|
1465
|
+
render();
|
|
1466
|
+
return state.save;
|
|
1467
|
+
}
|
|
1468
|
+
if (state.editor.constraintRefsChanged && !portMethod(port, 'replaceObjectiveConstraintRefs')) {
|
|
1469
|
+
state.save = { state: 'api_unavailable', message: '制約の参照を保存する経路が無いため、目的の本文も保存していません。' };
|
|
1470
|
+
render();
|
|
1471
|
+
return state.save;
|
|
1472
|
+
}
|
|
1473
|
+
state.editor.draft = definition;
|
|
1474
|
+
state.save = { state: 'saving', action: mode };
|
|
1475
|
+
render();
|
|
1476
|
+
try {
|
|
1477
|
+
const mutation = mode === 'create'
|
|
1478
|
+
? await createMethod(definition, context)
|
|
1479
|
+
: await updateMethod(definition.id, state.editor.expectedRevision, definition, context);
|
|
1480
|
+
const savedRef = extractMutationRef(mutation);
|
|
1481
|
+
if (!savedRef || savedRef.id !== definition.id || savedRef.type !== 'objective' || !savedRef.revision) {
|
|
1482
|
+
state.save = { state: 'saved_unverified', message: '保存の応答に、同じ目的の新しい版が含まれていません。', mutation };
|
|
1483
|
+
render();
|
|
1484
|
+
return state.save;
|
|
1485
|
+
}
|
|
1486
|
+
const expectedRevision = state.editor.expectedRevision;
|
|
1487
|
+
if (mode !== 'create' && (!expectedRevision || savedRef.revision === expectedRevision)) {
|
|
1488
|
+
state.save = {
|
|
1489
|
+
state: 'saved_unverified',
|
|
1490
|
+
message: '更新応答にexpectedRevisionとは異なる新版が含まれていません。',
|
|
1491
|
+
mutation,
|
|
1492
|
+
expectedRevision,
|
|
1493
|
+
reference: savedRef,
|
|
1494
|
+
};
|
|
1495
|
+
render();
|
|
1496
|
+
return state.save;
|
|
1497
|
+
}
|
|
1498
|
+
const readMethod = portMethod(port, 'readObjective');
|
|
1499
|
+
const readback = readMethod ? normalizeObjectiveRecord(await readMethod(savedRef.id, context, savedRef.revision)) : null;
|
|
1500
|
+
const returnedDefinition = readback ? { id: readback.id, type: readback.type, revision: readback.revision, digest: readback.digest } : null;
|
|
1501
|
+
const referenceMatches = definitionsEqual(savedRef, returnedDefinition);
|
|
1502
|
+
if (!referenceMatches || !readback || (mode !== 'create' && (!expectedRevision || readback.revision === expectedRevision))) {
|
|
1503
|
+
state.save = { state: 'saved_unverified', message: '保存した新しい版を読み戻して確かめられません。', mutation, readback };
|
|
1504
|
+
render();
|
|
1505
|
+
return state.save;
|
|
1506
|
+
}
|
|
1507
|
+
if (state.editor.constraintRefsChanged) {
|
|
1508
|
+
const replaceRefs = portMethod(port, 'replaceObjectiveConstraintRefs');
|
|
1509
|
+
await replaceRefs(savedRef, state.editor.constraintRefs ?? [], context);
|
|
1510
|
+
const readConstraintRefs = portMethod(port, 'listObjectiveConstraintRefs') ?? portMethod(port, 'listConstraintReferences');
|
|
1511
|
+
const constraintPayload = readConstraintRefs
|
|
1512
|
+
? await readConstraintRefs(savedRef, context)
|
|
1513
|
+
: null;
|
|
1514
|
+
const verifiedRefs = normalizeReferenceCollection(constraintPayload).refs;
|
|
1515
|
+
if (!verifiedRefs || !referencesEqual(verifiedRefs, state.editor.constraintRefs)) {
|
|
1516
|
+
state.save = { state: 'saved_unverified', message: '目的は保存されましたが、制約の参照を読み戻した内容が一致しません。', mutation, readback, constraintRefs: verifiedRefs };
|
|
1517
|
+
render();
|
|
1518
|
+
return state.save;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
state.selected = readback;
|
|
1522
|
+
state.selectedId = readback.id;
|
|
1523
|
+
state.railMode = 'detail';
|
|
1524
|
+
beforeCreate = null;
|
|
1525
|
+
state.editor = {
|
|
1526
|
+
state: 'ready', mode: 'edit', draft: editorDraftFromObjective(readback), expectedRevision: readback.revision,
|
|
1527
|
+
constraintRefs: state.editor.constraintRefs ?? [], originalConstraintRefs: state.editor.constraintRefs ?? [], constraintRefsChanged: false,
|
|
1528
|
+
};
|
|
1529
|
+
state.save = { state: 'verified', action: mode, reference: savedRef, readback };
|
|
1530
|
+
state.connection = 'ready';
|
|
1531
|
+
// Readiness belongs to a revision; re-read it for the one just saved.
|
|
1532
|
+
state.readiness = { state: 'unknown', ready: null, issues: null };
|
|
1533
|
+
render();
|
|
1534
|
+
await loadReadiness(readback);
|
|
1535
|
+
await loadObjectives();
|
|
1536
|
+
return state.save;
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
const normalized = normalizeObjectiveError(error);
|
|
1539
|
+
state.save = { state: normalized.state, message: normalized.message, currentRevision: normalized.currentRevision, error: normalized.error, draft: definition };
|
|
1540
|
+
render();
|
|
1541
|
+
return state.save;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
async function loadStoryLinks(storyId) {
|
|
1546
|
+
state.view = 'story';
|
|
1547
|
+
state.story = { state: 'loading', storyId: storyId ?? '', links: null, absence_confirmed: false };
|
|
1548
|
+
render();
|
|
1549
|
+
const method = portMethod(port, 'listStoryObjectiveLinks');
|
|
1550
|
+
if (!method) {
|
|
1551
|
+
state.story = { state: 'api_unavailable', storyId, links: null, absence_confirmed: false, message: 'ストーリーと目的の関係を読む経路がありません。' };
|
|
1552
|
+
render();
|
|
1553
|
+
return state.story;
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
state.story = { ...normalizeStoryObjectiveLinks(await method(storyId, context)), storyId };
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
state.story = { state: normalizeObjectiveError(error).state, storyId, links: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
|
|
1559
|
+
}
|
|
1560
|
+
render();
|
|
1561
|
+
return state.story;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
const controller = {
|
|
1565
|
+
state,
|
|
1566
|
+
render,
|
|
1567
|
+
loadObjectives,
|
|
1568
|
+
selectObjective,
|
|
1569
|
+
beginCreate,
|
|
1570
|
+
saveObjective,
|
|
1571
|
+
loadConstraintRefs,
|
|
1572
|
+
loadReadiness,
|
|
1573
|
+
loadStoryLinks,
|
|
1574
|
+
addConstraintRef,
|
|
1575
|
+
removeConstraintRef,
|
|
1576
|
+
beginEdit,
|
|
1577
|
+
cancelEdit,
|
|
1578
|
+
contractVersion: OBJECTIVE_EDITOR_CONTRACT_VERSION,
|
|
1579
|
+
};
|
|
1580
|
+
render();
|
|
1581
|
+
if (options.autoLoad !== false) void loadObjectives();
|
|
1582
|
+
return controller;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
export const createObjectiveEditorModule = createObjectiveEditorController;
|
|
1586
|
+
export default createObjectiveEditorController;
|