@things-factory/organization 10.1.81 → 10.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist-client/component/department-selector.d.ts +1 -1
  2. package/dist-client/component/department-selector.js +9 -6
  3. package/dist-client/component/department-selector.js.map +1 -1
  4. package/dist-client/pages/department/department-list-page.js +13 -10
  5. package/dist-client/pages/department/department-list-page.js.map +1 -1
  6. package/dist-client/pages/department/department-patch.d.ts +9 -0
  7. package/dist-client/pages/department/department-patch.js +22 -0
  8. package/dist-client/pages/department/department-patch.js.map +1 -0
  9. package/dist-client/tsconfig.tsbuildinfo +1 -1
  10. package/dist-server/service/department/department-mutation.js +14 -3
  11. package/dist-server/service/department/department-mutation.js.map +1 -1
  12. package/dist-server/service/department/department-parent.d.ts +10 -0
  13. package/dist-server/service/department/department-parent.js +35 -0
  14. package/dist-server/service/department/department-parent.js.map +1 -0
  15. package/dist-server/service/employee/employee-day.d.ts +5 -0
  16. package/dist-server/service/employee/employee-day.js +33 -0
  17. package/dist-server/service/employee/employee-day.js.map +1 -0
  18. package/dist-server/service/employee/employee-history.d.ts +2 -2
  19. package/dist-server/service/employee/employee-history.js +4 -4
  20. package/dist-server/service/employee/employee-history.js.map +1 -1
  21. package/dist-server/service/employee/employee-mutation.js +6 -5
  22. package/dist-server/service/employee/employee-mutation.js.map +1 -1
  23. package/dist-server/service/employee/employee-type.d.ts +5 -5
  24. package/dist-server/service/employee/employee-type.js +10 -10
  25. package/dist-server/service/employee/employee-type.js.map +1 -1
  26. package/dist-server/service/employee/employee.d.ts +2 -2
  27. package/dist-server/service/employee/employee.js +4 -4
  28. package/dist-server/service/employee/employee.js.map +1 -1
  29. package/dist-server/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/spec/unit/employee.spec.ts +2 -2
  32. package/tests/department-parent.test.ts +185 -0
  33. package/tests/department-patch.test.ts +52 -0
  34. package/tests/employee-day.test.ts +163 -0
  35. package/translations/en.json +1 -0
  36. package/translations/ja.json +1 -0
  37. package/translations/ko.json +1 -0
  38. package/translations/ms.json +1 -0
  39. package/translations/zh.json +1 -0
  40. package/dist-client/tsconfig.chk.tsbuildinfo +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/organization",
3
- "version": "10.1.81",
3
+ "version": "10.1.84",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -41,5 +41,5 @@
41
41
  "@things-factory/contact": "^10.1.79",
42
42
  "@things-factory/shell": "^10.1.79"
43
43
  },
44
- "gitHead": "0c4f91d92c8214a9b2eca6690a7ed48bd2d828cc"
44
+ "gitHead": "12df439ce09de2bc9aa87d4578766f46bfad48fd"
45
45
  }
