@monimesl/dot-cli 1.0.0-beta.1 → 1.0.0-beta.11

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 (98) hide show
  1. package/README.md +22 -9
  2. package/dist/cli.js +15 -12
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{server-DMnWDdJ6.js → server-BhnO9_Zu.js} +56 -7
  5. package/dist/server-BhnO9_Zu.js.map +1 -0
  6. package/dist/templates/showcase/.env +2 -2
  7. package/dist/templates/showcase/dot.ts +22 -2
  8. package/dist/templates/showcase/pages/basics/bars/controller.ts +2 -2
  9. package/dist/templates/showcase/pages/basics/bars/view.dml +2 -1
  10. package/dist/templates/showcase/pages/basics/bindings/state.ts +2 -2
  11. package/dist/templates/showcase/pages/basics/bindings/view.dml +2 -2
  12. package/dist/templates/showcase/pages/basics/colors/controller.ts +1 -1
  13. package/dist/templates/showcase/pages/basics/concert/view.dml +1 -1
  14. package/dist/templates/showcase/pages/basics/control-flow/controller.ts +1 -1
  15. package/dist/templates/showcase/pages/basics/control-flow/view.dml +1 -1
  16. package/dist/templates/showcase/pages/basics/events/controller.ts +1 -1
  17. package/dist/templates/showcase/pages/basics/events/view.dml +1 -1
  18. package/dist/templates/showcase/pages/basics/hooks/controller.ts +2 -1
  19. package/dist/templates/showcase/pages/basics/hooks/view.dml +2 -2
  20. package/dist/templates/showcase/pages/basics/navigation/controller.ts +1 -0
  21. package/dist/templates/showcase/pages/basics/navigation/view.dml +1 -1
  22. package/dist/templates/showcase/pages/basics/pick-genre/controller.ts +3 -3
  23. package/dist/templates/showcase/pages/basics/share/view.dml +2 -2
  24. package/dist/templates/showcase/pages/basics/state/controller.ts +9 -0
  25. package/dist/templates/showcase/pages/basics/state/state.ts +3 -0
  26. package/dist/templates/showcase/pages/basics/theme/controller.ts +28 -0
  27. package/dist/templates/showcase/pages/basics/theme/state.ts +4 -0
  28. package/dist/templates/showcase/pages/basics/theme/view.dml +50 -0
  29. package/dist/templates/showcase/pages/components/capture/controller.ts +25 -0
  30. package/dist/templates/showcase/pages/components/capture/state.ts +7 -0
  31. package/dist/templates/showcase/pages/components/capture/view.dml +32 -0
  32. package/dist/templates/showcase/pages/components/carousel/view.dml +2 -2
  33. package/dist/templates/showcase/pages/components/feedback/controller.ts +2 -1
  34. package/dist/templates/showcase/pages/components/feedback/state.ts +2 -2
  35. package/dist/templates/showcase/pages/components/feedback/view.dml +2 -2
  36. package/dist/templates/showcase/pages/components/forms/controller.ts +2 -2
  37. package/dist/templates/showcase/pages/components/forms/state.ts +1 -1
  38. package/dist/templates/showcase/pages/components/forms/view.dml +6 -5
  39. package/dist/templates/showcase/pages/components/icons/controller.ts +1 -0
  40. package/dist/templates/showcase/pages/components/icons/state.ts +1 -1
  41. package/dist/templates/showcase/pages/components/images/controller.ts +1 -1
  42. package/dist/templates/showcase/pages/components/images/state.ts +1 -1
  43. package/dist/templates/showcase/pages/components/images/view.dml +4 -4
  44. package/dist/templates/showcase/pages/components/layout/controller.ts +5 -1
  45. package/dist/templates/showcase/pages/components/layout/state.ts +2 -0
  46. package/dist/templates/showcase/pages/components/layout/view.dml +44 -6
  47. package/dist/templates/showcase/pages/components/lists/view.dml +12 -7
  48. package/dist/templates/showcase/pages/components/money/controller.ts +1 -1
  49. package/dist/templates/showcase/pages/components/money/view.dml +2 -2
  50. package/dist/templates/showcase/pages/components/pinned/controller.ts +10 -0
  51. package/dist/templates/showcase/pages/components/pinned/state.ts +25 -0
  52. package/dist/templates/showcase/pages/components/pinned/view.dml +42 -0
  53. package/dist/templates/showcase/pages/components/reading/state.ts +2 -2
  54. package/dist/templates/showcase/pages/components/sheets/controller.ts +1 -1
  55. package/dist/templates/showcase/pages/components/sheets/view.dml +1 -1
  56. package/dist/templates/showcase/pages/components/styles/view.dml +39 -4
  57. package/dist/templates/showcase/pages/components/tabs/state.ts +2 -2
  58. package/dist/templates/showcase/pages/components/tabs/view.dml +6 -5
  59. package/dist/templates/showcase/pages/components/text/view.dml +4 -4
  60. package/dist/templates/showcase/pages/dash/account/controller.ts +3 -0
  61. package/dist/templates/showcase/pages/dash/account/view.dml +2 -2
  62. package/dist/templates/showcase/pages/dash/cache/controller.ts +45 -16
  63. package/dist/templates/showcase/pages/dash/cache/state.ts +1 -0
  64. package/dist/templates/showcase/pages/dash/cache/view.dml +4 -0
  65. package/dist/templates/showcase/pages/dash/calls/controller.ts +3 -2
  66. package/dist/templates/showcase/pages/dash/contacts/controller.ts +1 -0
  67. package/dist/templates/showcase/pages/dash/crypto/controller.ts +2 -0
  68. package/dist/templates/showcase/pages/dash/crypto/view.dml +1 -1
  69. package/dist/templates/showcase/pages/dash/files/controller.ts +67 -32
  70. package/dist/templates/showcase/pages/dash/files/state.ts +1 -0
  71. package/dist/templates/showcase/pages/dash/files/view.dml +5 -1
  72. package/dist/templates/showcase/pages/dash/media/controller.ts +7 -1
  73. package/dist/templates/showcase/pages/dash/media/state.ts +2 -0
  74. package/dist/templates/showcase/pages/dash/network/controller.ts +5 -1
  75. package/dist/templates/showcase/pages/dash/network/view.dml +1 -1
  76. package/dist/templates/showcase/pages/dash/payments/cases.ts +40 -22
  77. package/dist/templates/showcase/pages/dash/payments/controller.ts +27 -3
  78. package/dist/templates/showcase/pages/dash/payments/state.ts +2 -0
  79. package/dist/templates/showcase/pages/dash/payments/view.dml +3 -1
  80. package/dist/templates/showcase/pages/dash/reminders/controller.ts +73 -0
  81. package/dist/templates/showcase/pages/dash/reminders/state.ts +6 -0
  82. package/dist/templates/showcase/pages/dash/reminders/view.dml +34 -0
  83. package/dist/templates/showcase/pages/dash/secrets/controller.ts +37 -14
  84. package/dist/templates/showcase/pages/dash/secrets/state.ts +1 -0
  85. package/dist/templates/showcase/pages/dash/secrets/view.dml +4 -0
  86. package/dist/templates/showcase/pages/dash/system/controller.ts +5 -0
  87. package/dist/templates/showcase/pages/dash/system/view.dml +4 -4
  88. package/dist/templates/showcase/pages/dash/ui/controller.ts +6 -1
  89. package/dist/templates/showcase/pages/index/controller.ts +1 -0
  90. package/dist/templates/showcase/pages/index/state.ts +30 -6
  91. package/dist/templates/showcase/statics/icons/ticket-stub.svg +1 -0
  92. package/dist/templates/starter/.env +2 -2
  93. package/dist/templates/starter/dot.ts +1 -0
  94. package/dist/templates/starter/pages/index/controller.ts +2 -0
  95. package/dist/templates/starter/pages/index/state.ts +2 -0
  96. package/dist/templates/starter/pages/index/view.dml +2 -1
  97. package/package.json +6 -6
  98. package/dist/server-DMnWDdJ6.js.map +0 -1
