@things-factory/worklist 10.1.36 → 10.1.38
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/dist-client/components/activity-instance-preview.js +2 -13
- package/dist-client/components/activity-instance-preview.js.map +1 -1
- package/dist-client/components/activity-thread-timeline.d.ts +2 -2
- package/dist-client/components/can-pick.d.ts +15 -0
- package/dist-client/components/can-pick.js +16 -0
- package/dist-client/components/can-pick.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +1 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/pages/activity-catalog/activity-catalog-page.js +50 -40
- package/dist-client/pages/activity-catalog/activity-catalog-page.js.map +1 -1
- package/dist-client/pages/activity-catalog/activity-detail-actions.d.ts +34 -0
- package/dist-client/pages/activity-catalog/activity-detail-actions.js +18 -0
- package/dist-client/pages/activity-catalog/activity-detail-actions.js.map +1 -0
- package/dist-client/pages/activity-catalog/activity-detail-page.d.ts +68 -0
- package/dist-client/pages/activity-catalog/activity-detail-page.js +489 -0
- package/dist-client/pages/activity-catalog/activity-detail-page.js.map +1 -0
- package/dist-client/pages/activity-catalog/activity-guide.d.ts +16 -0
- package/dist-client/pages/activity-catalog/activity-guide.js +5 -0
- package/dist-client/pages/activity-catalog/activity-guide.js.map +1 -0
- package/dist-client/pages/activity-catalog/activity-panels.d.ts +80 -0
- package/dist-client/pages/activity-catalog/activity-panels.js +53 -0
- package/dist-client/pages/activity-catalog/activity-panels.js.map +1 -0
- package/dist-client/pages/activity-catalog/activity-stage.d.ts +31 -0
- package/dist-client/pages/activity-catalog/activity-stage.js +44 -0
- package/dist-client/pages/activity-catalog/activity-stage.js.map +1 -0
- package/dist-client/pages/activity-instance/activity-instance-start-page.d.ts +1 -0
- package/dist-client/pages/activity-instance/activity-instance-start-page.js +33 -1
- package/dist-client/pages/activity-instance/activity-instance-start-page.js.map +1 -1
- package/dist-client/pages/activity-instance/discard-offer.d.ts +11 -0
- package/dist-client/pages/activity-instance/discard-offer.js +11 -0
- package/dist-client/pages/activity-instance/discard-offer.js.map +1 -0
- package/dist-client/pages/activity-thread/activity-thread-view.d.ts +1 -1
- package/dist-client/pages/todo/approval-done-list-page.d.ts +5 -1
- package/dist-client/pages/todo/approval-done-list-page.js +8 -1
- package/dist-client/pages/todo/approval-done-list-page.js.map +1 -1
- package/dist-client/pages/todo/approval-pending-list-page.d.ts +5 -1
- package/dist-client/pages/todo/approval-pending-list-page.js +8 -1
- package/dist-client/pages/todo/approval-pending-list-page.js.map +1 -1
- package/dist-client/pages/todo/done-list-page.d.ts +5 -1
- package/dist-client/pages/todo/done-list-page.js +8 -1
- package/dist-client/pages/todo/done-list-page.js.map +1 -1
- package/dist-client/pages/todo/draft-list-page.d.ts +5 -1
- package/dist-client/pages/todo/draft-list-page.js +18 -12
- package/dist-client/pages/todo/draft-list-page.js.map +1 -1
- package/dist-client/pages/todo/list-thumbnail.d.ts +25 -0
- package/dist-client/pages/todo/list-thumbnail.js +25 -0
- package/dist-client/pages/todo/list-thumbnail.js.map +1 -0
- package/dist-client/pages/todo/todo-list-page.d.ts +5 -1
- package/dist-client/pages/todo/todo-list-page.js +8 -1
- package/dist-client/pages/todo/todo-list-page.js.map +1 -1
- package/dist-client/pages/work-door/door-links.d.ts +30 -0
- package/dist-client/pages/work-door/door-links.js +30 -0
- package/dist-client/pages/work-door/door-links.js.map +1 -0
- package/dist-client/pages/work-door/work-doors-page.js +15 -5
- package/dist-client/pages/work-door/work-doors-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +3 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/activity-approval/delegate.js +27 -2
- package/dist-server/controllers/activity-approval/delegate.js.map +1 -1
- package/dist-server/controllers/activity-approval/delegation.d.ts +31 -0
- package/dist-server/controllers/activity-approval/delegation.js +31 -0
- package/dist-server/controllers/activity-approval/delegation.js.map +1 -0
- package/dist-server/controllers/activity-instance/discard-draft.d.ts +22 -0
- package/dist-server/controllers/activity-instance/discard-draft.js +54 -0
- package/dist-server/controllers/activity-instance/discard-draft.js.map +1 -0
- package/dist-server/controllers/activity-read/assert-may-read.d.ts +15 -0
- package/dist-server/controllers/activity-read/assert-may-read.js +99 -0
- package/dist-server/controllers/activity-read/assert-may-read.js.map +1 -0
- package/dist-server/controllers/activity-read/participates.d.ts +41 -0
- package/dist-server/controllers/activity-read/participates.js +42 -0
- package/dist-server/controllers/activity-read/participates.js.map +1 -0
- package/dist-server/controllers/activity-thread/delegate.js +23 -1
- package/dist-server/controllers/activity-thread/delegate.js.map +1 -1
- package/dist-server/service/activity/activity-mutation.js +1 -0
- package/dist-server/service/activity/activity-mutation.js.map +1 -1
- package/dist-server/service/activity-approval/activity-approval-query.js +7 -1
- package/dist-server/service/activity-approval/activity-approval-query.js.map +1 -1
- package/dist-server/service/activity-approval/activity-approval-subscription.js +2 -11
- package/dist-server/service/activity-approval/activity-approval-subscription.js.map +1 -1
- package/dist-server/service/activity-instance/activity-instance-mutation.js +12 -20
- package/dist-server/service/activity-instance/activity-instance-mutation.js.map +1 -1
- package/dist-server/service/activity-instance/activity-instance-query.js +30 -9
- package/dist-server/service/activity-instance/activity-instance-query.js.map +1 -1
- package/dist-server/service/activity-instance/activity-instance-subscription.js +4 -12
- package/dist-server/service/activity-instance/activity-instance-subscription.js.map +1 -1
- package/dist-server/service/activity-instance/draft-list-states.d.ts +39 -0
- package/dist-server/service/activity-instance/draft-list-states.js +51 -0
- package/dist-server/service/activity-instance/draft-list-states.js.map +1 -0
- package/dist-server/service/activity-instance/index.d.ts +1 -2
- package/dist-server/service/activity-instance/index.js +1 -2
- package/dist-server/service/activity-instance/index.js.map +1 -1
- package/dist-server/service/activity-template/activity-template-mutation.js +6 -0
- package/dist-server/service/activity-template/activity-template-mutation.js.map +1 -1
- package/dist-server/service/activity-thread/activity-thread-mutation.d.ts +0 -3
- package/dist-server/service/activity-thread/activity-thread-mutation.js +0 -19
- package/dist-server/service/activity-thread/activity-thread-mutation.js.map +1 -1
- package/dist-server/service/activity-thread/activity-thread-query.js +7 -1
- package/dist-server/service/activity-thread/activity-thread-query.js.map +1 -1
- package/dist-server/service/activity-thread/activity-thread-subscription.js +2 -8
- package/dist-server/service/activity-thread/activity-thread-subscription.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -2
- package/dist-server/service/subscription/subscription-filters.d.ts +36 -0
- package/dist-server/service/subscription/subscription-filters.js +41 -0
- package/dist-server/service/subscription/subscription-filters.js.map +1 -0
- package/dist-server/service/work-door/work-door-declaration.d.ts +15 -0
- package/dist-server/service/work-door/work-door-declaration.js +18 -0
- package/dist-server/service/work-door/work-door-declaration.js.map +1 -1
- package/dist-server/service/work-door/work-door-resolver.d.ts +3 -0
- package/dist-server/service/work-door/work-door-resolver.js +21 -1
- package/dist-server/service/work-door/work-door-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/tests/delegate-approval.test.ts +112 -0
- package/tests/discard-draft.test.ts +145 -0
- package/tests/may-read-case.test.ts +166 -0
- package/tests/open-doors.test.ts +274 -0
- package/tests/published-shape.test.ts +85 -0
- package/tests/work-door-not-used-refuse.test.ts +110 -0
- package/things-factory.config.js +1 -0
- package/translations/en.json +32 -3
- package/translations/ja.json +33 -4
- package/translations/ko.json +33 -4
- package/translations/ms.json +32 -3
- package/translations/zh.json +33 -4
- package/dist-server/service/activity-instance/activity-instance-history-query.d.ts +0 -12
- package/dist-server/service/activity-instance/activity-instance-history-query.js +0 -79
- package/dist-server/service/activity-instance/activity-instance-history-query.js.map +0 -1
- package/dist-server/service/activity-instance/activity-instance-history-type.d.ts +0 -5
- package/dist-server/service/activity-instance/activity-instance-history-type.js +0 -21
- package/dist-server/service/activity-instance/activity-instance-history-type.js.map +0 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
import { buildSchema } from 'type-graphql'
|
|
5
|
+
import { GraphQLSchema } from 'graphql'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Which of worklist's doors are open, and why each one is allowed to be.
|
|
9
|
+
*
|
|
10
|
+
* ── The thing this catches ──────────────────────────────────────────────────
|
|
11
|
+
* `@privilege` is opt-in. A root field that declares nothing is recorded as open, and that is
|
|
12
|
+
* indistinguishable from a field somebody decided to open. `deleteActivities` went in that way: its
|
|
13
|
+
* neighbours `deleteActivity` and `createActivity` ask for the activity privilege, it does not.
|
|
14
|
+
*
|
|
15
|
+
* Every open field must be on a list below, with its reason. A new open field fails. A field that
|
|
16
|
+
* gets closed fails too, until it is moved to CLOSED - so closing is visible as well.
|
|
17
|
+
*
|
|
18
|
+
* ── Why measured and not read off the source ────────────────────────────────
|
|
19
|
+
* The schema is built from `service/index.ts`'s own `resolverClasses`, the list the server boots
|
|
20
|
+
* from, and the directive fills the privilege table while it walks it. A pattern over the files
|
|
21
|
+
* answered 45 where the schema has 47 - it missed fields declared with `@Args(type => ListParam)`.
|
|
22
|
+
*
|
|
23
|
+
* ── Why the compiled output ──────────────────────────────────────────────────
|
|
24
|
+
* The entities use type-graphql decorators with no explicit type, which need
|
|
25
|
+
* `emitDecoratorMetadata`; jest's babel transform does not emit it (auth-base `tests/compiled.ts`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DIST = join(__dirname, '..', 'dist-server')
|
|
29
|
+
const AUTH_BASE_DIST = join(__dirname, '..', '..', 'auth-base', 'dist-server')
|
|
30
|
+
|
|
31
|
+
function loadCompiled<T = any>(file: string): T {
|
|
32
|
+
if (!existsSync(file)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`compiled output missing: ${file}\n` +
|
|
35
|
+
`this test reads the tsc output (see the header). build first:\n` +
|
|
36
|
+
` yarn workspace @things-factory/worklist build:server`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return require(file)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { schema: declared } = loadCompiled(join(DIST, 'service', 'index.js'))
|
|
44
|
+
const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled(
|
|
45
|
+
join(AUTH_BASE_DIST, 'service', 'privilege', 'privilege-directive.js')
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
type Root = 'Query' | 'Mutation' | 'Subscription'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Open on purpose. Each one answers about the caller, is a catalogue of this domain, or checks
|
|
52
|
+
* who may act inside its own body.
|
|
53
|
+
*/
|
|
54
|
+
const OPEN_BY_DECISION: Record<Root, Record<string, string>> = {
|
|
55
|
+
Query: {
|
|
56
|
+
/* The caller's own. */
|
|
57
|
+
todoList: "the caller's open threads - myOpenThreadsWhere(user, roles)",
|
|
58
|
+
doneList: "threads the caller ended - at.assignee = the caller",
|
|
59
|
+
draftList: 'instances the caller made - ai.creator = the caller',
|
|
60
|
+
pickableActivityList: "instances waiting for a role the caller holds, or for nobody's role",
|
|
61
|
+
approvalPendingList: "approvals waiting for the caller or the caller's roles - myPendingApprovalsWhere",
|
|
62
|
+
approvalDoneList: 'approvals the caller judged - ap.approver = the caller',
|
|
63
|
+
approvalReferenceList: 'approvals the caller is a reference on - ap.approver = the caller',
|
|
64
|
+
approvalCompletedList: 'approvals the caller took part in - ap.approver = the caller',
|
|
65
|
+
activitySummary: "counts of the caller's own threads and approvals",
|
|
66
|
+
activityEvents: "the caller's own threads and approvals, for the calendar",
|
|
67
|
+
|
|
68
|
+
/* A catalogue of this domain - what can be started, not anybody's case. */
|
|
69
|
+
activities: 'activity definitions of this domain',
|
|
70
|
+
activity: 'one activity definition of this domain',
|
|
71
|
+
activityByName: 'one activity definition of this domain, by name',
|
|
72
|
+
startableActivities: 'definitions the caller may start here',
|
|
73
|
+
supervisableActivities: 'definitions the caller supervises here',
|
|
74
|
+
installableActivities: 'what the installed modules declare - the same for every domain',
|
|
75
|
+
installableActivity: 'one declaration of an installed module',
|
|
76
|
+
activityTemplates: 'activity templates of this domain',
|
|
77
|
+
activityTemplate: 'one activity template of this domain'
|
|
78
|
+
},
|
|
79
|
+
Mutation: {
|
|
80
|
+
/* Checked in the body: checkAuthority refuses anyone who does not hold the seat. */
|
|
81
|
+
startActivityThread: 'checkAuthority - only the assignee',
|
|
82
|
+
restartActivityThread: 'checkAuthority - only the assignee',
|
|
83
|
+
saveActivityThread: 'checkAuthority - only the assignee',
|
|
84
|
+
submitActivityThread: 'checkAuthority - only the assignee',
|
|
85
|
+
endActivityThread: 'checkAuthority - only the assignee',
|
|
86
|
+
abortActivityThread: 'checkAuthority - only the assignee',
|
|
87
|
+
delegateActivityThread: 'checkAuthority - only the assignee',
|
|
88
|
+
approveActivityApproval: 'checkAuthority - only the approver, never the issuer (self-approval)',
|
|
89
|
+
rejectActivityApproval: 'checkAuthority - only the approver',
|
|
90
|
+
saveActivityApproval: 'checkAuthority - only the approver',
|
|
91
|
+
delegateActivityApproval: 'checkAuthority - only the approver',
|
|
92
|
+
|
|
93
|
+
/* Checked in the body: entitlement.ts, with the reason in its header. */
|
|
94
|
+
draftActivityInstance: 'entitlement - the caller may start this activity',
|
|
95
|
+
issueActivityInstance: 'entitlement - the caller may start this activity; refuses an ungated issue',
|
|
96
|
+
pickActivityInstance: "entitlement - the instance waits for a role the caller holds",
|
|
97
|
+
deleteDraftActivityInstance:
|
|
98
|
+
'entitlement - the writer or a manager, drafts only, the state checked in the delete itself (ruling 2026-09-24)'
|
|
99
|
+
},
|
|
100
|
+
Subscription: {
|
|
101
|
+
/* Each filter compares the event's domain first, then the person - test/subscription-filters.test.ts. */
|
|
102
|
+
activityThread: 'events of threads assigned to the caller, in this domain',
|
|
103
|
+
activityApproval: 'events of approvals waiting for the caller, in this domain',
|
|
104
|
+
activityInstance: "events of instances the caller made or whose role the caller holds, in this domain"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Open and read in the body: one case by id. The thread screen and the approval screen open a case for
|
|
110
|
+
* the people working on it, who mostly hold no management privilege, so the body decides - the domain
|
|
111
|
+
* first, then the case's participants, else a named refusal (controllers/activity-read). Ruled
|
|
112
|
+
* 2026-09-24.
|
|
113
|
+
*/
|
|
114
|
+
const READ_IN_THE_BODY: Record<Root, Record<string, string>> = {
|
|
115
|
+
Query: {
|
|
116
|
+
activityInstance:
|
|
117
|
+
'assert-may-read - participants of the instance, or the activity-instance query privilege. Asked the privilege until 2026-09-24, which kept OPERATOR from reopening a draft of their own',
|
|
118
|
+
activityThread: 'assert-may-read - participants of the thread, or the activity-instance query privilege',
|
|
119
|
+
activityApproval: 'assert-may-read - read through its thread, the same people'
|
|
120
|
+
},
|
|
121
|
+
Mutation: {},
|
|
122
|
+
Subscription: {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Closed doors, by name, so that reopening one is a visible act. All were open until 2026-09-24,
|
|
127
|
+
* reachable by anyone signed in to the domain.
|
|
128
|
+
*
|
|
129
|
+
* deleteActivities deleteActivity beside it already asked the activity privilege
|
|
130
|
+
* the six template writes definition writes asked a privilege; the templates they come from did not
|
|
131
|
+
* activityThreads every thread of the domain, while activityInstances asked a privilege
|
|
132
|
+
* activityApprovals every approval of the domain, likewise
|
|
133
|
+
*/
|
|
134
|
+
const CLOSED: Record<Root, string[]> = {
|
|
135
|
+
Query: ['activityThreads', 'activityApprovals'],
|
|
136
|
+
Mutation: [
|
|
137
|
+
'deleteActivities',
|
|
138
|
+
'createActivityTemplate',
|
|
139
|
+
'updateActivityTemplate',
|
|
140
|
+
'updateMultipleActivityTemplate',
|
|
141
|
+
'deleteActivityTemplate',
|
|
142
|
+
'deleteActivityTemplates',
|
|
143
|
+
'importActivityTemplates'
|
|
144
|
+
],
|
|
145
|
+
Subscription: []
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Taken out of the schema on 2026-09-24 because nothing called them. A door that comes back under the
|
|
150
|
+
* same name comes back open, so it is listed here to fail loudly instead.
|
|
151
|
+
*
|
|
152
|
+
* updateActivityThreadOutput overwrote any thread's output in the domain
|
|
153
|
+
* activityInstanceHistory(-ies) every instance history of the domain
|
|
154
|
+
*/
|
|
155
|
+
const REMOVED: Record<Root, string[]> = {
|
|
156
|
+
Query: ['activityInstanceHistory', 'activityInstanceHistories'],
|
|
157
|
+
Mutation: ['updateActivityThreadOutput'],
|
|
158
|
+
Subscription: []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* worklist-client declares deleteActivities, createActivity and the rest under the same names. Those are
|
|
163
|
+
* empty bodies that write nothing (a remote call that was never written), not this door, and they are
|
|
164
|
+
* not in this package's schema.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
let schema: GraphQLSchema
|
|
168
|
+
|
|
169
|
+
beforeAll(async () => {
|
|
170
|
+
schema = await buildSchema({
|
|
171
|
+
resolvers: declared.resolverClasses,
|
|
172
|
+
validate: false,
|
|
173
|
+
/* The subscriptions name their own subscribe; the walk only needs a pubSub to accept them. */
|
|
174
|
+
pubSub: { publish() {}, subscribe() {} } as any
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
privilegeDirectiveResolver(schema)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
function rootType(typeName: Root) {
|
|
181
|
+
return typeName === 'Query'
|
|
182
|
+
? schema.getQueryType()
|
|
183
|
+
: typeName === 'Mutation'
|
|
184
|
+
? schema.getMutationType()
|
|
185
|
+
: schema.getSubscriptionType()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function openFields(typeName: Root): string[] {
|
|
189
|
+
return Object.keys(rootType(typeName)?.getFields() || {}).filter(field => {
|
|
190
|
+
const declaredGate = privilegeOfField(typeName, field)
|
|
191
|
+
|
|
192
|
+
return declaredGate.known && declaredGate['gate'] === null
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
describe('every open door has a written reason', () => {
|
|
197
|
+
it.each(['Query', 'Mutation', 'Subscription'] as const)('%s', typeName => {
|
|
198
|
+
const listed = [...Object.keys(OPEN_BY_DECISION[typeName]), ...Object.keys(READ_IN_THE_BODY[typeName])]
|
|
199
|
+
|
|
200
|
+
expect(openFields(typeName).sort()).toEqual(listed.sort())
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('no door is on both lists', () => {
|
|
204
|
+
for (const typeName of ['Query', 'Mutation', 'Subscription'] as const) {
|
|
205
|
+
const both = Object.keys(OPEN_BY_DECISION[typeName]).filter(name => name in READ_IN_THE_BODY[typeName])
|
|
206
|
+
|
|
207
|
+
expect(both).toEqual([])
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('the doors that were closed', () => {
|
|
213
|
+
const closed = Object.entries(CLOSED).flatMap(([typeName, fields]) => fields.map(field => [typeName, field]))
|
|
214
|
+
|
|
215
|
+
it.each(closed)('%s.%s', (typeName, field) => {
|
|
216
|
+
const declaredGate = privilegeOfField(typeName, field)
|
|
217
|
+
|
|
218
|
+
expect(declaredGate.known).toBe(true)
|
|
219
|
+
expect(declaredGate['gate']).not.toBeNull()
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('the doors that were taken out', () => {
|
|
224
|
+
const removed = Object.entries(REMOVED).flatMap(([typeName, fields]) => fields.map(field => [typeName, field]))
|
|
225
|
+
|
|
226
|
+
it.each(removed)('%s.%s is not in the schema', (typeName, field) => {
|
|
227
|
+
expect(Object.keys(rootType(typeName as Root)?.getFields() || {})).not.toContain(field)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('a case read under its parent still rides on the parent', () => {
|
|
232
|
+
/*
|
|
233
|
+
* dataset's four screens (data-ooc, data-sample) read activityInstance { activityThreads {
|
|
234
|
+
* activityApprovals } } under their own records. Closing the root lists must not close this path:
|
|
235
|
+
* these are field resolvers, reached only through a parent the caller could already read.
|
|
236
|
+
*/
|
|
237
|
+
it.each([
|
|
238
|
+
['ActivityInstance', 'activityThreads'],
|
|
239
|
+
['ActivityThread', 'activityApprovals']
|
|
240
|
+
])('%s.%s is still a field', (typeName, field) => {
|
|
241
|
+
const type = schema.getType(typeName) as any
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* Only that the field is there. A @privilege on a field resolver does not reach the schema at all
|
|
245
|
+
* (measured 2026-09-24: no AST node, not in the privilege table), so a gate cannot close this path -
|
|
246
|
+
* only its body can. tests/may-read-case.test.ts calls the two bodies as a stranger and expects rows.
|
|
247
|
+
*/
|
|
248
|
+
expect(type?.getFields?.()[field]).toBeDefined()
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('what the walk itself has to be true for', () => {
|
|
253
|
+
it('reaches the whole schema, not a handful of fields', () => {
|
|
254
|
+
/* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */
|
|
255
|
+
expect(Object.keys(schema.getQueryType()?.getFields() || {}).length).toBeGreaterThan(25)
|
|
256
|
+
expect(Object.keys(schema.getMutationType()?.getFields() || {}).length).toBeGreaterThan(35)
|
|
257
|
+
expect(Object.keys(schema.getSubscriptionType()?.getFields() || {}).length).toBe(3)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('tells an undeclared field from one the schema does not have', () => {
|
|
261
|
+
expect(privilegeOfField('Query', 'noSuchField')).toEqual({ known: false })
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('the census, in plain numbers', () => {
|
|
265
|
+
/* 2026-09-24 after the rulings: 34 open by reason, 3 read in the body, 9 closed, 3 removed. */
|
|
266
|
+
const count = (lists: Record<Root, Record<string, string>>) =>
|
|
267
|
+
Object.keys(lists.Query).length + Object.keys(lists.Mutation).length
|
|
268
|
+
|
|
269
|
+
expect(count(OPEN_BY_DECISION)).toBe(34)
|
|
270
|
+
expect(count(READ_IN_THE_BODY)).toBe(3)
|
|
271
|
+
expect(CLOSED.Query.length + CLOSED.Mutation.length).toBe(9)
|
|
272
|
+
expect(REMOVED.Query.length + REMOVED.Mutation.length).toBe(3)
|
|
273
|
+
})
|
|
274
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
import { EventSubscriber } from 'typeorm'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the three subscriptions filter on is really on the entity they are handed.
|
|
7
|
+
*
|
|
8
|
+
* Each subscriber publishes `event.entity`, and the filters (subscription-filters.ts) read its
|
|
9
|
+
* @RelationId fields - domainId first, then the person. If TypeORM left those empty on a new row,
|
|
10
|
+
* every new thread, approval and instance would be dropped without a sound, and the filter tests
|
|
11
|
+
* would still be green. Measured here with the real entities on sqlite, on insert and on update.
|
|
12
|
+
*
|
|
13
|
+
* Saved the way the controllers save: with the relation objects, not the ids.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
17
|
+
const DIST = join(__dirname, '..', 'dist-server', 'service')
|
|
18
|
+
const { ActivityApproval } = require(join(DIST, 'activity-approval', 'activity-approval.js'))
|
|
19
|
+
const { ActivityThread } = require(join(DIST, 'activity-thread', 'activity-thread.js'))
|
|
20
|
+
const { ActivityInstance } = require(join(DIST, 'activity-instance', 'activity-instance.js'))
|
|
21
|
+
const { Domain } = require('@things-factory/shell')
|
|
22
|
+
const { Role, User } = require('@things-factory/auth-base')
|
|
23
|
+
|
|
24
|
+
const seen: { on: string; name: string; entity: any }[] = []
|
|
25
|
+
|
|
26
|
+
class HandedToSubscribers {
|
|
27
|
+
afterInsert(event: any) {
|
|
28
|
+
event.entity && seen.push({ on: 'insert', name: event.metadata.name, entity: { ...event.entity } })
|
|
29
|
+
}
|
|
30
|
+
afterUpdate(event: any) {
|
|
31
|
+
event.entity && seen.push({ on: 'update', name: event.metadata.name, entity: { ...event.entity } })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
EventSubscriber()(HandedToSubscribers)
|
|
35
|
+
|
|
36
|
+
let db: any
|
|
37
|
+
let domain: any
|
|
38
|
+
let user: any
|
|
39
|
+
let role: any
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
db = createSqliteTestDataSource([ActivityApproval, ActivityThread, ActivityInstance], { subscribers: [HandedToSubscribers] })
|
|
43
|
+
await db.initialize()
|
|
44
|
+
|
|
45
|
+
domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
|
|
46
|
+
user = await db.getRepository(User).save({ name: 'u', email: 'u@example.com', password: 'x', domains: [domain] })
|
|
47
|
+
role = await db.getRepository(Role).save({ name: 'r', domain })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterAll(async () => db?.destroy())
|
|
51
|
+
|
|
52
|
+
beforeEach(() => (seen.length = 0))
|
|
53
|
+
|
|
54
|
+
const handed = (name: string, on: string) => seen.find(one => one.name === name && one.on === on)?.entity
|
|
55
|
+
|
|
56
|
+
it('a new instance carries its domain, its maker and the role it waits for', async () => {
|
|
57
|
+
await db.getRepository(ActivityInstance).save({ domain, name: 'i', creator: user, assigneeRole: role, state: 'issued' })
|
|
58
|
+
|
|
59
|
+
expect(handed('ActivityInstance', 'insert')).toMatchObject({ domainId: domain.id, creatorId: user.id, assigneeRoleId: role.id })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('a new thread carries its domain and its assignee', async () => {
|
|
63
|
+
await db.getRepository(ActivityThread).save({ domain, name: 't', round: 1, assignee: user, state: 'assigned' })
|
|
64
|
+
|
|
65
|
+
expect(handed('ActivityThread', 'insert')).toMatchObject({ domainId: domain.id, assigneeId: user.id })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('a new approval carries its domain and its approver; so does an update of a loaded one', async () => {
|
|
69
|
+
const thread = await db.getRepository(ActivityThread).save({ domain, name: 't2', round: 1, state: 'started' })
|
|
70
|
+
const saved = await db.getRepository(ActivityApproval).save({
|
|
71
|
+
domain,
|
|
72
|
+
activityThread: thread,
|
|
73
|
+
approver: user,
|
|
74
|
+
order: 1,
|
|
75
|
+
round: 1,
|
|
76
|
+
judgment: 'pending'
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(handed('ActivityApproval', 'insert')).toMatchObject({ domainId: domain.id, approverId: user.id })
|
|
80
|
+
|
|
81
|
+
const loaded = await db.getRepository(ActivityApproval).findOne({ where: { id: saved.id } })
|
|
82
|
+
await db.getRepository(ActivityApproval).save({ ...loaded, judgment: 'approved' })
|
|
83
|
+
|
|
84
|
+
expect(handed('ActivityApproval', 'update')).toMatchObject({ domainId: domain.id, approverId: user.id })
|
|
85
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A door that refuses on an empty approval line cannot be declared unused (ADR-0077 decision 3).
|
|
6
|
+
*
|
|
7
|
+
* "We do not use this door" means the work runs with no approval step. `whenNoLine: 'refuse'` means the
|
|
8
|
+
* work cannot stand without one. Accepted together, the door screen shows the door as off while the
|
|
9
|
+
* product still demands approval - and a product that honoured it would scrap, release a hold or lower
|
|
10
|
+
* a control unapproved.
|
|
11
|
+
*
|
|
12
|
+
* Both sides of the axis are walked through the real resolver on sqlite, inside a real transaction:
|
|
13
|
+
* the refusing doors are refused and write nothing, the `run` / `as-established` / assignment doors are
|
|
14
|
+
* accepted as before.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { createSqliteTestDataSource } = require(join(__dirname, '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
18
|
+
const DIST = join(__dirname, '..', 'dist-server', 'service', 'work-door')
|
|
19
|
+
const { WorkDoorResolver } = require(join(DIST, 'work-door-resolver.js'))
|
|
20
|
+
const { WorkDoorNotUsed } = require(join(DIST, 'work-door-not-used.js'))
|
|
21
|
+
const { WorkDoorJournal } = require(join(DIST, 'work-door-journal.js'))
|
|
22
|
+
const { registerWorkDoors, whyNotUsedCannotBeDeclared } = require(join(DIST, 'work-door-declaration.js'))
|
|
23
|
+
const { Activity } = require(join(__dirname, '..', 'dist-server', 'service', 'activity', 'activity.js'))
|
|
24
|
+
const { Domain, addDataSource, removeDataSource } = require('@things-factory/shell')
|
|
25
|
+
const { User } = require('@things-factory/auth-base')
|
|
26
|
+
|
|
27
|
+
const REFUSING = [
|
|
28
|
+
{ name: 'Scrap', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'irreversible' },
|
|
29
|
+
{ name: 'Release', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'approval-is-the-act' },
|
|
30
|
+
{ name: 'Defer', labelKey: 'x', kind: 'approval', whenNoLine: 'refuse', because: 'lowers-a-control' }
|
|
31
|
+
]
|
|
32
|
+
const ACCEPTED = [
|
|
33
|
+
{ name: 'Priority', labelKey: 'x', kind: 'approval', whenNoLine: 'run' },
|
|
34
|
+
{ name: 'Withdraw', labelKey: 'x', kind: 'approval', whenNoLine: 'as-established', because: 'approval-is-the-act' },
|
|
35
|
+
{ name: 'Dispatch', labelKey: 'x', kind: 'assignment' }
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
let db: any
|
|
39
|
+
let domain: any
|
|
40
|
+
let user: any
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
registerWorkDoors({ module: 'not-used-test', doors: [...REFUSING, ...ACCEPTED] as any })
|
|
44
|
+
|
|
45
|
+
db = createSqliteTestDataSource([Activity, WorkDoorNotUsed, WorkDoorJournal])
|
|
46
|
+
await db.initialize()
|
|
47
|
+
/* After saving, the resolver answers the whole door list, and that read goes through the default source. */
|
|
48
|
+
addDataSource('default', db)
|
|
49
|
+
|
|
50
|
+
domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
|
|
51
|
+
user = await db.getRepository(User).save({ name: 'u', email: 'u@example.com', password: 'x', domains: [domain] })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
removeDataSource('default')
|
|
56
|
+
await db?.destroy()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const declare = (doorName: string) =>
|
|
60
|
+
db.transaction((tx: any) =>
|
|
61
|
+
new WorkDoorResolver().declareWorkDoorNotUsed(doorName, 'we do not do this here', {
|
|
62
|
+
state: { domain, user, tx },
|
|
63
|
+
t: (key: string) => key
|
|
64
|
+
} as any)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const declared = (doorName: string) => db.getRepository(WorkDoorNotUsed).count({ where: { doorName } })
|
|
68
|
+
|
|
69
|
+
describe('the judgement', () => {
|
|
70
|
+
it('★ a refusing door answers its reason; the others answer nothing', () => {
|
|
71
|
+
expect(REFUSING.map(door => whyNotUsedCannotBeDeclared(door as any))).toEqual([
|
|
72
|
+
'irreversible',
|
|
73
|
+
'approval-is-the-act',
|
|
74
|
+
'lowers-a-control'
|
|
75
|
+
])
|
|
76
|
+
expect(ACCEPTED.map(door => whyNotUsedCannotBeDeclared(door as any))).toEqual([undefined, undefined, undefined])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('declaring a door unused, through the resolver', () => {
|
|
81
|
+
it.each(REFUSING.map(door => [door.name, door.because]))(
|
|
82
|
+
'★ %s refuses on an empty line, so it cannot be turned off — the refusal names the reason (%s)',
|
|
83
|
+
async (name, because) => {
|
|
84
|
+
await expect(declare(name as string)).rejects.toMatchObject({ code: `work-door-not-used-refused-${because}` })
|
|
85
|
+
expect(await declared(name as string)).toBe(0)
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
it.each(ACCEPTED.map(door => [door.name, door.whenNoLine ?? 'assignment']))(
|
|
90
|
+
'%s (%s) is accepted as before',
|
|
91
|
+
async name => {
|
|
92
|
+
await declare(name as string)
|
|
93
|
+
expect(await declared(name as string)).toBe(1)
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('every language says it', () => {
|
|
99
|
+
const languages = ['en', 'ko', 'ja', 'zh', 'ms']
|
|
100
|
+
|
|
101
|
+
it.each(languages)('★ %s has a sentence for each reason — a missing key prints the key', language => {
|
|
102
|
+
const words = JSON.parse(readFileSync(join(__dirname, '..', 'translations', `${language}.json`), 'utf-8'))
|
|
103
|
+
|
|
104
|
+
for (const door of REFUSING) {
|
|
105
|
+
const sentence = words[`error.work-door-not-used-refused-${door.because}`]
|
|
106
|
+
|
|
107
|
+
expect(sentence).toEqual(expect.stringContaining('{name}'))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
package/things-factory.config.js
CHANGED
|
@@ -9,6 +9,7 @@ export default {
|
|
|
9
9
|
{ tagname: 'todo-list-page', page: 'todo-list' },
|
|
10
10
|
{ tagname: 'done-list-calendar-page', page: 'done-list-calendar' },
|
|
11
11
|
{ tagname: 'activity-catalog-page', page: 'activity-catalog' },
|
|
12
|
+
{ tagname: 'activity-detail-page', page: 'activity-detail' },
|
|
12
13
|
{ tagname: 'done-list-page', page: 'done-list' },
|
|
13
14
|
{ tagname: 'approval-pending-list-page', page: 'approval-pending-list' },
|
|
14
15
|
{ tagname: 'approval-done-list-page', page: 'approval-done-list' },
|
package/translations/en.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
+
"button.try-again": "try again",
|
|
3
|
+
"button.discard-draft": "discard draft",
|
|
2
4
|
"button.abort": "abort",
|
|
5
|
+
"button.activity-install": "use it here",
|
|
6
|
+
"button.activity-set-approval-line": "set the approval line",
|
|
7
|
+
"button.activity-uninstall": "stop using it",
|
|
3
8
|
"button.approve": "approve",
|
|
4
9
|
"button.assign": "assign",
|
|
10
|
+
"button.back-to-activity-catalog": "activity catalog",
|
|
5
11
|
"button.buy": "buy",
|
|
6
12
|
"button.copy": "copy",
|
|
7
13
|
"button.delegate": "delegate",
|
|
@@ -24,13 +30,19 @@
|
|
|
24
30
|
"error.activity-approval is already terminated": "this approval task ({activityApproval}) has already been terminated ({actual}).",
|
|
25
31
|
"error.activity-approval is reference only": "you are a reference on this item, so you cannot approve or reject it. you can leave a comment.",
|
|
26
32
|
"error.activity-approval not found": "this approval task ({activityApproval}) cannot be found.",
|
|
33
|
+
"error.activity-approval delegate to self": "the task is already yours; choose someone else to hand it to.",
|
|
34
|
+
"error.activity-approval delegate outside domain": "that person is not in this site, so the task cannot be handed to them.",
|
|
35
|
+
"error.activity-approval delegate to issuer": "the person who raised this cannot approve it, so it cannot be handed to them.",
|
|
27
36
|
"error.activity-approval taken by someone else": "someone else handled this approval first. reload the list to see where it stands.",
|
|
28
37
|
"error.activity-instance belongs to someone else": "{id} is someone else's draft. Only the person who wrote it, or someone who manages activity instances, may edit or issue it.",
|
|
29
38
|
"error.activity-instance is addressed to another role": "{id} is assigned to another role. only someone with that role can pick it.",
|
|
30
39
|
"error.activity-instance is already issued": "this task ({id}) has already been issued with the {state} status.",
|
|
31
40
|
"error.activity-instance is already terminated": "this task ({id}) has already been terminated with the {state} status.",
|
|
32
41
|
"error.activity-instance not found": "this task ({activityInstance}) cannot be found.",
|
|
42
|
+
"error.activity-instance not a draft any more": "this draft has already gone out, so it cannot be thrown away.",
|
|
33
43
|
"error.activity-instance should not be assigned": "this task ({id}) is already in {actual} status and cannot be assigned.",
|
|
44
|
+
"error.activity-case not a participant": "you do not take part in this case, so it cannot be opened here.",
|
|
45
|
+
"error.activity-instance unknown state": "{state} is not a state a task can be in. available: {known}",
|
|
34
46
|
"error.activity-thread is already terminated": "this task thread ({id}) has already been terminated with the {state} status.",
|
|
35
47
|
"error.activity-thread not found": "this task thread ({activityThread}) cannot be found.",
|
|
36
48
|
"error.activity-thread rejection is final": "this case cannot be reopened — a rejection closes it for good. raise a new one instead.",
|
|
@@ -42,6 +54,9 @@
|
|
|
42
54
|
"error.approval line names a role that nobody holds": "{seats}: nobody holds the role named for that step. add someone to that role.",
|
|
43
55
|
"error.not authorized to handle this task": "you are not authorized to perform this task.",
|
|
44
56
|
"error.work-door-not-used-reason-missing": "enter why this task is not used.",
|
|
57
|
+
"error.work-door-not-used-refused-irreversible": "\"{name}\" cannot be turned off. what it does cannot be undone, so it only runs through approval. set its approval line instead.",
|
|
58
|
+
"error.work-door-not-used-refused-approval-is-the-act": "\"{name}\" cannot be turned off. its approval is the work itself. set its approval line instead.",
|
|
59
|
+
"error.work-door-not-used-refused-lowers-a-control": "\"{name}\" cannot be turned off. it lowers a control that stands, so it only runs through approval. set its approval line instead.",
|
|
45
60
|
"error.work-door-unknown": "unknown task \"{name}\". use a task name from the status screen.",
|
|
46
61
|
"field.activity-type": "activity type",
|
|
47
62
|
"field.approval-changed-at": "last change",
|
|
@@ -126,9 +141,11 @@
|
|
|
126
141
|
"field.wl-submitted-at": "submitted",
|
|
127
142
|
"field.wl-terminated-at": "finished",
|
|
128
143
|
"field.wl-view-source": "view source",
|
|
144
|
+
"label.activity-approval-line": "approval line",
|
|
129
145
|
"label.activity-build-info": "build info",
|
|
130
146
|
"label.activity-input": "task content",
|
|
131
147
|
"label.activity-instance": "activity instance",
|
|
148
|
+
"label.activity-panels": "more settings",
|
|
132
149
|
"label.activity-starting-type-issue": "issue",
|
|
133
150
|
"label.activity-starting-type-post": "post",
|
|
134
151
|
"label.activity-state-aborted": "aborted",
|
|
@@ -164,6 +181,8 @@
|
|
|
164
181
|
"label.activity-transaction-save": "save",
|
|
165
182
|
"label.activity-transaction-start": "start",
|
|
166
183
|
"label.activity-transaction-submit": "submit",
|
|
184
|
+
"label.activity-use-here": "use here",
|
|
185
|
+
"label.activity-what-is-it": "what this is",
|
|
167
186
|
"label.approval-info": "approval info",
|
|
168
187
|
"label.approval-noted": "noted",
|
|
169
188
|
"label.approval-round-n": "resubmitted, round {n}",
|
|
@@ -205,14 +224,23 @@
|
|
|
205
224
|
"label.what-it-was-judged-from": "what it was judged from",
|
|
206
225
|
"label.yes": "yes",
|
|
207
226
|
"menu.work-doors": "approval and assignment forms",
|
|
227
|
+
"text.discard-draft-confirm": "discard this draft?",
|
|
228
|
+
"text.discard-draft-for-good": "a discarded draft cannot be brought back.",
|
|
229
|
+
"text.draft-discarded": "the draft was discarded.",
|
|
208
230
|
"text.activity-activated": "{name} is now on for this domain.",
|
|
209
231
|
"text.activity-change-failed": "{name} was not changed: {why}",
|
|
210
232
|
"text.activity-change-not-allowed": "you are not allowed to change {name}. ask an administrator for the activity privilege.",
|
|
211
233
|
"text.activity-deactivated": "{name} is off. this work will no longer be raised.",
|
|
212
234
|
"text.activity-draft-how": "enter a title and description, then choose save draft to open the editor.",
|
|
213
|
-
"text.activity-draft-where": "drafts are in the
|
|
235
|
+
"text.activity-draft-where": "drafts are in the “raised by me” list and can be edited until you {action}.",
|
|
214
236
|
"text.activity-guide-unwritten": "an explanation of this work has not been written yet.",
|
|
215
237
|
"text.activity-in-use-here": "in use",
|
|
238
|
+
"text.activity-line-none": "no approval line",
|
|
239
|
+
"text.activity-line-not-built": "this activity is not built yet, so it cannot have an approval line",
|
|
240
|
+
"text.activity-line-not-installed": "use it here first, then the approval line can be set",
|
|
241
|
+
"text.activity-line-steps": "{steps} approval steps",
|
|
242
|
+
"text.activity-no-panels": "this app has no more settings for an activity",
|
|
243
|
+
"text.activity-not-found": "that activity cannot be found ({name})",
|
|
216
244
|
"text.activity-not-in-use-here": "not in use",
|
|
217
245
|
"text.activity_instance_aborted_successfully": "activity instance aborted successfully",
|
|
218
246
|
"text.and-n-more": "{n} more",
|
|
@@ -323,12 +351,13 @@
|
|
|
323
351
|
"title.activity template model item list": "activity template model item list",
|
|
324
352
|
"title.activity thread": "activity thread",
|
|
325
353
|
"title.activity thread list": "tasks",
|
|
354
|
+
"title.activity-detail": "activity",
|
|
326
355
|
"title.activity-stats list": "task statistics",
|
|
327
356
|
"title.approval done list": "approval done list",
|
|
328
357
|
"title.approval pending list": "my approval inbox",
|
|
329
358
|
"title.assigned list": "assigned list",
|
|
330
359
|
"title.done list": "done",
|
|
331
|
-
"title.draft list": "
|
|
360
|
+
"title.draft list": "raised by me",
|
|
332
361
|
"title.edit duration": "edit duration",
|
|
333
362
|
"title.import activity-approval": "import approvals",
|
|
334
363
|
"title.import activity-stats": "import task statistics",
|
|
@@ -342,4 +371,4 @@
|
|
|
342
371
|
"title.work-doors": "approval and assignment forms in this plant",
|
|
343
372
|
"title.work-doors-next": "What to do next",
|
|
344
373
|
"title.worklist dashboard": "worklist dashboard"
|
|
345
|
-
}
|
|
374
|
+
}
|