@coworkee/airworks 0.0.1029 → 0.0.1031

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.
@@ -9,4 +9,6 @@ export declare const editionsApp: (coworkee: Coworkee) => CoworkeeApp;
9
9
  export declare const tenantsApp: (coworkee: Coworkee) => CoworkeeApp;
10
10
  export declare const auditApp: (coworkee: Coworkee) => CoworkeeApp;
11
11
  export declare const aiToolCallsApp: (coworkee: Coworkee) => CoworkeeApp;
12
+ /** The API documentation (Swagger UI) of the app in a window. */
13
+ export declare const apiDocsApp: (coworkee: Coworkee) => CoworkeeApp;
12
14
  export declare const systemApps: ((coworkee: Coworkee) => CoworkeeApp)[];
@@ -3,6 +3,7 @@ import { choices, crudApp, renderCrud } from "../../crud.js";
3
3
  import { esc } from "../../dom.js";
4
4
  import { confirmDialog, formDialog, infoDialog } from "../../form.js";
5
5
  import { icons } from "../../icons.js";
6
+ import { recordAppId } from "../record.js";
6
7
  const json = (method, body) => ({ method, body: body === undefined ? undefined : JSON.stringify(body) });
7
8
  const check = (value) => value ? '✓' : '';
8
9
  const paged = (page, pageSize, extra = {}) => new URLSearchParams({ Page: String(page), PageSize: String(pageSize), ...Object.fromEntries(Object.entries(extra).filter(([, value]) => value)) });
@@ -211,12 +212,19 @@ export const tenantsApp = crudApp({
211
212
  });
212
213
  const changesTable = (coworkee, changes) => `<table class="cw-table"><thead><tr><th>${esc(coworkee.t('Field'))}</th>
213
214
  <th>${esc(coworkee.t('Changes'))}</th></tr></thead><tbody>${changes.map((change) => `<tr><td>${esc(change.property)}</td><td>${esc(change.oldValue)} &rarr; ${esc(change.newValue)}</td></tr>`).join('')}</tbody></table>`;
