@things-factory/shell 10.1.29 → 10.1.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/shell",
3
- "version": "10.1.29",
3
+ "version": "10.1.31",
4
4
  "description": "Core module for framework",
5
5
  "bin": {
6
6
  "things-factory": "bin/things-factory",
@@ -126,5 +126,5 @@
126
126
  "pg": "^8.7.3",
127
127
  "sqlite3": "^5.0.8"
128
128
  },
129
- "gitHead": "e6bf9dec003fe5e794746aaeb5852b3b50478232"
129
+ "gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
130
130
  }
@@ -12,11 +12,11 @@
12
12
  * compute time, so bundle order stops mattering, and a slot that is not opened cannot be reached.
13
13
  *
14
14
  * Three owners, one mechanism - no reliance on bundle order or specificity:
15
- * --brand-<slot> a product's accent (its own brand.css) 26 slots
15
+ * --brand-<slot> a product's accent (its own <product>-accent.css) 26 slots
16
16
  * --suite-<slot> the operato suite's shared greys (@things-factory/operato-suite) 17 slots
17
17
  * closed error x4, shadow, scrim - literal, nobody above the platform sets them 6 slots
18
18
  * A tenant's domain theme still wins over all of these: it writes --md-sys-color-* directly, later in
19
- * the bundle. Apps with neither brand.css nor the suite get the fallbacks, the shell values of before.
19
+ * the bundle. Apps with neither an accent file nor the suite get the fallbacks, the shell values of before.
20
20
  *
21
21
  * If md adds a slot the counts go red on purpose: someone has to decide whose it is.
22
22
  */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A refusal keeps its name on the way out, and gets its sentence from the caller's language.