@@ -188,8 +188,8 @@ describe('Employee Entity Tests', () => {
188
188
  const { tx, domain } = context.state
189
189
 
190
190
  // Given
191
- const hiredOn = new Date('2020-01-15')
192
- const retiredOn = new Date('2024-12-31')
191
+ const hiredOn = '2020-01-15'
192
+ const retiredOn = '2024-12-31'
193
193
 
194
194
  // When
195
195
  const employee = await employeeFactory.createWithDomain(
@@ -0,0 +1,185 @@
1
+ /**
2
+ * A department's parent can be set, changed and cleared from the department list, and a loop is refused.
3
+ *
4
+ * The list had no parent cell. Departments made anywhere but "add child node" on a new row stayed top-level
5
+ * for good — OA's five all were — and the department selector, which drew the first top-level department
6
+ * only, could offer one of the five.
7
+ *
8
+ * This sends the list's patch through the server's own DepartmentPatch and the real updateMultipleDepartment,
9
+ * on sqlite, and reads the parent back from the table.
10
+ */
11
+ import 'reflect-metadata'
12
+
13
+ const { existsSync } = require('fs')
14
+ const { join } = require('path')
15
+ const { DataSource } = require('typeorm')
16
+ const { buildSchema, Resolver, Query, Mutation, Arg, Ctx } = require('type-graphql')
17
+ const { graphql } = require('graphql')
18
+
19
+ const shell = require('@things-factory/shell')
20
+ const authBase = require('@things-factory/auth-base')
21
+ const contact = require('@things-factory/contact')
22
+
23
+ function loadCompiled(modulePath: string): any {
24
+ const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
25
+ if (!existsSync(resolved)) {
26
+ throw new Error(`organization build output is missing: ${resolved}\nBuild it first: npm run build:server`)
27
+ }
28
+ return require(resolved)
29
+ }
30
+
31
+ let dataSource: any
32
+ let Department: any
33
+ let schema: any
34
+ let domain: any
35
+ let user: any
36
+
37
+ const SAVE = `
38
+ mutation ($patches: [DepartmentPatch!]!) {
39
+ updateMultipleDepartment(patches: $patches) { id name }
40
+ }
41
+ `
42
+
43
+ beforeAll(async () => {
44
+ const organization = loadCompiled('service/index')
45
+ Department = loadCompiled('service/department/department').Department
46
+ const { DepartmentMutation } = loadCompiled('service/department/department-mutation')
47
+ const { DepartmentPatch } = loadCompiled('service/department/department-type')
48
+
49
+ dataSource = new DataSource({
50
+ type: 'sqlite',
51
+ database: ':memory:',
52
+ synchronize: true,
53
+ dropSchema: true,
54
+ namingStrategy: new shell.NamingStrategy(),
55
+ entities: [...shell.entities, ...authBase.entities, ...contact.entities, ...organization.entities],
56
+ logging: false
57
+ })
58
+ await dataSource.initialize()
59
+ shell.addDataSource('default', dataSource)
60
+ shell.addDataSource('tx', dataSource)
61
+
62
+ /* The real mutation behind the real input type; the context stands in for what its directives set up. */
63
+ class DepartmentParentProbe {
64
+ probe() {
65
+ return true
66
+ }
67
+
68
+ async updateMultipleDepartment(patches: any[], context: any) {
69
+ return await new DepartmentMutation().updateMultipleDepartment(patches, context)
70
+ }
71
+ }
72
+ Query(() => Boolean)(DepartmentParentProbe.prototype, 'probe', Object.getOwnPropertyDescriptor(DepartmentParentProbe.prototype, 'probe'))
73
+ Arg('patches', () => [DepartmentPatch])(DepartmentParentProbe.prototype, 'updateMultipleDepartment', 0)
74
+ Ctx()(DepartmentParentProbe.prototype, 'updateMultipleDepartment', 1)
75
+ Mutation(() => [Department])(
76
+ DepartmentParentProbe.prototype,
77
+ 'updateMultipleDepartment',
78
+ Object.getOwnPropertyDescriptor(DepartmentParentProbe.prototype, 'updateMultipleDepartment')
79
+ )
80
+ Resolver()(DepartmentParentProbe)
81
+
82
+ schema = await buildSchema({ resolvers: [DepartmentParentProbe], validate: false })
83
+ })
84
+
85
+ let ceo: any
86
+ let team1: any
87
+ let team2: any
88
+
89
+ beforeEach(async () => {
90
+ await dataSource.synchronize(true)
91
+ domain = await dataSource.getRepository(shell.Domain).save({ name: 'department-parent', subdomain: 'department-parent' })
92
+ user = await dataSource.getRepository(authBase.User).save({ name: 'tester', email: 'tester@department-parent.test' })
93
+
94
+ /* OA's shape: every department top-level. */
95
+ const repository = dataSource.getRepository(Department)
96
+ ceo = await repository.save({ domain, controlNo: 'CEO', name: 'CEO' })
97
+ team1 = await repository.save({ domain, controlNo: 'T01', name: '1팀' })
98
+ team2 = await repository.save({ domain, controlNo: 'T02', name: '2팀' })
99
+ })
100
+
101
+ afterAll(async () => {
102
+ if (!dataSource) return
103
+ shell.removeDataSource('default')
104
+ shell.removeDataSource('tx')
105
+ await dataSource.destroy()
106
+ })
107
+
108
+ async function save(patches: any[]) {
109
+ return await graphql({
110
+ schema,
111
+ source: SAVE,
112
+ variableValues: { patches },
113
+ contextValue: { state: { domain, user, tx: dataSource.manager } }
114
+ })
115
+ }
116
+
117
+ async function parentOf(department: any) {
118
+ return (await dataSource.getRepository(Department).findOneBy({ id: department.id })).parentId ?? null
119
+ }
120
+
121
+ describe("a department's parent is set from the department list", () => {
122
+ it('an existing top-level department moves under another', async () => {
123
+ const result = await save([
124
+ { id: team1.id, cuFlag: 'M', parent: { id: ceo.id } },
125
+ { id: team2.id, cuFlag: 'M', parent: { id: ceo.id } }
126
+ ])
127
+
128
+ expect(result.errors).toBeUndefined()
129
+ expect(await parentOf(team1)).toBe(ceo.id)
130
+ expect(await parentOf(team2)).toBe(ceo.id)
131
+ expect(await parentOf(ceo)).toBeNull()
132
+ })
133
+
134
+ it('moves from one parent to another, and back to top-level with null', async () => {
135
+ await save([{ id: team2.id, cuFlag: 'M', parent: { id: ceo.id } }])
136
+
137
+ expect((await save([{ id: team2.id, cuFlag: 'M', parent: { id: team1.id } }])).errors).toBeUndefined()
138
+ expect(await parentOf(team2)).toBe(team1.id)
139
+
140
+ expect((await save([{ id: team2.id, cuFlag: 'M', parent: null }])).errors).toBeUndefined()
141
+ expect(await parentOf(team2)).toBeNull()
142
+ })
143
+
144
+ it('an edit that does not name the parent leaves it where it is', async () => {
145
+ await save([{ id: team1.id, cuFlag: 'M', parent: { id: ceo.id } }])
146
+
147
+ expect((await save([{ id: team1.id, cuFlag: 'M', description: 'first team' }])).errors).toBeUndefined()
148
+ expect(await parentOf(team1)).toBe(ceo.id)
149
+ })
150
+
151
+ it('a new row added under a department is saved there', async () => {
152
+ const result = await save([{ cuFlag: '+', controlNo: 'T01-A', name: '1팀 A조', parent: { id: team1.id } }])
153
+
154
+ expect(result.errors).toBeUndefined()
155
+ const created = await dataSource.getRepository(Department).findOneBy({ controlNo: 'T01-A' })
156
+ expect(created.parentId).toBe(team1.id)
157
+ })
158
+
159
+ it('a department cannot be its own parent', async () => {
160
+ const result = await save([{ id: team1.id, cuFlag: 'M', parent: { id: team1.id } }])
161
+
162
+ expect(result.errors?.[0]?.message).toMatch(/cannot be placed under itself/)
163
+ expect(await parentOf(team1)).toBeNull()
164
+ })
165
+
166
+ it('a department cannot go under one of its own sub-departments', async () => {
167
+ await save([{ id: team1.id, cuFlag: 'M', parent: { id: ceo.id } }])
168
+ await save([{ id: team2.id, cuFlag: 'M', parent: { id: team1.id } }])
169
+
170
+ const result = await save([{ id: ceo.id, cuFlag: 'M', parent: { id: team2.id } }])
171
+
172
+ expect(result.errors?.[0]?.message).toMatch(/cannot be placed under itself/)
173
+ expect(await parentOf(ceo)).toBeNull()
174
+ })
175
+
176
+ it('a parent from another domain is not found', async () => {
177
+ const other = await dataSource.getRepository(shell.Domain).save({ name: 'other', subdomain: 'other' })
178
+ const stranger = await dataSource.getRepository(Department).save({ domain: other, controlNo: 'X', name: 'X' })
179
+
180
+ const result = await save([{ id: team1.id, cuFlag: 'M', parent: { id: stranger.id } }])
181
+
182
+ expect(result.errors?.[0]?.message).toMatch(/parent department not found/)
183
+ expect(await parentOf(team1)).toBeNull()
184
+ })
185
+ })
@@ -0,0 +1,52 @@
1
+ import { toDepartmentPatch } from '../client/pages/department/department-patch'
2
+
3
+ /**
4
+ * The department list's save sends the parent as a reference.
5
+ *
6
+ * The parent cell's selector hands back { id, controlNo, name, description }. DepartmentPatch.parent is an
7
+ * ObjectRef, which has no controlNo, so the whole patch would be refused if the cell's value went as it is.
8
+ */
9
+ describe('the department list sends a parent as { id } or null', () => {
10
+ const picked = { id: 'd-ceo', controlNo: 'CEO', name: 'CEO', description: 'office' }
11
+
12
+ it('a changed parent cell goes as its id only', () => {
13
+ const patch = toDepartmentPatch({
14
+ id: 'd-t01',
15
+ __dirty__: 'M',
16
+ __dirtyfields__: { parent: { before: null, after: picked } }
17
+ })
18
+
19
+ expect(patch).toEqual({ id: 'd-t01', parent: { id: 'd-ceo' }, cuFlag: 'M' })
20
+ })
21
+
22
+ it('a cleared parent cell goes as null, which makes the department top-level', () => {
23
+ const patch = toDepartmentPatch({
24
+ id: 'd-t01',
25
+ __dirty__: 'M',
26
+ __dirtyfields__: { parent: { before: picked, after: null } }
27
+ })
28
+
29
+ expect(patch.parent).toBeNull()
30
+ })
31
+
32
+ it('an edit of another cell does not send the parent the row was drawn with', () => {
33
+ const patch = toDepartmentPatch({
34
+ id: 'd-t01',
35
+ parent: { id: 'd-ceo', name: 'CEO' },
36
+ __dirty__: 'M',
37
+ __dirtyfields__: { description: { before: '', after: 'first team' } }
38
+ })
39
+
40
+ expect(patch).toEqual({ id: 'd-t01', description: 'first team', cuFlag: 'M' })
41
+ })
42
+
43
+ it('a row added with "add child node" sends the parent it was added under', () => {
44
+ const patch = toDepartmentPatch({
45
+ parent: { id: 'd-t01' },
46
+ __dirty__: '+',
47
+ __dirtyfields__: { name: { before: undefined, after: '1팀 A조' } }
48
+ })
49
+
50
+ expect(patch).toEqual({ name: '1팀 A조', parent: { id: 'd-t01' }, cuFlag: '+' })
51
+ })
52
+ })
@@ -0,0 +1,163 @@
1
+ /**
2
+ * hiredOn and retiredOn are calendar days, and the grid's date editor sends them as 'YYYY-MM-DD'.
3
+ *
4
+ * They were declared `Date`, so GraphQL typed them as a date-time and refused the grid's own value before
5
+ * any resolver ran: 'Variable "$patches" got invalid value "2025-12-31" at "patches[0].retiredOn"'. The
6
+ * employee list could not save a retire day at all.
7
+ *
8
+ * This sends the grid's patch through the server's own EmployeePatch and Employee types and the real
9
+ * updateMultipleEmployee, on sqlite, and reads the day back — from the table, from the history row and
10
+ * through GraphQL — unchanged.
11
+ */
12
+ import 'reflect-metadata'
13
+
14
+ const { existsSync } = require('fs')
15
+ const { join } = require('path')
16
+ const { DataSource } = require('typeorm')
17
+ const { buildSchema, Resolver, Query, Mutation, Arg, Ctx } = require('type-graphql')
18
+ const { graphql } = require('graphql')
19
+
20
+ const shell = require('@things-factory/shell')
21
+ const authBase = require('@things-factory/auth-base')
22
+ const contact = require('@things-factory/contact')
23
+
24
+ function loadCompiled(modulePath: string): any {
25
+ const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
26
+ if (!existsSync(resolved)) {
27
+ throw new Error(`organization build output is missing: ${resolved}\nBuild it first: npm run build:server`)
28
+ }
29
+ return require(resolved)
30
+ }
31
+
32
+ let dataSource: any
33
+ let organization: any
34
+ let Employee: any
35
+ let EmployeeHistory: any
36
+ let schema: any
37
+ let domain: any
38
+ let user: any
39
+
40
+ const SAVE = `
41
+ mutation ($patches: [EmployeePatch!]!) {
42
+ updateMultipleEmployee(patches: $patches) { id hiredOn retiredOn }
43
+ }
44
+ `
45
+
46
+ beforeAll(async () => {
47
+ organization = loadCompiled('service/index')
48
+ Employee = loadCompiled('service/employee/employee').Employee
49
+ EmployeeHistory = loadCompiled('service/employee/employee-history').EmployeeHistory
50
+ const { EmployeeHistoryEntitySubscriber } = loadCompiled('service/employee/event-subscriber')
51
+ const { EmployeeMutation } = loadCompiled('service/employee/employee-mutation')
52
+ const { EmployeePatch } = loadCompiled('service/employee/employee-type')
53
+
54
+ dataSource = new DataSource({
55
+ type: 'sqlite',
56
+ database: ':memory:',
57
+ synchronize: true,
58
+ dropSchema: true,
59
+ namingStrategy: new shell.NamingStrategy(),
60
+ entities: [...shell.entities, ...authBase.entities, ...contact.entities, ...organization.entities],
61
+ subscribers: [EmployeeHistoryEntitySubscriber],
62
+ logging: false
63
+ })
64
+ await dataSource.initialize()
65
+ shell.addDataSource('default', dataSource)
66
+ shell.addDataSource('tx', dataSource)
67
+
68
+ /* The real mutation, behind the real input and output types. Its @transaction and @privilege directives are
69
+ * the framework's; the context below stands in for what they set up. */
70
+ class EmployeeDayProbe {
71
+ /* A schema needs a Query field; this one is never asked. */
72
+ probe() {
73
+ return true
74
+ }
75
+
76
+ async updateMultipleEmployee(patches: any[], context: any) {
77
+ return await new EmployeeMutation().updateMultipleEmployee(patches, context)
78
+ }
79
+ }
80
+ Arg('patches', () => [EmployeePatch])(EmployeeDayProbe.prototype, 'updateMultipleEmployee', 0)
81
+ Ctx()(EmployeeDayProbe.prototype, 'updateMultipleEmployee', 1)
82
+ Mutation(() => [Employee])(
83
+ EmployeeDayProbe.prototype,
84
+ 'updateMultipleEmployee',
85
+ Object.getOwnPropertyDescriptor(EmployeeDayProbe.prototype, 'updateMultipleEmployee')
86
+ )
87
+ Query(() => Boolean)(EmployeeDayProbe.prototype, 'probe', Object.getOwnPropertyDescriptor(EmployeeDayProbe.prototype, 'probe'))
88
+ Resolver()(EmployeeDayProbe)
89
+
90
+ schema = await buildSchema({ resolvers: [EmployeeDayProbe], validate: false })
91
+ })
92
+
93
+ beforeEach(async () => {
94
+ await dataSource.synchronize(true)
95
+ domain = await dataSource.getRepository(shell.Domain).save({ name: 'employee-day', subdomain: 'employee-day' })
96
+ user = await dataSource.getRepository(authBase.User).save({ name: 'tester', email: 'tester@employee-day.test' })
97
+ })
98
+
99
+ afterAll(async () => {
100
+ if (!dataSource) return
101
+ shell.removeDataSource('default')
102
+ shell.removeDataSource('tx')
103
+ await dataSource.destroy()
104
+ })
105
+
106
+ async function save(patches: any[]) {
107
+ return await graphql({
108
+ schema,
109
+ source: SAVE,
110
+ variableValues: { patches },
111
+ contextValue: { state: { domain, user, tx: dataSource.manager } }
112
+ })
113
+ }
114
+
115
+ async function seedEmployee() {
116
+ return await dataSource.getRepository(Employee).save({ domain, controlNo: 'E-1', name: 'Kim', hiredOn: '2020-01-15' })
117
+ }
118
+
119
+ describe('employee hire and retire days are calendar days', () => {
120
+ it("the grid's patch with retiredOn '2025-12-31' saves and reads back unchanged", async () => {
121
+ const employee = await seedEmployee()
122
+
123
+ const result = await save([{ id: employee.id, cuFlag: 'M', retiredOn: '2025-12-31' }])
124
+
125
+ expect(result.errors).toBeUndefined()
126
+ expect(result.data.updateMultipleEmployee).toEqual([{ id: employee.id, hiredOn: '2020-01-15', retiredOn: '2025-12-31' }])
127
+
128
+ const stored = await dataSource.getRepository(Employee).findOneBy({ id: employee.id })
129
+ expect(stored.retiredOn).toBe('2025-12-31')
130
+ expect(stored.hiredOn).toBe('2020-01-15')
131
+
132
+ const history = await dataSource.getRepository(EmployeeHistory).find({ where: { originalId: employee.id } })
133
+ expect(history.map(row => row.retiredOn)).toContain('2025-12-31')
134
+ })
135
+
136
+ it('hiredOn edited in the grid saves too', async () => {
137
+ const employee = await seedEmployee()
138
+
139
+ const result = await save([{ id: employee.id, cuFlag: 'M', hiredOn: '2021-03-02' }])
140
+
141
+ expect(result.errors).toBeUndefined()
142
+ expect((await dataSource.getRepository(Employee).findOneBy({ id: employee.id })).hiredOn).toBe('2021-03-02')
143
+ })
144
+
145
+ it("a cleared date cell ('') removes the day", async () => {
146
+ const employee = await seedEmployee()
147
+
148
+ const result = await save([{ id: employee.id, cuFlag: 'M', hiredOn: '' }])
149
+
150
+ expect(result.errors).toBeUndefined()
151
+ expect((await dataSource.getRepository(Employee).findOneBy({ id: employee.id })).hiredOn).toBeNull()
152
+ })
153
+
154
+ it('a value that is not a calendar day is refused, not stored', async () => {
155
+ const employee = await seedEmployee()
156
+
157
+ for (const value of ['2025-12-31T00:00:00.000Z', '2025-02-30', '45657']) {
158
+ const result = await save([{ id: employee.id, cuFlag: 'M', retiredOn: value }])
159
+ expect(result.errors?.[0]?.message).toMatch(/retiredOn must be a calendar day/)
160
+ }
161
+ expect((await dataSource.getRepository(Employee).findOneBy({ id: employee.id })).retiredOn).toBeNull()
162
+ })
163
+ })
@@ -19,6 +19,7 @@
19
19
  "field.notify-after": "notify at",
20
20
  "field.owner": "owner",
21
21
  "field.owner-type": "owner type",
22
+ "field.parent-department": "parent department",
22
23
  "field.participation": "capacity",
23
24
  "field.register-account": "account",
24
25
  "field.supervisor": "supervisor",
@@ -19,6 +19,7 @@
19
19
  "field.notify-after": "通知時点",
20
20
  "field.owner": "所有者",
21
21
  "field.owner-type": "所有者タイプ",
22
+ "field.parent-department": "上位部署",
22
23
  "field.participation": "資格",
23
24
  "field.register-account": "アカウント登録",
24
25
  "field.supervisor": "監督者",
@@ -19,6 +19,7 @@
19
19
  "field.notify-after": "알림 시점",
20
20
  "field.owner": "소유자",
21
21
  "field.owner-type": "소유자 종류",
22
+ "field.parent-department": "상위 부서",
22
23
  "field.participation": "자격",
23
24
  "field.register-account": "계정 등록",
24
25
  "field.supervisor": "감독자",
@@ -19,6 +19,7 @@
19
19
  "field.notify-after": "masa notis",
20
20
  "field.owner": "pemilik",
21
21
  "field.owner-type": "jenis pemilik",
22
+ "field.parent-department": "jabatan induk",
22
23
  "field.participation": "kapasiti",
23
24
  "field.register-account": "daftar akaun",
24
25
  "field.supervisor": "penyelia",
@@ -19,6 +19,7 @@
19
19
  "field.notify-after": "通知时点",
20
20
  "field.owner": "所有者",
21
21
  "field.owner-type": "所有者类型",
22
+ "field.parent-department": "上级部门",
22
23
  "field.participation": "身份",
23
24
  "field.register-account": "注册账户",
24
25
  "field.supervisor": "主管",