215
+ /** The entity sets with a history; the audit log names their entity types. */
216
+ let historySets;
217
+ const setOf = (entityType) => historySets?.find((set) => set.entityType === entityType)?.entitySet;
214
218
  export const auditApp = crudApp({
215
219
  id: 'coworkee.admin.audit', group: 'Monitoring', title: 'Audit log', icon: icons.history, accent: '#5d4037', width: 960,
216
220
  permissions: ['Audit.View'],
217
221
  search: true, serverSearch: true,
218
222
  load: async ({ request }, { page, pageSize, search }) => {
219
- const result = await request(`/api/v1/audit?${paged(page, pageSize, { EntityType: search })}`);
223
+ const [result, sets] = await Promise.all([
224
+ request(`/api/v1/audit?${paged(page, pageSize, { EntityType: search })}`),
225
+ historySets ?? request('/api/v1/history').catch(() => []),
226
+ ]);
227
+ historySets = sets;
220
228
  return { items: result.items, total: Number(result.totalCount) };
221
229
  },
222
230
  columns: [
@@ -226,6 +234,11 @@ export const auditApp = crudApp({
226
234
  actions: [{
227
235
  title: 'Changes', icon: '&#x2139;',
228
236
  run: async (entry, coworkee) => { await infoDialog(coworkee, `${entry.entityType} ${entry.entityId}`, changesTable(coworkee, entry.changes), true); return false; },
237
+ }, {
238
+ // undo, restore a state or a deleted record: the record window has its history
239
+ title: 'Open record history', icon: '&#x21b6;',
240
+ visible: (entry) => !!setOf(entry.entityType),
241
+ run: async (entry, coworkee) => { coworkee.airWorks.openApp(recordAppId, { set: setOf(entry.entityType), id: entry.entityId }); return false; },
229
242
  }],
230
243
  });
231
244
  export const aiToolCallsApp = crudApp({
@@ -249,4 +262,9 @@ export const aiToolCallsApp = crudApp({
249
262
  },
250
263
  }],
251
264
  });
252
- export const systemApps = [languagesApp, translationsApp, themesApp, backupsApp, servicesApp, editionsApp, tenantsApp, auditApp, aiToolCallsApp];
265
+ /** The API documentation (Swagger UI) of the app in a window. */
266
+ export const apiDocsApp = (coworkee) => ({
267
+ id: 'coworkee.api-docs', group: 'Monitoring', title: coworkee.t('API documentation'), icon: icons.dataObject, accent: '#447d1c', width: 1000, height: 680,
268
+ requiredPermissions: ['ApiDocs.View'], url: '/swagger/index.html',
269
+ });
270
+ export const systemApps = [languagesApp, translationsApp, themesApp, backupsApp, servicesApp, editionsApp, tenantsApp, auditApp, aiToolCallsApp, apiDocsApp];
@@ -1,6 +1,8 @@
1
1
  import type { Coworkee, CoworkeeApp } from '../coworkee.ts';
2
2
  import { type EntityTableOptions } from '../table.ts';
3
- /** Every OData entity set of the app the user may read, each as a table. */
3
+ /** Opens a row of an entity set in the record window. */
4
+ export declare const openRecord: (coworkee: Coworkee, entitySet: string, row: Record<string, unknown>) => void;
5
+ /** Every OData entity set the user may read: a searchable table per set, a click opens the record with its history and workflow, deleted records come back from the bin. */
4
6
  export declare const dataApp: (coworkee: Coworkee) => CoworkeeApp;
5
7
  export type EntityAppOptions = EntityTableOptions & {
6
8
  title?: string;
package/dist/apps/data.js CHANGED
@@ -1,36 +1,72 @@
1
1
  import { entitySets } from '@coworkee/client';
2
- import { errorText, esc } from "../dom.js";
2
+ import { display, errorText, esc, label, recordTitle } from "../dom.js";
3
+ import { recordAppId } from "./record.js";
3
4
  import { icons } from "../icons.js";
4
5
  import { renderEntityTable } from "../table.js";
5
- /** Every OData entity set of the app the user may read, each as a table. */
6
+ /** Opens a row of an entity set in the record window. */
7
+ export const openRecord = (coworkee, entitySet, row) => {
8
+ const id = row.Id ?? row.id;
9
+ if (id !== undefined && id !== null)
10
+ coworkee.airWorks.openApp(recordAppId, { set: entitySet, id: String(id), title: recordTitle(row) });
11
+ };
12
+ function renderDeleted(coworkee, panel, set) {
13
+ const { t, culture } = coworkee;
14
+ panel.innerHTML = `<p class="cw-muted">${esc(t('MudDataGrid_Loading'))}</p>`;
15
+ coworkee.request(`/api/v1/history/${encodeURIComponent(set)}/deleted`).then((items) => {
16
+ panel.innerHTML = `<table class="cw-table"><thead><tr><th>${esc(t('Name'))}</th><th>${esc(t('Deleted'))}</th><th>${esc(t('Who'))}</th></tr></thead><tbody>${items.map((item) => `<tr class="cw-clickable" tabindex="0" data-deleted="${esc(item.entityId)}" data-title="${esc(item.title ?? '')}"><td>${esc(item.title ?? item.entityId)}</td><td>${esc(display(item.deletedAt, culture))}</td><td>${esc(item.deletedByName ?? t('system'))}</td></tr>`).join('')
17
+ || `<tr><td colspan="3" class="cw-empty">${esc(t('Nothing was deleted.'))}</td></tr>`}</tbody></table>`;
18
+ }).catch((error) => { panel.innerHTML = `<p class="cw-error">${esc(errorText(error))}</p>`; });
19
+ }
20
+ /** Every OData entity set the user may read: a searchable table per set, a click opens the record with its history and workflow, deleted records come back from the bin. */
6
21
  export const dataApp = (coworkee) => ({
7
22
  id: 'coworkee.data',
8
- title: coworkee.t('Data'),
23
+ title: coworkee.t('Records'),
9
24
  icon: 'fn-reporttable.png',
10
25
  accent: '#167d79',
11
- width: 980,
12
- height: 620,
26
+ width: 1040,
27
+ height: 640,
13
28
  probe: '/odata',
14
- render: async ({ host, intent, setState }) => {
29
+ render: async ({ host, intent, setState, onClose }) => {
15
30
  const { t } = coworkee;
16
31
  host.classList.add('cw-host');
17
- const sets = (await entitySets()).sort((a, b) => a.name.localeCompare(b.name));
18
- host.innerHTML = `<div class="cw-app cw-data"><nav class="cw-list">${sets.map((set) => `<button data-set="${esc(set.name)}">${esc(t(set.name))}</button>`).join('') || `<p class="cw-muted">${esc(t('Nothing found.'))}</p>`}</nav><section class="cw-panel"></section></div>`;
32
+ const [sets, histories] = await Promise.all([entitySets(), coworkee.request('/api/v1/history').catch(() => [])]);
33
+ sets.sort((a, b) => label(t, a.name).localeCompare(label(t, b.name)));
34
+ host.innerHTML = `<div class="cw-app cw-data"><nav class="cw-list" aria-label="${esc(t('Records'))}">${sets.map((set) => `<button data-set="${esc(set.name)}">${esc(label(t, set.name))}</button>`).join('') || `<p class="cw-muted">${esc(t('Nothing found.'))}</p>`}</nav>
35
+ <section class="cw-stack"><div class="cw-views" hidden><button data-view="all" aria-pressed="true">${esc(t('All'))}</button><button data-view="deleted" aria-pressed="false">${esc(t('Deleted'))}</button></div>
36
+ <div class="cw-panel cw-grow"></div></section></div>`;
19
37
  const panel = host.querySelector('.cw-panel');
20
- const open = (name) => {
38
+ const views = host.querySelector('.cw-views');
39
+ let current = '';
40
+ let stop;
41
+ onClose(() => stop?.());
42
+ const open = (name, view = 'all') => {
43
+ current = name;
21
44
  host.querySelectorAll('[data-set]').forEach((button) => button.setAttribute('aria-current', String(button.dataset.set === name)));
22
- setState({ set: name }, t(name));
23
- try {
24
- renderEntityTable(coworkee, panel, { entitySet: name });
25
- }
26
- catch (error) {
27
- panel.innerHTML = `<p class="cw-error">${esc(errorText(error))}</p>`;
28
- }
45
+ host.querySelectorAll('[data-view]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.view === view)));
46
+ views.hidden = !histories.some((history) => history.entitySet === name && history.softDelete);
47
+ setState({ set: name }, label(t, name));
48
+ stop?.();
49
+ stop = undefined;
50
+ // a fresh element per set: the old table takes its listeners along
51
+ const content = document.createElement('div');
52
+ content.className = 'cw-fill';
53
+ panel.replaceChildren(content);
54
+ if (view === 'deleted')
55
+ return renderDeleted(coworkee, content, name);
56
+ const entityType = histories.find((history) => history.entitySet === name)?.entityType;
57
+ renderEntityTable(coworkee, content, { entitySet: name, liveType: entityType, onOpen: (row) => openRecord(coworkee, name, row) }, (callback) => { stop = callback; });
29
58
  };
30
- host.querySelector('nav').addEventListener('click', (event) => {
31
- const set = event.target.closest('[data-set]')?.dataset.set;
59
+ host.addEventListener('click', (event) => {
60
+ const target = event.target;
61
+ const set = target.closest('[data-set]')?.dataset.set;
62
+ const view = target.closest('[data-view]')?.dataset.view;
63
+ const deleted = target.closest('[data-deleted]');
32
64
  if (set)
33
65
  open(set);
66
+ else if (view)
67
+ open(current, view);
68
+ else if (deleted)
69
+ coworkee.airWorks.openApp(recordAppId, { set: current, id: deleted.dataset.deleted, title: deleted.dataset.title ?? '' });
34
70
  });
35
71
  const first = intent?.set && sets.some((set) => set.name === intent.set) ? intent.set : sets[0]?.name;
36
72
  if (first)
@@ -49,6 +85,6 @@ export const entityApp = (options) => (coworkee) => ({
49
85
  render: ({ host, onClose }) => {
50
86
  host.classList.add('cw-host');
51
87
  host.innerHTML = '<div class="cw-app"></div>';
52
- renderEntityTable(coworkee, host.firstElementChild, options, onClose);
88
+ renderEntityTable(coworkee, host.firstElementChild, { onOpen: (row) => openRecord(coworkee, options.entitySet, row), ...options }, onClose);
53
89
  },
54
90
  });
@@ -9,6 +9,7 @@ export declare const uploadFile: (file: File, folderId?: string) => Promise<{
9
9
  size: number | string;
10
10
  createdAt: string;
11
11
  createdBy: null | string;
12
+ isPublic: boolean;
12
13
  }>;
13
14
  /** The folders and files of the organisation: browse, upload (also by dropping files), rename, delete, download and preview. */
14
15
  export declare const filesApp: (coworkee: Coworkee) => CoworkeeApp;
@@ -1,5 +1,6 @@
1
1
  import { apiRequest, unwrap } from '@coworkee/client';
2
2
  import { bytes, display, errorText, esc } from "../dom.js";
3
+ import { confirmDialog, promptDialog } from "../form.js";
3
4
  import { filePreviewAppId } from "./file-preview.js";
4
5
  export const fileContentUrl = (id, download = false) => `/api/v1/files/${encodeURIComponent(id)}/content${download ? '?download=true' : ''}`;
5
6
  /** Uploads one file into a folder (the API takes the raw body). */
@@ -53,7 +54,7 @@ export const filesApp = (coworkee) => ({
53
54
  ...content.folders.map((folder) => `<tr data-folder="${esc(folder.id)}" data-name="${esc(folder.name)}"><td><span class="cw-icon">📁</span>${esc(folder.name)}</td><td></td><td>${esc(display(folder.createdAt, culture))}</td>${actions('folder', folder.id)}</tr>`),
54
55
  ...content.files.map((file) => `<tr data-file="${esc(file.id)}" data-name="${esc(file.name)}" data-type="${esc(file.contentType)}"><td><span class="cw-icon">📄</span>${esc(file.name)}</td><td>${esc(bytes(file.size, culture))}</td><td>${esc(display(file.createdAt, culture))}</td>${actions('file', file.id)}</tr>`),
55
56
  ];
56
- body.innerHTML = rows.join('') || `<tr><td colspan="4" class="cw-muted">${esc(t('(empty)'))}</td></tr>`;
57
+ body.innerHTML = rows.join('') || `<tr><td colspan="4" class="cw-empty">${esc(content.canUpload ? t('Drop files here') : t('(empty)'))}</td></tr>`;
57
58
  };
58
59
  const run = async (work) => {
59
60
  try {
@@ -66,7 +67,6 @@ export const filesApp = (coworkee) => ({
66
67
  };
67
68
  const upload = (files) => run(async () => { for (const file of [...files])
68
69
  await uploadFile(file, folderId()); });
69
- // shortcut: names and confirmations through the browser's prompt/confirm, an AirWorks dialog once one is wanted
70
70
  host.addEventListener('click', (event) => {
71
71
  const target = event.target;
72
72
  const action = target.closest('[data-action]')?.dataset.action;
@@ -80,9 +80,10 @@ export const filesApp = (coworkee) => ({
80
80
  else if (action === 'refresh')
81
81
  void load();
82
82
  else if (action === 'folder') {
83
- const name = prompt(t('New folder'))?.trim();
84
- if (name)
85
- void run(() => api.POST('/api/v1/files/folders', { body: { parentId: folderId() ?? null, name } }).then(unwrap));
83
+ void promptDialog(coworkee, t('New folder'), 'Name').then((name) => {
84
+ if (name?.trim())
85
+ void run(() => api.POST('/api/v1/files/folders', { body: { parentId: folderId() ?? null, name: name.trim() } }).then(unwrap));
86
+ });
86
87
  }
87
88
  else if (crumb !== undefined) {
88
89
  path.splice(Number(crumb) + 1);
@@ -91,19 +92,22 @@ export const filesApp = (coworkee) => ({
91
92
  else if (rename) {
92
93
  const [kind, id] = rename.split(':');
93
94
  const row = target.closest('tr');
94
- const name = prompt(t('Rename'), row.dataset.name)?.trim();
95
- if (name && name !== row.dataset.name) {
95
+ void promptDialog(coworkee, t('Rename'), 'Name', row.dataset.name).then((value) => {
96
+ const name = value?.trim();
97
+ if (!name || name === row.dataset.name)
98
+ return;
96
99
  void run(() => (kind === 'folder'
97
100
  ? api.PUT('/api/v1/files/folders/{id}/name', { params: { path: { id } }, body: { name } })
98
101
  : api.PUT('/api/v1/files/{id}/name', { params: { path: { id } }, body: { name } })).then(unwrap));
99
- }
102
+ });
100
103
  }
101
104
  else if (remove) {
102
105
  const [kind, id] = remove.split(':');
103
106
  const name = target.closest('tr').dataset.name;
104
- if (confirm(kind === 'folder' ? t("Delete the folder '{0}' and everything in it?", name) : t("Delete the file '{0}'?", name))) {
105
- void run(() => api.POST('/api/v1/files/delete', { body: kind === 'folder' ? { fileIds: [], folderIds: [id] } : { fileIds: [id], folderIds: [] } }).then(unwrap));
106
- }
107
+ void confirmDialog(coworkee, kind === 'folder' ? t("Delete the folder '{0}' and everything in it?", name) : t("Delete the file '{0}'?", name), t('Delete')).then((ok) => {
108
+ if (ok)
109
+ void run(() => api.POST('/api/v1/files/delete', { body: kind === 'folder' ? { fileIds: [], folderIds: [id] } : { fileIds: [id], folderIds: [] } }).then(unwrap));
110
+ });
107
111
  }
108
112
  else if (!target.closest('a, button')) {
109
113
  const row = target.closest('tr[data-folder], tr[data-file]');
@@ -0,0 +1,4 @@
1
+ import type { Coworkee, CoworkeeApp } from '../coworkee.ts';
2
+ export declare const inboxAppId = "coworkee.inbox";
3
+ /** What waits for the user: approvals to decide and notifications, live; the count shows on the tile. */
4
+ export declare const inboxApp: (coworkee: Coworkee) => CoworkeeApp;
@@ -0,0 +1,114 @@
1
+ import { realtimeEvents, realtimeTopics, unwrap } from '@coworkee/client';
2
+ import { display, errorText, esc, label } from "../dom.js";
3
+ import { icons } from "../icons.js";
4
+ import { decide, recordAppId } from "./record.js";
5
+ import { openWebPage } from "./web.js";
6
+ export const inboxAppId = 'coworkee.inbox';
7
+ function approvalsHtml(coworkee, approvals) {
8
+ const { t, culture } = coworkee;
9
+ return approvals.map((approval) => `<li data-approval="${esc(approval.id)}" data-set="${esc(approval.entityType)}" data-entity="${esc(approval.entityId)}">
10
+ <div class="cw-grow"><button class="cw-link" data-open>${esc(approval.title)}</button><br/>
11
+ <small class="cw-muted">${esc(label(t, approval.entityType))} · ${esc(t(approval.state))} · ${esc(t('Requested {0}', display(approval.createdAt, culture)))}${approval.requiresAll
12
+ ? ` · ${esc(t('{0} of {1} approved', approval.approvers.filter((a) => a.approved === true).length, approval.approvers.filter((a) => !a.escalation).length))}` : ''}</small>
13
+ ${approval.dueAt ? `<br/><small class="${new Date(approval.dueAt) < new Date() ? 'cw-error' : 'cw-muted'}">${esc(t('Due {0}', display(approval.dueAt, culture)))}</small>` : ''}</div>
14
+ ${approval.canDecide ? `<span class="cw-decide"><button class="cw-primary" data-approve="true">${esc(t('Approve'))}</button><button data-approve="false">${esc(t('Reject'))}</button></span>` : ''}</li>`).join('')
15
+ || `<li class="cw-empty">${esc(t('Nothing waits for your decision.'))}</li>`;
16
+ }
17
+ function notificationsHtml(coworkee, items) {
18
+ const { t, culture } = coworkee;
19
+ const linked = (item) => !!item.link && !!coworkee.user.appUrl;
20
+ return items.map((item) => {
21
+ const title = esc(t(item.title, ...(item.arguments ?? [])));
22
+ return `<li class="${item.readAt ? '' : 'unread'}" data-id="${esc(item.id)}" ${linked(item) ? `data-link="${esc(item.link)}"` : ''}>
23
+ <div class="cw-grow">${linked(item) ? `<button class="cw-link" data-follow>${title}</button>` : `<strong>${title}</strong>`}
24
+ ${item.body ? `<p>${esc(t(item.body, ...(item.arguments ?? [])))}</p>` : '<br/>'}<small class="cw-muted">${esc(display(item.createdAt, culture))}</small></div>
25
+ <span class="cw-row-actions">${item.readAt ? '' : `<button data-read title="${esc(t('Mark as read'))}" aria-label="${esc(t('Mark as read'))}">&#x2713;</button>`}
26
+ <button data-delete title="${esc(t('Delete'))}" aria-label="${esc(t('Delete'))}">&#x2715;</button></span></li>`;
27
+ }).join('') || `<li class="cw-empty">${esc(t('No notifications yet.'))}</li>`;
28
+ }
29
+ /** What waits for the user: approvals to decide and notifications, live; the count shows on the tile. */
30
+ export const inboxApp = (coworkee) => ({
31
+ id: inboxAppId,
32
+ title: coworkee.t('Inbox'),
33
+ icon: icons.inbox,
34
+ accent: '#a2273b',
35
+ width: 640,
36
+ height: 600,
37
+ singleInstance: true,
38
+ probe: '/api/v1/notifications/unread-count',
39
+ render: ({ host, intent, setState, onClose }) => {
40
+ const { t, api } = coworkee;
41
+ const withApprovals = coworkee.can('Workflows.View');
42
+ let tab = intent?.tab === 'notifications' || !withApprovals ? 'notifications' : 'approvals';
43
+ host.classList.add('cw-host');
44
+ host.innerHTML = `<div class="cw-app cw-inbox"><div class="cw-toolbar"><nav class="cw-tabs" role="tablist"></nav><span class="cw-spacer"></span>
45
+ <button data-action="all" hidden>${esc(t('Mark all as read'))}</button><button data-action="refresh" title="${esc(t('Refresh'))}" aria-label="${esc(t('Refresh'))}">&#x21bb;</button></div>
46
+ <ul class="cw-feed"></ul></div>`;
47
+ const list = host.querySelector('ul');
48
+ const tabs = host.querySelector('nav');
49
+ const load = async () => {
50
+ try {
51
+ const [approvals, notifications] = await Promise.all([
52
+ withApprovals ? coworkee.request('/api/v1/workflows/approvals').catch(() => []) : Promise.resolve([]),
53
+ api.GET('/api/v1/notifications', { params: { query: { Page: 1, PageSize: 50 } } }).then(unwrap),
54
+ ]);
55
+ const open = approvals.filter((approval) => approval.canDecide).length;
56
+ const unread = notifications.items.filter((item) => !item.readAt).length;
57
+ const sections = [...(withApprovals ? [['approvals', t('Approvals'), open]] : []), ['notifications', t('Notifications'), unread]];
58
+ tabs.innerHTML = sections.map(([id, title, count]) => `<button role="tab" data-tab="${id}" aria-selected="${id === tab}">${esc(title)}${count ? ` <span class="cw-badge">${count}</span>` : ''}</button>`).join('');
59
+ host.querySelector('[data-action="all"]').hidden = tab !== 'notifications' || unread === 0;
60
+ list.innerHTML = tab === 'approvals' ? approvalsHtml(coworkee, approvals) : notificationsHtml(coworkee, notifications.items);
61
+ }
62
+ catch (error) {
63
+ list.innerHTML = `<li class="cw-error">${esc(errorText(error))}</li>`;
64
+ }
65
+ };
66
+ const run = async (work) => {
67
+ try {
68
+ await work();
69
+ }
70
+ catch (error) {
71
+ coworkee.notify(errorText(error), 'error');
72
+ }
73
+ await load();
74
+ void coworkee.refreshBadges();
75
+ };
76
+ host.addEventListener('click', (event) => {
77
+ const target = event.target;
78
+ const item = target.closest('li');
79
+ const id = item?.dataset.id;
80
+ const action = target.closest('[data-action]')?.dataset.action;
81
+ const nextTab = target.closest('[data-tab]')?.dataset.tab;
82
+ const approve = target.closest('[data-approve]')?.dataset.approve;
83
+ if (nextTab) {
84
+ tab = nextTab;
85
+ setState({ tab });
86
+ void load();
87
+ }
88
+ else if (action === 'refresh')
89
+ void load();
90
+ else if (action === 'all')
91
+ void run(() => api.POST('/api/v1/notifications/read-all'));
92
+ else if (item?.dataset.approval && approve)
93
+ void run(() => decide(coworkee, item.dataset.approval, approve === 'true'));
94
+ else if (item?.dataset.approval && target.closest('[data-open]'))
95
+ coworkee.airWorks.openApp(recordAppId, { set: item.dataset.set, id: item.dataset.entity });
96
+ else if (id && target.closest('[data-read]'))
97
+ void run(() => api.POST('/api/v1/notifications/{id}/read', { params: { path: { id } } }));
98
+ else if (id && target.closest('[data-delete]'))
99
+ void run(() => api.DELETE('/api/v1/notifications/{id}', { params: { path: { id } } }));
100
+ else if (id && target.closest('[data-follow]')) {
101
+ openWebPage(coworkee, item.dataset.link, target.textContent ?? undefined);
102
+ if (item.classList.contains('unread'))
103
+ void run(() => api.POST('/api/v1/notifications/{id}/read', { params: { path: { id } } }));
104
+ }
105
+ });
106
+ // new notifications and approval requests arrive on the user's topic
107
+ const stop = coworkee.user.userId ? coworkee.realtime.subscribe(realtimeTopics.user(coworkee.user.userId), ({ type }) => {
108
+ if (type === realtimeEvents.entityChanged)
109
+ void load();
110
+ }) : undefined;
111
+ onClose(() => stop?.());
112
+ void load();
113
+ },
114
+ });
@@ -1,3 +1,3 @@
1
- import type { Coworkee, CoworkeeApp } from '../coworkee.ts';
2
- /** Name and phone of the signed-in user, the account pages of the auth server and sign out. */
3
- export declare const profileApp: (coworkee: Coworkee) => CoworkeeApp;
1
+ import type { Coworkee } from '../coworkee.ts';
2
+ /** Name and phone of the signed-in user, the account pages of the auth server and sign out; the first tab of the settings. */
3
+ export declare function renderProfile(coworkee: Coworkee, host: HTMLElement): Promise<void>;
@@ -1,40 +1,30 @@
1
1
  import { unwrap } from '@coworkee/client';
2
2
  import { errorText, esc } from "../dom.js";
3
- /** Name and phone of the signed-in user, the account pages of the auth server and sign out. */
4
- export const profileApp = (coworkee) => ({
5
- id: 'coworkee.profile',
6
- title: coworkee.t('Profile'),
7
- icon: 'ratio-personal.png',
8
- accent: '#00777a',
9
- width: 520,
10
- height: 520,
11
- singleInstance: true,
12
- render: async ({ host }) => {
13
- const { t, api } = coworkee;
14
- host.classList.add('cw-host');
15
- const profile = unwrap(await api.GET('/api/v1/identity/me'));
16
- const input = (name, label, value) => `<div class="cw-field"><label for="cw-profile-${name}">${esc(t(label))}</label><input id="cw-profile-${name}" name="${name}" value="${esc(value ?? '')}"/></div>`;
17
- host.innerHTML = `<div class="cw-app cw-profile"><header class="cw-profile-head">
18
- ${profile.avatarUrl ? `<img class="cw-avatar" src="${esc(profile.avatarUrl)}" alt=""/>` : `<span class="cw-avatar">${esc((coworkee.user.name ?? profile.email).charAt(0).toUpperCase())}</span>`}
19
- <div><strong>${esc(coworkee.user.name ?? '')}</strong><br/><span class="cw-muted">${esc(profile.email)}</span></div></header>
20
- <form>${input('firstName', 'First name', profile.firstName)}${input('lastName', 'Last name', profile.lastName)}${input('phoneNumber', 'Phone', profile.phoneNumber)}
21
- <div class="cw-actions">
22
- ${coworkee.user.manageUrl ? `<a class="cw-button" href="${esc(coworkee.user.manageUrl)}" target="_blank" rel="noopener">${esc(t('Account'))}</a>` : ''}
23
- <button type="button" data-action="logout">${esc(t('Sign out'))}</button>
24
- <button type="submit" class="cw-primary">${esc(t('Save'))}</button>
25
- </div></form></div>`;
26
- const form = host.querySelector('form');
27
- form.addEventListener('submit', async (event) => {
28
- event.preventDefault();
29
- const values = Object.fromEntries(new FormData(form));
30
- try {
31
- unwrap(await api.PUT('/api/v1/identity/me', { body: { firstName: values.firstName || null, lastName: values.lastName || null, phoneNumber: values.phoneNumber || null, address: profile.address ?? null } }));
32
- coworkee.notify(t('Saved'));
33
- }
34
- catch (error) {
35
- coworkee.notify(errorText(error), 'error');
36
- }
37
- });
38
- host.querySelector('[data-action="logout"]').addEventListener('click', () => void coworkee.airWorks.logout());
39
- },
40
- });
3
+ /** Name and phone of the signed-in user, the account pages of the auth server and sign out; the first tab of the settings. */
4
+ export async function renderProfile(coworkee, host) {
5
+ const { t, api } = coworkee;
6
+ const profile = unwrap(await api.GET('/api/v1/identity/me'));
7
+ const input = (name, label, value) => `<div class="cw-field"><label for="cw-profile-${name}">${esc(t(label))}</label><input id="cw-profile-${name}" name="${name}" value="${esc(value ?? '')}"/></div>`;
8
+ host.innerHTML = `<div class="cw-profile"><header class="cw-profile-head">
9
+ ${profile.avatarUrl ? `<img class="cw-avatar" src="${esc(profile.avatarUrl)}" alt=""/>` : `<span class="cw-avatar">${esc((coworkee.user.name ?? profile.email).charAt(0).toUpperCase())}</span>`}
10
+ <div><strong>${esc(coworkee.user.name ?? '')}</strong><br/><span class="cw-muted">${esc(profile.email)}</span></div></header>
11
+ <form>${input('firstName', 'First name', profile.firstName)}${input('lastName', 'Last name', profile.lastName)}${input('phoneNumber', 'Phone', profile.phoneNumber)}
12
+ <div class="cw-actions">
13
+ ${coworkee.user.manageUrl ? `<a class="cw-button" href="${esc(coworkee.user.manageUrl)}" target="_blank" rel="noopener">${esc(t('Account'))}</a>` : ''}
14
+ <button type="button" data-action="logout">${esc(t('Sign out'))}</button>
15
+ <button type="submit" class="cw-primary">${esc(t('Save'))}</button>
16
+ </div></form></div>`;
17
+ const form = host.querySelector('form');
18
+ form.addEventListener('submit', async (event) => {
19
+ event.preventDefault();
20
+ const values = Object.fromEntries(new FormData(form));
21
+ try {
22
+ unwrap(await api.PUT('/api/v1/identity/me', { body: { firstName: values.firstName || null, lastName: values.lastName || null, phoneNumber: values.phoneNumber || null, address: profile.address ?? null } }));
23
+ coworkee.notify(t('Saved'));
24
+ }
25
+ catch (error) {
26
+ coworkee.notify(errorText(error), 'error');
27
+ }
28
+ });
29
+ host.querySelector('[data-action="logout"]').addEventListener('click', () => void coworkee.airWorks.logout());
30
+ }
@@ -0,0 +1,8 @@
1
+ import type { Coworkee, CoworkeeApp } from '../coworkee.ts';
2
+ export declare const recordAppId = "coworkee.record";
3
+ /** Approves or rejects an approval with an optional comment; false when the user cancelled. */
4
+ export declare function decide(coworkee: Coworkee, id: string, approve: boolean): Promise<boolean>;
5
+ /** Renders one record of an OData entity set: its fields, its history with undo and restore, its workflow with steps and approvals. */
6
+ export declare function renderRecord(coworkee: Coworkee, host: HTMLElement, set: string, id: string, onTitle?: (title: string) => void): () => void;
7
+ /** One record in a window, opened from the records, the search, the inbox and the audit log with the intent { set, id }. */
8
+ export declare const recordApp: (coworkee: Coworkee) => CoworkeeApp;