3
+ *
4
+ * ── Why these four ────────────────────────────────────────────────────────
5
+ * The code is the contract (ADR-0042): a client reads `extensions.refusal.code` and decides what to
6
+ * show. If Apollo's own `code` were left empty it would read `INTERNAL_SERVER_ERROR`, and a refusal
7
+ * would look like a crash. If `t` were called without params the locale would be lost and a Korean
8
+ * reader would get English with no error anywhere. If a module has not declared the key, the English
9
+ * sentence has to win over the key printed raw.
10
+ *
11
+ * Moved here from operato-plant when worklist became the third consumer (ADR-0069 decision 3).
12
+ */
13
+
14
+ import { GraphQLError } from 'graphql'
15
+
16
+ import { Refusal, refusalKey, rethrowSpoken, speakRefusal } from '../server/refusal/refusal.js'
17
+
18
+ const refusal = () => new Refusal('approval-line-missing', { role: 'quality' }, 'no approval line stands for quality.')
19
+
20
+ describe('a refusal on the way out', () => {
21
+ it('carries its own name, and says the server refused on purpose', () => {
22
+ const error = speakRefusal(refusal(), {})
23
+
24
+ expect(error).toBeInstanceOf(GraphQLError)
25
+ expect(error.extensions.code).toBe('BAD_USER_INPUT')
26
+ expect(error.extensions.refusal).toEqual({ code: 'approval-line-missing', params: { role: 'quality' } })
27
+ })
28
+
29
+ it('asks for error.<code> and hands the values over, never undefined', () => {
30
+ const asked: Array<{ key: string; options?: Record<string, unknown> }> = []
31
+ const t = (key: string, options?: Record<string, unknown>) => {
32
+ asked.push({ key, options })
33
+ return '품질 결재선이 비어 있습니다.'
34
+ }
35
+
36
+ const error = speakRefusal(refusal(), { t })
37
+
38
+ expect(asked).toHaveLength(1)
39
+ expect(asked[0].key).toBe(refusalKey('approval-line-missing'))
40
+ /* `t(key, undefined)` answers in English however the request came in, so the values always travel. */
41
+ expect(asked[0].options).toEqual({ role: 'quality', defaultValue: 'no approval line stands for quality.' })
42
+ expect(error.message).toBe('품질 결재선이 비어 있습니다.')
43
+ })
44
+
45
+ it('reads as English when the module has not declared the key', () => {
46
+ /* i18next answers with defaultValue when the key is missing; a missing key must not print itself. */
47
+ const t = (_key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? _key)
48
+
49
+ expect(speakRefusal(refusal(), { t }).message).toBe('no approval line stands for quality.')
50
+ })
51
+ })
52
+
53
+ describe('rethrowSpoken', () => {
54
+ it('speaks a refusal', () => {
55
+ expect(() => rethrowSpoken(refusal(), {})).toThrow(GraphQLError)
56
+ })
57
+
58
+ it('leaves anything else exactly as it was - a crash is not a refusal', () => {
59
+ const crash = new TypeError('cannot read properties of undefined')
60
+
61
+ expect(() => rethrowSpoken(crash, {})).toThrow(crash)
62
+ })
63
+ })
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A theme file carries its owner's name - `themes/` is one namespace shared by every module.
3
+ *
4
+ * The builder's ThemeOverridePlugin collects `client/themes/*.css` from every module and keeps the
5
+ * FIRST file it meets for each file name, application first (webpack-plugins/theme-override-plugin.js).
6
+ * It never looks inside. So a module that ships `light.css` replaces the shell's `light.css` whole -
7
+ * including the 49 slots the shell opens as `var(--brand-*)` / `var(--suite-*)`, and including any
8
+ * fix made there later. A file with one declaration in it does that just as completely as a full
9
+ * palette.
10
+ *
11
+ * Hence the rule (ADR-0060 decision 2-1): inside things-factory only the shell carries the names of
12
+ * the shell's theme files. A product that means to replace one of them wholesale may still do it -
13
+ * that is what an application's own light.css was - but then it owns all 49 slots and stops
14
+ * receiving anything the platform decides.
15
+ *
16
+ * The exceptions below are named, with what has to happen for each to go. When one is dealt with
17
+ * this test goes red and the line gets deleted - that is the point of listing them rather than
18
+ * filtering them out.
19
+ */
20
+
21
+ import { readdirSync, existsSync } from 'fs'
22
+ import path from 'path'
23
+
24
+ const PACKAGES = path.resolve(__dirname, '..', '..')
25
+ const SHELL_THEMES = path.resolve(__dirname, '..', 'client', 'themes')
26
+
27
+ /**
28
+ * Files that carry a shell theme file's name today.
29
+ *
30
+ * - The generator templates are copied into a new application, so a new product is born replacing
31
+ * the shell theme - operato-warehouse was born that way. They are fixed once the four operato
32
+ * products have moved.
33
+ */
34
+ const KNOWN: string[] = [
35
+ 'generator/templates/app/client/themes/dark.css',
36
+ 'generator/templates/app/client/themes/light.css',
37
+ 'generator/templates/brand/client/themes/dark.css',
38
+ 'generator/templates/brand/client/themes/light.css'
39
+ ]
40
+
41
+ /** Every `client/themes/*.css` under packages, as a path relative to packages. */
42
+ function themeFiles(): string[] {
43
+ const found: string[] = []
44
+
45
+ const walk = (dir: string) => {
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name.startsWith('dist')) {
48
+ continue
49
+ }
50
+
51
+ const full = path.join(dir, entry.name)
52
+
53
+ if (entry.isDirectory()) {
54
+ walk(full)
55
+ } else if (entry.name.endsWith('.css') && path.basename(path.dirname(full)) === 'themes') {
56
+ if (path.basename(path.dirname(path.dirname(full))) === 'client') {
57
+ found.push(path.relative(PACKAGES, full))
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ walk(PACKAGES)
64
+
65
+ return found.sort()
66
+ }
67
+
68
+ describe('theme file names', () => {
69
+ it('finds the theme files at all - an empty scan would pass everything below', () => {
70
+ const files = themeFiles()
71
+
72
+ expect(files.length).toBeGreaterThan(20)
73
+ expect(files).toContain(path.join('shell', 'client', 'themes', 'light.css'))
74
+ })
75
+
76
+ it('only the shell carries the names of the shell theme files', () => {
77
+ const shellNames = new Set(readdirSync(SHELL_THEMES))
78
+ const collisions = themeFiles().filter(
79
+ file => !file.startsWith(`shell${path.sep}`) && shellNames.has(path.basename(file))
80
+ )
81
+
82
+ expect(collisions).toEqual(KNOWN.map(file => file.split('/').join(path.sep)))
83
+ })
84
+
85
+ it('every exception still exists - a stale line hides the next collision', () => {
86
+ for (const file of KNOWN) {
87
+ expect(existsSync(path.join(PACKAGES, file.split('/').join(path.sep)))).toBe(true)
88
+ }
89
+ })
90
+ })