@@ -1,3 +1,5 @@
1
+ import { errorMessage } from '@monimesl/dot-runtime';
2
+
1
3
  import state from './state';
2
4
 
3
5
  const RECENT = 'recent/';
@@ -12,38 +14,65 @@ export default definePage({
12
14
  await this.actions.refresh();
13
15
  },
14
16
  },
17
+ // Values are JSON: text, numbers, true or false, lists and objects. Each can be up to 1 MB,
18
+ // and the cache up to 64 MB. A full cache rejects with 'failed'.
15
19
  actions: {
16
20
  async refresh() {
17
- const keys = await this.dash.storage.cache.keys();
18
- const recent: string[] = [];
19
- for (const key of keys.filter((each) => each.startsWith(RECENT))) {
20
- recent.push((await this.dash.storage.cache.get<string>(key)) ?? '');
21
+ try {
22
+ const keys = await this.dash.storage.cache.keys();
23
+ const recent: string[] = [];
24
+ for (const key of keys.filter((each) => each.startsWith(RECENT))) {
25
+ // get gives null for a key that's missing or expired.
26
+ recent.push((await this.dash.storage.cache.get<string>(key)) ?? '');
27
+ }
28
+ const hasGenre = await this.dash.storage.cache.exists(GENRE);
29
+ this.setState({
30
+ recent,
31
+ genre: hasGenre ? ((await this.dash.storage.cache.get<string>(GENRE)) ?? '') : '',
32
+ keys: keys.join(', '),
33
+ });
34
+ } catch (error) {
35
+ this.setState({ note: errorMessage(error) });
21
36
  }
22
- const hasGenre = await this.dash.storage.cache.exists(GENRE);
23
- this.setState({
24
- recent,
25
- genre: hasGenre ? ((await this.dash.storage.cache.get<string>(GENRE)) ?? '') : '',
26
- keys: keys.join(', '),
27
- });
28
37
  },
29
38
  // Each view lasts 30 seconds, then the cache forgets it.
30
39
  async view(_state, event: DataEvent<{ id: string; name: string }>) {
31
- await this.dash.storage.cache.set(`${RECENT}${event.data.id}`, event.data.name, {
32
- ttl: RECENT_MILLISECONDS,
33
- });
40
+ try {
41
+ await this.dash.storage.cache.set(`${RECENT}${event.data.id}`, event.data.name, {
42
+ ttl: RECENT_MILLISECONDS,
43
+ });
44
+ this.setState({ note: '' });
45
+ } catch (error) {
46
+ this.setState({ note: errorMessage(error) });
47
+ }
34
48
  await this.actions.refresh();
35
49
  },
36
50
  // With no ttl, the genre lasts until it's removed.
37
51
  async setGenre(_state, event: ValueEvent) {
38
- await this.dash.storage.cache.set(GENRE, event.detail.value);
52
+ try {
53
+ await this.dash.storage.cache.set(GENRE, event.detail.value);
54
+ this.setState({ note: '' });
55
+ } catch (error) {
56
+ this.setState({ note: errorMessage(error) });
57
+ }
39
58
  await this.actions.refresh();
40
59
  },
41
60
  async forgetGenre() {
42
- await this.dash.storage.cache.remove(GENRE);
61
+ try {
62
+ await this.dash.storage.cache.remove(GENRE);
63
+ this.setState({ note: '' });
64
+ } catch (error) {
65
+ this.setState({ note: errorMessage(error) });
66
+ }
43
67
  await this.actions.refresh();
44
68
  },
45
69
  async clear() {
46
- await this.dash.storage.cache.clear();
70
+ try {
71
+ await this.dash.storage.cache.clear();
72
+ this.setState({ note: '' });
73
+ } catch (error) {
74
+ this.setState({ note: errorMessage(error) });
75
+ }
47
76
  await this.actions.refresh();
48
77
  },
49
78
  },
@@ -14,4 +14,5 @@ export default {
14
14
  recent: [] as string[],
15
15
  genre: '',
16
16
  keys: '',
17
+ note: '',
17
18
  };
@@ -45,4 +45,8 @@
45
45
  </View>
46
46
  </View>
47
47
  </View>
48
+
49
+ <If condition="{{ note }}">
50
+ <Notice tone="danger">{{ note }}</Notice>
51
+ </If>
48
52
  </View>
@@ -3,7 +3,7 @@ import { errorMessage, isDashError } from '@monimesl/dot-runtime';
3
3
 
4
4
  import state from './state';
5
5
 
6
- // The long wait, kept so the Cancel button can stop it.
6
+ // The long wait, kept so the Cancel button can stop it. cancel() makes it reject with 'cancelled'.
7
7
  let wait: DashRequest<void> | null = null;
8
8
 
9
9
  export default definePage({
@@ -27,7 +27,8 @@ export default definePage({
27
27
  try {
28
28
  switch (event.data.call) {
29
29
  case 'timeout':
30
- // Gives up after 1 second on a wait of 5.
30
+ // Every call takes { timeout } last, except payments.request, where Dash decides.
31
+ // This gives up after 1 second on a wait of 5.
31
32
  await this.dash.system.delay(5000, { timeout: 1000 });
32
33
  break;
33
34
  case 'cancelled':
@@ -6,6 +6,7 @@ export default definePage({
6
6
  initialState: state,
7
7
  configs: { navigationBar: { title: 'Contacts' } },
8
8
  actions: {
9
+ // Resolves with { name, phone }, the number as the contact has it, such as "076 123456".
9
10
  async choose() {
10
11
  try {
11
12
  const contact = await unlessCancelled(this.dash.contacts.pickPhone());
@@ -12,6 +12,7 @@ export default definePage({
12
12
  encode: (state) => ({ encoded: dash.utils.base64.encode(state.text) }),
13
13
  },
14
14
  actions: {
15
+ // Text goes in as UTF-8. Binary data goes in as { base64: '…' }.
15
16
  async hash(state) {
16
17
  try {
17
18
  const sha256 = await this.dash.crypto.sha256(state.text);
@@ -25,6 +26,7 @@ export default definePage({
25
26
  this.setState({ note: errorMessage(error) });
26
27
  }
27
28
  },
29
+ // randomBytes takes a count from 1 to 1024.
28
30
  async makeRandom() {
29
31
  try {
30
32
  const random = await this.dash.crypto.randomBytes(16);
@@ -1,6 +1,6 @@
1
1
  <View>
2
2
  <Text variant="title">dash.crypto</Text>
3
- <Text>Hashes, signatures and random values, worked out on the phone. Each gives hex, or base64 with { as: 'base64' }.</Text>
3
+ <Text>Hashes, signatures and random values, worked out on the phone. They come as hex, or as base64 with { as: 'base64' }. randomUUID gives a UUID.</Text>
4
4
 
5
5
  <View variant="card" gap="medium">
6
6
  <Input label="Text" value="{{ text }}" onChange="setText" />
@@ -1,3 +1,5 @@
1
+ import { errorMessage } from '@monimesl/dot-runtime';
2
+
1
3
  import type { Archive, Song } from './state';
2
4
  import state from './state';
3
5
 
@@ -14,57 +16,90 @@ export default definePage({
14
16
  mutations: {
15
17
  setDraft: (_state, event: ValueEvent) => ({ draft: event.detail.value }),
16
18
  },
19
+ // Paths are inside the dot's folder, and '' is the folder itself. A call on a path with nothing
20
+ // there rejects with 'missing', while stat gives null.
17
21
  actions: {
18
22
  async refresh() {
19
- const entries = await this.dash.storage.file.list('', { recursive: true });
20
- const songs: Song[] = [];
21
- for (const entry of entries) {
22
- if (entry.kind === 'file' && entry.path.startsWith('setlist/')) {
23
- songs.push({ path: entry.path, text: await this.dash.storage.file.read(entry.path) });
23
+ try {
24
+ const entries = await this.dash.storage.file.list('', { recursive: true });
25
+ const songs: Song[] = [];
26
+ for (const entry of entries) {
27
+ if (entry.kind === 'file' && entry.path.startsWith('setlist/')) {
28
+ songs.push({ path: entry.path, text: await this.dash.storage.file.read(entry.path) });
29
+ }
24
30
  }
31
+ const archives: Archive[] = entries
32
+ .filter((entry) => entry.kind === 'folder' && /^archive\/[^/]+$/.test(entry.path))
33
+ .map((entry) => ({
34
+ path: entry.path,
35
+ time: new Date(Number(entry.name)).toLocaleTimeString('en-GB'),
36
+ }))
37
+ .reverse();
38
+ const documents = entries.filter(
39
+ (entry) => entry.kind === 'file' && entry.path.startsWith('documents/'),
40
+ );
41
+ // In bytes: what the dot's files take, and the most they may take.
42
+ const { used, quota } = await this.dash.storage.file.usage();
43
+ const kept = await this.dash.storage.file.stat(PICTURE);
44
+ this.setState({
45
+ songs,
46
+ archives,
47
+ documents,
48
+ usage: `${used} bytes used of ${quota / 1024 / 1024} MB`,
49
+ picture: kept ? (await this.dash.storage.file.open(PICTURE)).url : '',
50
+ });
51
+ } catch (error) {
52
+ this.setState({ note: errorMessage(error) });
25
53
  }
26
- const archives: Archive[] = entries
27
- .filter((entry) => entry.kind === 'folder' && /^archive\/[^/]+$/.test(entry.path))
28
- .map((entry) => ({
29
- path: entry.path,
30
- time: new Date(Number(entry.name)).toLocaleTimeString('en-GB'),
31
- }))
32
- .reverse();
33
- const documents = entries.filter(
34
- (entry) => entry.kind === 'file' && entry.path.startsWith('documents/'),
35
- );
36
- const { used, quota } = await this.dash.storage.file.usage();
37
- const kept = await this.dash.storage.file.stat(PICTURE);
38
- this.setState({
39
- songs,
40
- archives,
41
- documents,
42
- usage: `${used} bytes used of ${quota / 1024 / 1024} MB`,
43
- picture: kept ? (await this.dash.storage.file.open(PICTURE)).url : '',
44
- });
45
54
  },
46
55
  async save(state) {
47
- await this.dash.storage.file.write(`setlist/${Date.now()}.txt`, state.draft);
48
- this.setState({ draft: '' });
56
+ try {
57
+ await this.dash.storage.file.write(`setlist/${Date.now()}.txt`, state.draft);
58
+ this.setState({ draft: '', note: '' });
59
+ } catch (error) {
60
+ this.setState({ note: errorMessage(error) });
61
+ }
49
62
  await this.actions.refresh();
50
63
  },
51
64
  // Moves the whole setlist folder into the archive, under the time it was archived.
52
65
  async archive() {
53
- await this.dash.storage.file.createFolder('archive');
54
- await this.dash.storage.file.move('setlist', `archive/${Date.now()}`);
66
+ try {
67
+ await this.dash.storage.file.createFolder('archive');
68
+ await this.dash.storage.file.move('setlist', `archive/${Date.now()}`);
69
+ this.setState({ note: '' });
70
+ } catch (error) {
71
+ this.setState({ note: errorMessage(error) });
72
+ }
55
73
  await this.actions.refresh();
56
74
  },
57
- // Copies an archived setlist back, in place of the current one.
75
+ // Copies an archived setlist back, in place of the current one. Without overwrite, a path
76
+ // that's taken rejects with 'failed'.
58
77
  async copyBack(_state, event: DataEvent<{ path: string }>) {
59
- await this.dash.storage.file.copy(event.data.path, 'setlist', { overwrite: true });
78
+ try {
79
+ await this.dash.storage.file.copy(event.data.path, 'setlist', { overwrite: true });
80
+ this.setState({ note: '' });
81
+ } catch (error) {
82
+ this.setState({ note: errorMessage(error) });
83
+ }
60
84
  await this.actions.refresh();
61
85
  },
86
+ // A folder with files in it needs recursive.
62
87
  async clearSetlist() {
63
- await this.dash.storage.file.remove('setlist', { recursive: true });
88
+ try {
89
+ await this.dash.storage.file.remove('setlist', { recursive: true });
90
+ this.setState({ note: '' });
91
+ } catch (error) {
92
+ this.setState({ note: errorMessage(error) });
93
+ }
64
94
  await this.actions.refresh();
65
95
  },
66
96
  async forgetPicture() {
67
- await this.dash.storage.file.remove(PICTURE);
97
+ try {
98
+ await this.dash.storage.file.remove(PICTURE);
99
+ this.setState({ note: '' });
100
+ } catch (error) {
101
+ this.setState({ note: errorMessage(error) });
102
+ }
68
103
  await this.actions.refresh();
69
104
  },
70
105
  },
@@ -20,4 +20,5 @@ export default {
20
20
  picture: '',
21
21
  // Files saved into the dot's documents folder, such as a ticket from the dash.media page.
22
22
  documents: [] as FileEntry[],
23
+ note: '',
23
24
  };
@@ -1,6 +1,6 @@
1
1
  <View>
2
2
  <Text variant="title">dash.storage.file</Text>
3
- <Text>Each dot has its own folder on the phone. Other dots can't see it, and its files stay after the dot closes.</Text>
3
+ <Text>Each dot has its own folder on the phone. Other dots can't see it, and its files stay after the dot closes. Like the cache and secrets, it isn't backed up, so keep what matters on the dot's server too.</Text>
4
4
 
5
5
  <View gap="small">
6
6
  <Text variant="subtitle">write, read and list</Text>
@@ -75,4 +75,8 @@
75
75
  <Text variant="subtitle">usage</Text>
76
76
  <Text>{{ usage }}</Text>
77
77
  </View>
78
+
79
+ <If condition="{{ note }}">
80
+ <Notice tone="danger">{{ note }}</Notice>
81
+ </If>
78
82
  </View>
@@ -6,8 +6,12 @@ import state from './state';
6
6
  export default definePage({
7
7
  initialState: state,
8
8
  configs: { navigationBar: { title: 'Photos, files and scanning' } },
9
- // unlessCancelled gives undefined when the person closes the picker or scanner.
9
+ // Each call here needs a permission in dot.json. Dash asks the person the first time. If dot.json
10
+ // doesn't list it, or the person says no, the call rejects with 'denied'. unlessCancelled gives
11
+ // undefined when the person closes the picker or scanner.
10
12
  actions: {
13
+ // Resolves with a DotFile: a JPEG of at most 2048 pixels on its longest side, without its
14
+ // location. A phone without a camera rejects with 'unsupported'.
11
15
  async pickPhoto(_state, event: DataEvent<{ source: PhotoSource }>) {
12
16
  try {
13
17
  const photo = await unlessCancelled(
@@ -22,6 +26,7 @@ export default definePage({
22
26
  this.setState({ note: errorMessage(error) });
23
27
  }
24
28
  },
29
+ // A file larger than 25 MB rejects with 'invalid'.
25
30
  async pickTicket() {
26
31
  try {
27
32
  const ticket = await unlessCancelled(
@@ -51,6 +56,7 @@ export default definePage({
51
56
  this.setState({ note: errorMessage(error) });
52
57
  }
53
58
  },
59
+ // Resolves with { text, format }. Only QR codes for now.
54
60
  async scanTicket() {
55
61
  try {
56
62
  const scanned = await unlessCancelled(this.dash.media.scanCode());
@@ -1,6 +1,8 @@
1
1
  import type { DotFile } from '@monimesl/dot-runtime';
2
2
 
3
3
  export default {
4
+ // A DotFile has a name, a size in bytes, a contentType and a url to show it with. It's temporary,
5
+ // so it goes when the dot closes.
4
6
  photo: null as DotFile | null,
5
7
  photoDetails: '',
6
8
  ticket: null as DotFile | null,
@@ -87,6 +87,8 @@ export default definePage({
87
87
  this.setState({ connection: errorMessage(error) });
88
88
  }
89
89
  },
90
+ // Like fetch, it resolves with any answer, even a 404, so check ok. It rejects only when no
91
+ // answer comes, and with 'denied' for a host that network.hosts in dot.json doesn't list.
90
92
  async searchAsText(state) {
91
93
  try {
92
94
  const response = await this.dash.network.http.request({
@@ -113,6 +115,7 @@ export default definePage({
113
115
  this.setState({ limit: state.limit + PAGE });
114
116
  await this.actions.load();
115
117
  },
118
+ // With as: 'json', an answer that isn't JSON rejects with 'failed'.
116
119
  async load(state) {
117
120
  this.setState({ searching: true, note: '' });
118
121
  try {
@@ -153,7 +156,8 @@ export default definePage({
153
156
  return;
154
157
  }
155
158
  try {
156
- // The catalog's artwork comes in any size its address names.
159
+ // Resolves with ok and a temporary file for a 2xx answer of up to 256 MB. The catalog's
160
+ // artwork comes in any size its address names.
157
161
  const response = await this.dash.network.http.download({
158
162
  url: release.artwork.replace('100x100bb', '300x300bb'),
159
163
  name: 'artwork.jpg',
@@ -97,7 +97,7 @@
97
97
 
98
98
  <View gap="small">
99
99
  <Text variant="subtitle">Hosts dot.json doesn't list</Text>
100
- <Text variant="caption" tone="muted">Dash turns the request down.</Text>
100
+ <Text variant="caption" tone="muted">Dash turns the request down, with the code denied.</Text>
101
101
  <View variant="card" gap="small">
102
102
  <Button variant="secondary" onClick="tryUnlistedHost">Reach example.com</Button>
103
103
  <If condition="{{ note }}">
@@ -1,4 +1,10 @@
1
- import type { OrderLineItem, PaymentMethod, PaymentOptions } from '@monimesl/dot-runtime';
1
+ import type {
2
+ LinkProvider,
3
+ MomoProvider,
4
+ OrderLineItem,
5
+ PaymentMethod,
6
+ PaymentOptions,
7
+ } from '@monimesl/dot-runtime';
2
8
 
3
9
  // What the tester sets above the cases.
4
10
  export interface CaseInput {
@@ -17,6 +23,8 @@ export interface PaymentCase {
17
23
  reusesReference?: boolean;
18
24
  // Calls cancel() this long after asking.
19
25
  cancelAfterMilliseconds?: number;
26
+ // Asks a second time while the sheet is open, as a double tap would.
27
+ asksTwice?: boolean;
20
28
  }
21
29
 
22
30
  export interface CaseGroup {
@@ -26,8 +34,8 @@ export interface CaseGroup {
26
34
  cases: PaymentCase[];
27
35
  }
28
36
 
29
- // SLE 0.50 a ticket, in minor units as Monime counts money, so trying a case with real money costs
30
- // little: the two tickets the page starts with come to SLE 1.00.
37
+ // SLE 0.50 a ticket, in minor units as Monime counts money. In Dash the money is real, so the
38
+ // cases cost little: the two tickets the page starts with come to SLE 1.00.
31
39
  const TICKET_PRICE = 50;
32
40
  const SAMPLE_PHONE_NUMBER = '+23276123456';
33
41
  const SAMPLE_ACCOUNT_ID = 'fac-k6VgVTfSmLruHpiQgMg4DUeySZx';
@@ -115,13 +123,13 @@ export const GROUPS: CaseGroup[] = [
115
123
  id: 'orange',
116
124
  title: 'Orange Money only',
117
125
  expect: 'Mobile money with only Orange Money (m17).',
118
- options: paying([{ type: 'momo', providers: { include: ['m17'] } }]),
126
+ options: paying([{ type: 'momo', providers: ['m17'] }]),
119
127
  },
120
128
  {
121
129
  id: 'africell',
122
130
  title: 'Africell Money only',
123
131
  expect: 'Mobile money with only Africell Money (m18).',
124
- options: paying([{ type: 'momo', providers: { include: ['m18'] } }]),
132
+ options: paying([{ type: 'momo', providers: ['m18'] }]),
125
133
  },
126
134
  {
127
135
  id: 'one-number',
@@ -134,30 +142,30 @@ export const GROUPS: CaseGroup[] = [
134
142
  ),
135
143
  },
136
144
  {
137
- id: 'not-a-bank',
138
- title: 'Account at any provider but one bank',
139
- expect: 'Linked accounts, leaving out slb004.',
140
- options: paying([{ type: 'link', providers: { exclude: ['slb004'] } }]),
145
+ id: 'any-provider',
146
+ title: 'An empty list of providers',
147
+ expect: 'Linked accounts at any bank, as an empty list means all of them.',
148
+ options: paying([{ type: 'link', providers: [] }]),
141
149
  },
142
150
  {
143
- id: 'banks',
144
- title: 'Bank accounts only',
145
- expect: 'Linked accounts at slb001, slb004 and slb007 only, so no mobile money wallets.',
146
- options: paying([{ type: 'link', providers: { include: ['slb001', 'slb004', 'slb007'] } }]),
151
+ id: 'two-banks',
152
+ title: 'Accounts at two banks',
153
+ expect: 'Linked accounts at slb001 and slb009 only.',
154
+ options: paying([{ type: 'link', providers: ['slb001', 'slb009'] }]),
147
155
  },
148
156
  {
149
157
  id: 'orange-or-account',
150
158
  title: 'Orange Money from one number, or an account',
151
159
  expect:
152
- 'Mobile money limited to Orange Money and the number above, then linked accounts but slb004.',
160
+ 'Mobile money limited to Orange Money and the number above, then linked accounts at slb001 only.',
153
161
  options: (input, reference) =>
154
162
  paying([
155
163
  {
156
164
  type: 'momo',
157
- providers: { include: ['m17'] },
165
+ providers: ['m17'],
158
166
  phoneNumber: input.phoneNumber || SAMPLE_PHONE_NUMBER,
159
167
  },
160
- { type: 'link', providers: { exclude: ['slb004'] } },
168
+ { type: 'link', providers: ['slb001'] },
161
169
  ])(input, reference),
162
170
  },
163
171
  ],
@@ -261,6 +269,14 @@ export const GROUPS: CaseGroup[] = [
261
269
  options: (input, reference) => jazzNight(reference, [tickets(input)]),
262
270
  cancelAfterMilliseconds: 20_000,
263
271
  },
272
+ {
273
+ id: 'two-at-once',
274
+ title: 'Two requests at once',
275
+ expect:
276
+ 'One sheet opens, and it carries on. The second request rejects as denied, shown behind the sheet.',
277
+ options: (input, reference) => jazzNight(reference, [tickets(input)]),
278
+ asksTwice: true,
279
+ },
264
280
  ],
265
281
  },
266
282
  {
@@ -299,7 +315,7 @@ export const GROUPS: CaseGroup[] = [
299
315
  id: 'invalid',
300
316
  title: 'Refused before Dash',
301
317
  subtitle:
302
- 'Every host checks these the same way, so each rejects as invalid with the same message.',
318
+ 'Every host checks these the same way. Each rejects as invalid, with a message that starts as shown.',
303
319
  cases: [
304
320
  {
305
321
  id: 'no-items',
@@ -399,16 +415,18 @@ export const GROUPS: CaseGroup[] = [
399
415
  options: paying([{ type: 'card' }, { type: 'card' }]),
400
416
  },
401
417
  {
402
- id: 'both-filters',
403
- title: 'Include and exclude together',
404
- expect: "invalid: Give include or exclude for link's providers, not both",
405
- options: paying([{ type: 'link', providers: { include: ['m17'], exclude: ['m18'] } }]),
418
+ id: 'providers-not-a-list',
419
+ title: 'Providers that are not a list',
420
+ expect: 'invalid: link\'s providers must be a list of provider IDs, such as ["slb001"]',
421
+ // The types catch this. The cast gets past them, to show what Dash tells a dot in plain JavaScript.
422
+ options: paying([{ type: 'link', providers: 'slb001' as unknown as LinkProvider[] }]),
406
423
  },
407
424
  {
408
425
  id: 'unknown-provider',
409
426
  title: "A provider Monime doesn't know",
410
427
  expect: 'invalid: "m99" isn\'t a provider Monime knows for momo',
411
- options: paying([{ type: 'momo', providers: { include: ['m99'] } }]),
428
+ // The types catch this too.
429
+ options: paying([{ type: 'momo', providers: ['m99' as unknown as MomoProvider] }]),
412
430
  },
413
431
  {
414
432
  id: 'local-number',
@@ -24,6 +24,7 @@ export default definePage({
24
24
  setFinancialAccountId: (_state, event: ValueEvent) => ({
25
25
  financialAccountId: event.detail.value.trim(),
26
26
  }),
27
+ clearError: () => ({ error: '' }),
27
28
  },
28
29
  actions: {
29
30
  // data-case names the case to run, from cases.ts.
@@ -51,7 +52,16 @@ export default definePage({
51
52
  phoneNumber: state.phoneNumber,
52
53
  financialAccountId: state.financialAccountId,
53
54
  };
55
+ // Once the person picks a method, it resolves with how the payment ended: completed,
56
+ // pending, expired or cancelled. Before that, it rejects with 'cancelled' if the sheet
57
+ // closes, and with 'invalid' or 'denied' if Monime won't take the order.
54
58
  const request = this.dash.payments.request(paymentCase.options(input, reference));
59
+ if (paymentCase.asksTwice) {
60
+ // Only one sheet opens at a time, so this rejects as denied and the first carries on.
61
+ this.dash.payments
62
+ .request(paymentCase.options(input, `${reference}-again`))
63
+ .catch((error: unknown) => this.setState({ error: errorOutcome(error) }));
64
+ }
55
65
  const cancelAfter = paymentCase.cancelAfterMilliseconds;
56
66
  if (cancelAfter !== undefined) {
57
67
  // Runs beside the payment. Cancelling one that already ended does nothing.
@@ -67,6 +77,7 @@ export default definePage({
67
77
  this.setState(shownResult(result));
68
78
  } catch (error) {
69
79
  outcome = errorOutcome(error);
80
+ this.setState({ error: outcome });
70
81
  }
71
82
  // Another run may have started meanwhile, so this one names itself again.
72
83
  this.setState((current) => ({
@@ -74,9 +85,13 @@ export default definePage({
74
85
  runs: added(current.runs, paymentCase.title, outcome),
75
86
  }));
76
87
  },
88
+ // get resolves with the payment for a reference, or undefined if there is none.
77
89
  async checkPayment(state) {
78
90
  const title = 'Check the last payment';
79
91
  const found = await lookedUp(this.dash.payments.get(state.reference));
92
+ if (found.failed) {
93
+ this.setState({ error: found.outcome });
94
+ }
80
95
  this.setState((current) => ({
81
96
  ...found.shown,
82
97
  last: { title, expect: '', outcome: found.outcome },
@@ -86,6 +101,9 @@ export default definePage({
86
101
  async lookUpUnknown() {
87
102
  const title = 'Look up an order that never was';
88
103
  const found = await lookedUp(this.dash.payments.get('order-that-never-was'));
104
+ if (found.failed) {
105
+ this.setState({ error: found.outcome });
106
+ }
89
107
  this.setState((current) => ({
90
108
  last: {
91
109
  title,
@@ -102,11 +120,15 @@ async function lookedUp(lookup: Promise<PaymentResult | undefined>) {
102
120
  try {
103
121
  const result = await lookup;
104
122
  if (result === undefined) {
105
- return { outcome: 'undefined: there is no payment for this reference', shown: {} };
123
+ return {
124
+ outcome: 'undefined: there is no payment for this reference',
125
+ shown: {},
126
+ failed: false,
127
+ };
106
128
  }
107
- return { outcome: resultOutcome(result), shown: shownResult(result) };
129
+ return { outcome: resultOutcome(result), shown: shownResult(result), failed: false };
108
130
  } catch (error) {
109
- return { outcome: errorOutcome(error), shown: {} };
131
+ return { outcome: errorOutcome(error), shown: {}, failed: true };
110
132
  }
111
133
  }
112
134
 
@@ -114,6 +136,8 @@ function resultOutcome(result: PaymentResult): string {
114
136
  return `${result.status} by ${result.method}, ${moneyText(result.amount)}`;
115
137
  }
116
138
 
139
+ // isDashError(error) is true for an error from a Dash call, so it has a code. Give it codes, such
140
+ // as isDashError(error, 'invalid'), to check for those only.
117
141
  function errorOutcome(error: unknown): string {
118
142
  if (isDashError(error)) {
119
143
  return `${error.code}: ${error.message}`;
@@ -30,4 +30,6 @@ export default {
30
30
  requestId: '',
31
31
  orderNumber: '',
32
32
  runs: [] as Run[],
33
+ // The last error, shown as a toast so it's seen without scrolling to the runs.
34
+ error: '',
33
35
  };
@@ -1,6 +1,6 @@
1
1
  <View>
2
2
  <Text variant="title">dash.payments</Text>
3
- <Text>Takes a payment for an order through Dash's own payment sheet. The person picks a linked account, a card or mobile money, and the money goes to the Space that owns the dot. It needs the payments permission in dot.json. Each case below says what should happen. In Dash the money is real, so the orders cost a few cents.</Text>
3
+ <Text>Takes a payment for an order through Dash's own payment sheet. The person picks a linked account, a card or mobile money, and the money goes to the Space that owns the dot. It needs the payments permission in dot.json, which Dash doesn't ask the person for. Each case below says what should happen. In Dash the money is real, so the orders cost a few cents.</Text>
4
4
 
5
5
  <View gap="small">
6
6
  <Text variant="subtitle">The order</Text>
@@ -68,4 +68,6 @@
68
68
  </List>
69
69
  </View>
70
70
  </If>
71
+
72
+ <Toast message="{{ error }}" tone="danger" duration="{{ 6000 }}" onHide="clearError" />
71
73
  </View>