@sinequa/atomic-angular 1.15.1 → 1.17.0
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/document-workspaces/i18n/de.json +4 -2
- package/document-workspaces/i18n/en.json +4 -2
- package/document-workspaces/i18n/fr.json +4 -2
- package/document-workspaces/i18n/ja.json +4 -2
- package/document-workspaces/index.d.ts +84 -24
- package/fesm2022/sinequa-atomic-angular-document-workspaces.mjs +457 -236
- package/fesm2022/sinequa-atomic-angular-document-workspaces.mjs.map +1 -1
- package/fesm2022/sinequa-atomic-angular-workspace-elements.mjs +2 -2
- package/fesm2022/sinequa-atomic-angular-workspace-elements.mjs.map +1 -1
- package/package.json +1 -5
- package/elements/index.d.ts +0 -346
- package/fesm2022/sinequa-atomic-angular-elements.mjs +0 -7526
- package/fesm2022/sinequa-atomic-angular-elements.mjs.map +0 -1
|
@@ -7,7 +7,7 @@ import { Upload } from 'tus-js-client';
|
|
|
7
7
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
8
8
|
import { TranslocoService, TranslocoPipe, provideTranslocoScope } from '@jsverse/transloco';
|
|
9
9
|
import { Temporal } from 'temporal-polyfill';
|
|
10
|
-
import { DialogComponent, ButtonComponent, DialogContentComponent, DialogFooterComponent, DialogTitleComponent, FolderIcon, UserIcon, UserRoundIcon, FileIcon, TooltipDirective, TrashIcon, DialogService, ListIcon, MagnifyingGlassIcon, PlusIcon, XMarkIcon, FileSpreadsheetIcon, FileTextIcon, UploadIcon, DialogHeaderComponent, ChatPlusIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, EyeIcon, KeyIcon, MenuComponent, MenuContentComponent, MenuItemComponent, PenToSquareIcon, RefreshIcon, TagIcon, CheckIcon, CircleXIcon, SyncIcon } from '@sinequa/ui';
|
|
10
|
+
import { DialogComponent, ButtonComponent, DialogContentComponent, DialogFooterComponent, DialogTitleComponent, FolderIcon, UserIcon, UserRoundIcon, FileIcon, TooltipDirective, TrashIcon, DialogService, ListIcon, MagnifyingGlassIcon, PlusIcon, XMarkIcon, FileSpreadsheetIcon, FileTextIcon, UploadIcon, FileSearchIcon, DialogHeaderComponent, ChatPlusIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, EyeIcon, KeyIcon, MenuComponent, MenuContentComponent, MenuItemComponent, PenToSquareIcon, RefreshIcon, TagIcon, CheckIcon, CircleXIcon, SyncIcon } from '@sinequa/ui';
|
|
11
11
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
12
12
|
import { computePosition, offset, flip, shift, autoUpdate } from '@floating-ui/dom';
|
|
13
13
|
|
|
@@ -132,8 +132,8 @@ function withDocumentCountAdjusted(workspaces, id, delta) {
|
|
|
132
132
|
* touches the cache once the server has answered, so a rejected call leaves
|
|
133
133
|
* the state as it was — the call site reports the error.
|
|
134
134
|
*
|
|
135
|
-
* `addDocuments`
|
|
136
|
-
*
|
|
135
|
+
* `addDocuments`, `discardDocuments` and `patchDocument` stay purely local. The upload
|
|
136
|
+
* flow no longer uses them: the list is the index, and only the listing writes to it.
|
|
137
137
|
*/
|
|
138
138
|
const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Workspaces"), withState(initialState$1), withComputed(({ workspaces, pagination, selectedIds }) => ({
|
|
139
139
|
hasMore: computed(() => workspaces().length < pagination().total && workspaces().length < MAX_LOADED_WORKSPACES),
|
|
@@ -164,8 +164,8 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
|
|
|
164
164
|
};
|
|
165
165
|
});
|
|
166
166
|
};
|
|
167
|
-
const loadDocuments = async (workspaceId) => {
|
|
168
|
-
const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE });
|
|
167
|
+
const loadDocuments = async (workspaceId, signal) => {
|
|
168
|
+
const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE }, { signal });
|
|
169
169
|
const documents = res.documents.map(mapContainerDocumentToWorkspaceDocument);
|
|
170
170
|
patchState(store, state => ({
|
|
171
171
|
documentPagination: { ...state.documentPagination, [workspaceId]: { page: res.page, total: res.total } }
|
|
@@ -314,6 +314,23 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
|
|
|
314
314
|
}));
|
|
315
315
|
return { succeeded: result.succeeded, failed: result.failed.map(f => ({ id: f.containerId, error: f.error })) };
|
|
316
316
|
},
|
|
317
|
+
/**
|
|
318
|
+
* Local only — for when a read already proved a workspace is gone from the
|
|
319
|
+
* server (a 404), rather than this session deleting it: the detail view
|
|
320
|
+
* calls this once the user actually leaves a workspace its background
|
|
321
|
+
* refresh found deleted, instead of {@link remove}, which would call
|
|
322
|
+
* DELETE on something already deleted. Kept in the cache until then, so
|
|
323
|
+
* the view can still show its name while it explains what happened.
|
|
324
|
+
*/
|
|
325
|
+
forget(id) {
|
|
326
|
+
patchState(store, state => ({
|
|
327
|
+
workspaces: state.workspaces.filter(w => w.id !== id),
|
|
328
|
+
pagination: state.workspaces.some(w => w.id === id) ? { ...state.pagination, total: Math.max(0, state.pagination.total - 1) } : state.pagination,
|
|
329
|
+
documents: Object.fromEntries(Object.entries(state.documents).filter(([wid]) => wid !== id)),
|
|
330
|
+
documentPagination: Object.fromEntries(Object.entries(state.documentPagination).filter(([wid]) => wid !== id)),
|
|
331
|
+
selectedIds: state.selectedIds.filter(selected => selected !== id)
|
|
332
|
+
}));
|
|
333
|
+
},
|
|
317
334
|
loadDocuments,
|
|
318
335
|
/** Without this, a workspace with more than one page of documents is silently truncated. */
|
|
319
336
|
async loadMoreDocuments(workspaceId) {
|
|
@@ -336,17 +353,16 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
|
|
|
336
353
|
return merged;
|
|
337
354
|
},
|
|
338
355
|
/**
|
|
339
|
-
* Local only
|
|
340
|
-
*
|
|
341
|
-
* transfer completes.
|
|
356
|
+
* Local only: adds documents to the cache without calling the API. The upload
|
|
357
|
+
* flow does not use it (a file enters the list once the listing returns it).
|
|
342
358
|
*/
|
|
343
359
|
addDocuments(workspaceId, documents) {
|
|
344
360
|
setDocuments(workspaceId, [...(store.documents()[workspaceId] ?? []), ...documents]);
|
|
345
361
|
adjustDocumentCount(workspaceId, documents.length);
|
|
346
362
|
},
|
|
347
363
|
/**
|
|
348
|
-
* Local only, the counterpart of {@link addDocuments}: forgets documents
|
|
349
|
-
*
|
|
364
|
+
* Local only, the counterpart of {@link addDocuments}: forgets documents without
|
|
365
|
+
* calling the API.
|
|
350
366
|
* `removeDocuments` is the one that deletes on the server.
|
|
351
367
|
*/
|
|
352
368
|
discardDocuments(workspaceId, documentIds) {
|
|
@@ -355,7 +371,7 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
|
|
|
355
371
|
setDocuments(workspaceId, after);
|
|
356
372
|
adjustDocumentCount(workspaceId, after.length - before.length);
|
|
357
373
|
},
|
|
358
|
-
/**
|
|
374
|
+
/** Local only: merges a patch into one cached document, matched by id. */
|
|
359
375
|
patchDocument(workspaceId, documentId, patch) {
|
|
360
376
|
patchState(store, state => ({
|
|
361
377
|
documents: {
|
|
@@ -420,7 +436,8 @@ const initialState = {
|
|
|
420
436
|
workspaces: [],
|
|
421
437
|
connection: null,
|
|
422
438
|
clearedCount: 0,
|
|
423
|
-
jobIndexing: {}
|
|
439
|
+
jobIndexing: {},
|
|
440
|
+
ignoredJobs: {}
|
|
424
441
|
};
|
|
425
442
|
const FINISHED = ["done", "error", "rejected"];
|
|
426
443
|
const FAILED = ["error", "rejected"];
|
|
@@ -476,6 +493,45 @@ function phaseProgress(uploads, jobIndexing) {
|
|
|
476
493
|
function updateOne(uploads, id, change) {
|
|
477
494
|
return uploads.map(upload => (upload.id === id ? change(upload) : upload));
|
|
478
495
|
}
|
|
496
|
+
/** `ignored` plus the jobs of `uploads`. */
|
|
497
|
+
function withIgnored(ignored, uploads) {
|
|
498
|
+
const next = { ...ignored };
|
|
499
|
+
for (const upload of uploads)
|
|
500
|
+
next[jobOf(upload)] = true;
|
|
501
|
+
return next;
|
|
502
|
+
}
|
|
503
|
+
/** The row standing for a whole job, whose files the API does not list: `<jobId>/`. */
|
|
504
|
+
const remoteJobRowId = (jobId) => `${jobId}/`;
|
|
505
|
+
/** Where a whole job stands, as one row: what the API says of its two phases. */
|
|
506
|
+
function remoteJobState(job) {
|
|
507
|
+
if (job.uploadStatus === "uploading")
|
|
508
|
+
return { status: "uploading", percent: job.uploadProgress?.percentage ?? 0 };
|
|
509
|
+
if (job.uploadStatus === "upload_interrupted")
|
|
510
|
+
return { status: "error", percent: 0 };
|
|
511
|
+
if (job.indexingStatus === "complete")
|
|
512
|
+
return { status: "done", percent: 100 };
|
|
513
|
+
if (job.indexingStatus === "failed" || job.indexingStatus === "interrupted")
|
|
514
|
+
return { status: "error", percent: 0 };
|
|
515
|
+
// The job's aggregate indexing percentage lives in `jobIndexing`: no file has its own.
|
|
516
|
+
return { status: "indexing", percent: 0 };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The rows a job started elsewhere shows as: one per file the API names (errors and
|
|
520
|
+
* rejections, the only per-file data it has), and one for the job itself — unless a
|
|
521
|
+
* named file already explains why it failed.
|
|
522
|
+
*/
|
|
523
|
+
function remoteRows({ job, workspaceId, mine }) {
|
|
524
|
+
const base = { type: "doc", workspaceId, retryable: false, percent: 0, remote: { mine } };
|
|
525
|
+
const files = [
|
|
526
|
+
...(job.errors ?? []).map(e => ({ ...base, id: `${job.jobId}/${e.fileName}`, name: e.fileName, status: "error", message: e.error ?? undefined })),
|
|
527
|
+
...(job.rejected ?? []).map(r => ({ ...base, id: `${job.jobId}/${r.fileName}`, name: r.fileName, status: "rejected", message: r.reason }))
|
|
528
|
+
];
|
|
529
|
+
const state = remoteJobState(job);
|
|
530
|
+
if (state.status === "error" && files.length)
|
|
531
|
+
return files;
|
|
532
|
+
return [{ ...base, ...state, id: remoteJobRowId(job.jobId), name: "" }, ...files];
|
|
533
|
+
}
|
|
534
|
+
const sameRow = (a, b) => a.status === b.status && a.percent === b.percent && a.message === b.message && a.workspaceId === b.workspaceId && a.remote?.mine === b.remote?.mine;
|
|
479
535
|
/**
|
|
480
536
|
* The state of every upload the tracker follows — pure state, no I/O.
|
|
481
537
|
*
|
|
@@ -498,6 +554,13 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
|
|
|
498
554
|
}),
|
|
499
555
|
/** Everything is over and nothing failed: the panel celebrates, then dismisses itself. */
|
|
500
556
|
allDone: computed(() => uploads().length > 0 && uploads().every(u => u.status === "done")),
|
|
557
|
+
/** Everything is over, whether it worked or not: nothing moves any more, and the panel may be closed. */
|
|
558
|
+
settled: computed(() => uploads().length > 0 && uploads().every(isFinished)),
|
|
559
|
+
/**
|
|
560
|
+
* A failure on an upload of the current user's. It is theirs to read and close: the panel
|
|
561
|
+
* stays until they do. A failure on someone else's upload is only worth the usual delay.
|
|
562
|
+
*/
|
|
563
|
+
authorFailed: computed(() => uploads().some(u => isFailed(u) && (!u.remote || u.remote.mine))),
|
|
501
564
|
/** Per workspace, how far each phase is — what the tracker's progress bars show. */
|
|
502
565
|
progress: computed(() => {
|
|
503
566
|
const byWorkspace = new Map();
|
|
@@ -621,22 +684,62 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
|
|
|
621
684
|
patchState(store, { uploads: next, jobIndexing });
|
|
622
685
|
return changed;
|
|
623
686
|
},
|
|
687
|
+
/**
|
|
688
|
+
* Feeds in the jobs the status API reports that this client is not following — the ones
|
|
689
|
+
* another user started, or its own from before a page reload. Each becomes a row (see
|
|
690
|
+
* `remoteRows`), updated on every call; a job this client sent itself is left to the
|
|
691
|
+
* local rows, and so is one the user already got rid of.
|
|
692
|
+
*
|
|
693
|
+
* @returns Whether anything changed, which paces the poller.
|
|
694
|
+
*/
|
|
695
|
+
applyRemoteJobs(entries) {
|
|
696
|
+
const current = store.uploads();
|
|
697
|
+
const ignored = store.ignoredJobs();
|
|
698
|
+
const ownJobs = new Set(current.filter(u => !u.remote).map(jobOf));
|
|
699
|
+
const rows = new Map(current.map(u => [u.id, u]));
|
|
700
|
+
const workspaces = [...store.workspaces()];
|
|
701
|
+
let changed = false;
|
|
702
|
+
for (const entry of entries) {
|
|
703
|
+
if (ignored[entry.job.jobId] || ownJobs.has(entry.job.jobId))
|
|
704
|
+
continue;
|
|
705
|
+
for (const row of remoteRows(entry)) {
|
|
706
|
+
const known = rows.get(row.id);
|
|
707
|
+
if (known && sameRow(known, row))
|
|
708
|
+
continue;
|
|
709
|
+
rows.set(row.id, known ? { ...known, ...row } : row);
|
|
710
|
+
changed = true;
|
|
711
|
+
}
|
|
712
|
+
if (!workspaces.some(w => w.id === entry.workspaceId)) {
|
|
713
|
+
workspaces.push({ id: entry.workspaceId, name: entry.workspaceName });
|
|
714
|
+
changed = true;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (changed)
|
|
718
|
+
patchState(store, { uploads: [...rows.values()], workspaces });
|
|
719
|
+
return changed;
|
|
720
|
+
},
|
|
624
721
|
setConnection(connection) {
|
|
625
722
|
patchState(store, { connection });
|
|
626
723
|
},
|
|
627
724
|
remove(id) {
|
|
628
|
-
patchState(store, state =>
|
|
725
|
+
patchState(store, state => {
|
|
726
|
+
const uploads = state.uploads.filter(u => u.id !== id);
|
|
727
|
+
// The job is forgotten once its last row is gone; while one is left, the job is still followed.
|
|
728
|
+
const gone = state.uploads.filter(u => u.id === id && !uploads.some(other => jobOf(other) === jobOf(u)));
|
|
729
|
+
return { uploads, ignoredJobs: withIgnored(state.ignoredJobs, gone) };
|
|
730
|
+
});
|
|
629
731
|
},
|
|
630
732
|
/** Drops a finished workspace's files from the tracker; they stay counted as indexed. */
|
|
631
733
|
removeWorkspace(workspaceId) {
|
|
632
734
|
patchState(store, state => ({
|
|
633
735
|
uploads: state.uploads.filter(u => u.workspaceId !== workspaceId),
|
|
634
736
|
workspaces: state.workspaces.filter(w => w.id !== workspaceId),
|
|
635
|
-
clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length
|
|
737
|
+
clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length,
|
|
738
|
+
ignoredJobs: withIgnored(state.ignoredJobs, state.uploads.filter(u => u.workspaceId === workspaceId))
|
|
636
739
|
}));
|
|
637
740
|
},
|
|
638
741
|
reset() {
|
|
639
|
-
patchState(store, initialState);
|
|
742
|
+
patchState(store, state => ({ ...initialState, ignoredJobs: withIgnored(state.ignoredJobs, state.uploads) }));
|
|
640
743
|
}
|
|
641
744
|
})));
|
|
642
745
|
|
|
@@ -727,6 +830,12 @@ function isJobTerminal(job) {
|
|
|
727
830
|
function allJobs(res) {
|
|
728
831
|
return (res.containers ?? []).flatMap(c => c.jobs ?? []);
|
|
729
832
|
}
|
|
833
|
+
/** Like {@link allJobs}, but keeps the workspace each job belongs to. */
|
|
834
|
+
function containerJobs(res) {
|
|
835
|
+
return (res.containers ?? []).flatMap(c => (c.jobs ?? []).map(job => ({ job, containerId: c.containerId, containerName: c.containerName })));
|
|
836
|
+
}
|
|
837
|
+
/** How long the panel stays on screen once every upload is over. */
|
|
838
|
+
const FINISHED_UPLOAD_VISIBLE_MS = 2 * 60 * 1000;
|
|
730
839
|
|
|
731
840
|
/**
|
|
732
841
|
* `crypto.randomUUID()` is only available in a secure context (HTTPS, or HTTP on
|
|
@@ -952,9 +1061,6 @@ const UPLOAD_FACTORY = new InjectionToken("UPLOAD_FACTORY", {
|
|
|
952
1061
|
factory: () => createTusUpload
|
|
953
1062
|
});
|
|
954
1063
|
|
|
955
|
-
// One page is enough to refresh what the open workspace shows: the detail view
|
|
956
|
-
// itself loads page 1 at the same size.
|
|
957
|
-
const DOCUMENT_STATUS_PAGE_SIZE = 100;
|
|
958
1064
|
/**
|
|
959
1065
|
* Sends files to a workspace and follows them until they are indexed.
|
|
960
1066
|
*
|
|
@@ -969,6 +1075,10 @@ const DOCUMENT_STATUS_PAGE_SIZE = 100;
|
|
|
969
1075
|
* speaks for both. What the user sees lives in `UploadTrackerStore`; this
|
|
970
1076
|
* service only writes to it.
|
|
971
1077
|
*
|
|
1078
|
+
* It never writes a file into a workspace's list: the list is the index, and a file
|
|
1079
|
+
* enters it only once the container listing returns it. What the service does for the
|
|
1080
|
+
* list is re-read that listing while something is in progress.
|
|
1081
|
+
*
|
|
972
1082
|
* Root-scoped: an upload must outlive the view that started it. It registers its
|
|
973
1083
|
* window and auth listeners once, and removes them when the application is destroyed.
|
|
974
1084
|
*/
|
|
@@ -1036,37 +1146,20 @@ class UploadService {
|
|
|
1036
1146
|
const jobId = generateUUID();
|
|
1037
1147
|
const fileCount = files.length;
|
|
1038
1148
|
const totalBytes = files.reduce((total, f) => total + f.size, 0);
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
// then the document has no real id to act on.
|
|
1047
|
-
id: `${jobId}/${file.name}`,
|
|
1048
|
-
name: file.name,
|
|
1049
|
-
workspaceId: workspace.id,
|
|
1050
|
-
type: getDocumentType(file.name),
|
|
1051
|
-
createdAt: now,
|
|
1052
|
-
size: file.size,
|
|
1053
|
-
status: "uploading",
|
|
1054
|
-
percent: 0,
|
|
1055
|
-
jobId,
|
|
1056
|
-
uploadedBy
|
|
1057
|
-
}));
|
|
1058
|
-
this.workspaces.addDocuments(workspace.id, documents);
|
|
1059
|
-
this.tracker.track(documents.map(d => ({ id: d.id, name: d.name, type: d.type })), workspace.id, workspace.name);
|
|
1060
|
-
documents.forEach((document, i) => {
|
|
1061
|
-
this.inFlight.add(document.id);
|
|
1062
|
-
this.startUpload(document, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
|
|
1149
|
+
// The id is the key the tracker and this service follow the file by. It is NOT the
|
|
1150
|
+
// server's: the listing names a document `/DocUpload/…|<containerId>/<fileName>`.
|
|
1151
|
+
const items = files.map(file => ({ id: `${jobId}/${file.name}`, workspaceId: workspace.id }));
|
|
1152
|
+
this.tracker.track(files.map((file, i) => ({ id: items[i].id, name: file.name, type: getDocumentType(file.name) })), workspace.id, workspace.name);
|
|
1153
|
+
items.forEach((item, i) => {
|
|
1154
|
+
this.inFlight.add(item.id);
|
|
1155
|
+
this.startUpload(item, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
|
|
1063
1156
|
});
|
|
1064
1157
|
this.poller.start();
|
|
1065
|
-
return
|
|
1158
|
+
return items.map(item => item.id);
|
|
1066
1159
|
}
|
|
1067
1160
|
/**
|
|
1068
1161
|
* Abandons a transfer and forgets it: stops it, and drops the file from the
|
|
1069
|
-
* tracker
|
|
1162
|
+
* tracker. Works while it is sending
|
|
1070
1163
|
* and once it has failed; once the bytes are in, indexing is the server's and
|
|
1071
1164
|
* there is nothing left to cancel.
|
|
1072
1165
|
*
|
|
@@ -1083,7 +1176,6 @@ class UploadService {
|
|
|
1083
1176
|
this.inFlight.delete(id);
|
|
1084
1177
|
this.clearRetryNotice(id);
|
|
1085
1178
|
this.tracker.remove(id);
|
|
1086
|
-
this.workspaces.discardDocuments(entry.workspaceId, [this.storedId(entry.workspaceId, id)]);
|
|
1087
1179
|
try {
|
|
1088
1180
|
await entry.handle.abort(true);
|
|
1089
1181
|
}
|
|
@@ -1098,7 +1190,6 @@ class UploadService {
|
|
|
1098
1190
|
if (!entry || entry.refused || upload?.status !== "error")
|
|
1099
1191
|
return;
|
|
1100
1192
|
this.tracker.restart(id);
|
|
1101
|
-
this.patchStored(entry.workspaceId, id, { status: "uploading", percent: 0 });
|
|
1102
1193
|
this.inFlight.add(id);
|
|
1103
1194
|
entry.handle.resume();
|
|
1104
1195
|
this.poller.start();
|
|
@@ -1115,17 +1206,15 @@ class UploadService {
|
|
|
1115
1206
|
}
|
|
1116
1207
|
/**
|
|
1117
1208
|
* Tells the service which workspace is open, so the status polling can be
|
|
1118
|
-
* scoped to it (the API documents that as the cheaper lookup) and
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1209
|
+
* scoped to it (the API documents that as the cheaper lookup) and re-read its
|
|
1210
|
+
* listing. `null` when none is. Starts the polling: a page reload loses every
|
|
1211
|
+
* local tus handle and the listing no longer says what is in progress, so the
|
|
1212
|
+
* status API is the only way to know. The loop stops by itself after one tick
|
|
1213
|
+
* when nothing is.
|
|
1122
1214
|
*/
|
|
1123
1215
|
watch(workspaceId) {
|
|
1124
1216
|
this.focusedWorkspaceId = workspaceId;
|
|
1125
|
-
if (
|
|
1126
|
-
return;
|
|
1127
|
-
const unfinished = this.workspaces.documentsOf(workspaceId).some(d => d.status === "uploading" || d.status === "indexing");
|
|
1128
|
-
if (unfinished)
|
|
1217
|
+
if (workspaceId)
|
|
1129
1218
|
this.poller.start();
|
|
1130
1219
|
}
|
|
1131
1220
|
startPolling() {
|
|
@@ -1135,11 +1224,11 @@ class UploadService {
|
|
|
1135
1224
|
this.poller.stop();
|
|
1136
1225
|
}
|
|
1137
1226
|
// ── sending ──
|
|
1138
|
-
startUpload(
|
|
1139
|
-
const workspaceId =
|
|
1227
|
+
startUpload(item, file, job, endpoint) {
|
|
1228
|
+
const workspaceId = item.workspaceId;
|
|
1140
1229
|
if (!endpoint) {
|
|
1141
|
-
this.inFlight.delete(
|
|
1142
|
-
this.markError(
|
|
1230
|
+
this.inFlight.delete(item.id);
|
|
1231
|
+
this.markError(item);
|
|
1143
1232
|
return;
|
|
1144
1233
|
}
|
|
1145
1234
|
const entry = {
|
|
@@ -1161,25 +1250,25 @@ class UploadService {
|
|
|
1161
1250
|
// again once the user is back.
|
|
1162
1251
|
void this.recoverSessionAndResume();
|
|
1163
1252
|
if (this.signedOut) {
|
|
1164
|
-
this.clearRetryNotice(
|
|
1253
|
+
this.clearRetryNotice(item.id);
|
|
1165
1254
|
return false;
|
|
1166
1255
|
}
|
|
1167
1256
|
}
|
|
1168
1257
|
else if (FATAL_UPLOAD_STATUSES.has(status)) {
|
|
1169
1258
|
entry.refused = true;
|
|
1170
|
-
this.clearRetryNotice(
|
|
1259
|
+
this.clearRetryNotice(item.id);
|
|
1171
1260
|
return false;
|
|
1172
1261
|
}
|
|
1173
1262
|
// tus waits retryDelays[attempt] before this attempt and passes that same
|
|
1174
1263
|
// index here, so the banner's countdown is the real delay, not an estimate.
|
|
1175
|
-
this.noteRetryScheduled(
|
|
1264
|
+
this.noteRetryScheduled(item.id, attempt);
|
|
1176
1265
|
return true;
|
|
1177
1266
|
},
|
|
1178
1267
|
// Anything that came back cleanly means the connection works again for this
|
|
1179
1268
|
// upload, whatever the retry bookkeeping still says.
|
|
1180
1269
|
responded: status => {
|
|
1181
1270
|
if (status < 400)
|
|
1182
|
-
this.noteHealthy(
|
|
1271
|
+
this.noteHealthy(item.id);
|
|
1183
1272
|
},
|
|
1184
1273
|
// A recovery kicked off by a previous 401 may still be in flight, and
|
|
1185
1274
|
// carrying the token it is fetching is the entire point of this retry.
|
|
@@ -1192,67 +1281,42 @@ class UploadService {
|
|
|
1192
1281
|
// processed: on a fast transfer a stale event can still arrive after
|
|
1193
1282
|
// success moved the file to indexing. Drop anything that comes once the
|
|
1194
1283
|
// upload phase is over, so it cannot stomp the indexing percentage.
|
|
1195
|
-
if (this.tracker.uploads().find(u => u.id ===
|
|
1284
|
+
if (this.tracker.uploads().find(u => u.id === item.id)?.status !== "uploading")
|
|
1196
1285
|
return;
|
|
1197
|
-
this.tracker.setProgress(
|
|
1286
|
+
this.tracker.setProgress(item.id, bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0);
|
|
1198
1287
|
},
|
|
1199
1288
|
success: () => {
|
|
1200
1289
|
// Bytes are in; indexing is now the server's business, so the poller owns
|
|
1201
1290
|
// this document from here. Nothing left to resume.
|
|
1202
|
-
this.noteHealthy(
|
|
1203
|
-
this.uploads.delete(
|
|
1204
|
-
this.markIndexing(
|
|
1291
|
+
this.noteHealthy(item.id);
|
|
1292
|
+
this.uploads.delete(item.id);
|
|
1293
|
+
this.markIndexing(item);
|
|
1205
1294
|
this.poller.start();
|
|
1206
1295
|
},
|
|
1207
1296
|
failed: () => {
|
|
1208
1297
|
// The handle is kept: the retry budget is spent, but the upload is not lost —
|
|
1209
1298
|
// `retry()` can pick it up again and tus resumes from the server's offset.
|
|
1210
|
-
this.clearRetryNotice(
|
|
1211
|
-
this.inFlight.delete(
|
|
1212
|
-
this.markError(
|
|
1299
|
+
this.clearRetryNotice(item.id);
|
|
1300
|
+
this.inFlight.delete(item.id);
|
|
1301
|
+
this.markError(item, { retryable: !entry.refused });
|
|
1213
1302
|
}
|
|
1214
1303
|
}
|
|
1215
1304
|
})
|
|
1216
1305
|
};
|
|
1217
|
-
this.uploads.set(
|
|
1306
|
+
this.uploads.set(item.id, entry);
|
|
1218
1307
|
entry.handle.begin();
|
|
1219
1308
|
}
|
|
1220
|
-
// ── what a
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
keyOf(document) {
|
|
1227
|
-
return document.jobId ? `${document.jobId}/${document.name}` : document.id;
|
|
1228
|
-
}
|
|
1229
|
-
/** The id the workspace store holds for the file followed under `key`. */
|
|
1230
|
-
storedId(workspaceId, key) {
|
|
1231
|
-
return this.workspaces.documentsOf(workspaceId).find(d => this.keyOf(d) === key)?.id ?? key;
|
|
1232
|
-
}
|
|
1233
|
-
patchStored(workspaceId, key, patch) {
|
|
1234
|
-
this.workspaces.patchDocument(workspaceId, this.storedId(workspaceId, key), patch);
|
|
1235
|
-
}
|
|
1236
|
-
markIndexing(document) {
|
|
1237
|
-
const key = this.keyOf(document);
|
|
1238
|
-
this.patchStored(document.workspaceId, key, { status: "indexing" });
|
|
1239
|
-
this.tracker.setIndexing(key);
|
|
1240
|
-
}
|
|
1241
|
-
markComplete(document) {
|
|
1242
|
-
const key = this.keyOf(document);
|
|
1243
|
-
this.patchStored(document.workspaceId, key, { status: null });
|
|
1244
|
-
this.tracker.setDone(key);
|
|
1245
|
-
}
|
|
1246
|
-
markError(document, options = {}) {
|
|
1247
|
-
const key = this.keyOf(document);
|
|
1248
|
-
this.patchStored(document.workspaceId, key, { status: "error" });
|
|
1249
|
-
this.tracker.setError(key, options);
|
|
1309
|
+
// ── what a file's state change writes ──
|
|
1310
|
+
markIndexing(item) {
|
|
1311
|
+
this.tracker.setIndexing(item.id);
|
|
1312
|
+
}
|
|
1313
|
+
markError(item, options = {}) {
|
|
1314
|
+
this.tracker.setError(item.id, options);
|
|
1250
1315
|
}
|
|
1251
1316
|
// ── following indexing ──
|
|
1252
1317
|
//
|
|
1253
|
-
//
|
|
1254
|
-
// - GET .../container/{id}/documents ->
|
|
1255
|
-
// - GET .../document-upload/status -> aggregate, errors[] and rejected[] PER JOB
|
|
1318
|
+
// - GET .../document-upload/status -> the tracker: aggregate, errors[] and rejected[] PER JOB
|
|
1319
|
+
// - GET .../container/{id}/documents -> the workspace's list, which is the index and nothing else
|
|
1256
1320
|
async pollUploadStatus(signal) {
|
|
1257
1321
|
try {
|
|
1258
1322
|
const result = await this.runPollTick(signal);
|
|
@@ -1277,84 +1341,51 @@ class UploadService {
|
|
|
1277
1341
|
// the tracker still follows a batch after the user navigates away from it.
|
|
1278
1342
|
const response = await fetchUploadStatus(workspaceId ? { containerId: workspaceId } : {}, { signal });
|
|
1279
1343
|
const jobs = allJobs(response);
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1344
|
+
// Jobs started elsewhere come first, so that `applyJobs` finds their rows to update.
|
|
1345
|
+
const remote = await this.remoteJobs(response, signal);
|
|
1346
|
+
let changed = this.tracker.applyRemoteJobs(remote);
|
|
1347
|
+
changed = this.tracker.applyJobs(jobs) || changed;
|
|
1284
1348
|
this.settleFinishedUploads();
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
changed = changed || applied.changed;
|
|
1291
|
-
}
|
|
1349
|
+
const pending = jobs.some(job => !isJobTerminal(job));
|
|
1350
|
+
// The list follows the index, not the status: a file shows up once the listing returns it.
|
|
1351
|
+
// The last tick of a loop reads it too, which is when a file that just got indexed comes in.
|
|
1352
|
+
if (workspaceId)
|
|
1353
|
+
changed = (await this.refreshListing(workspaceId, signal)) || changed;
|
|
1292
1354
|
return { pending: pending || this.inFlight.size > 0, changed };
|
|
1293
1355
|
}
|
|
1294
1356
|
/**
|
|
1295
|
-
* The
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1357
|
+
* The jobs of the response the panel should show although this client did not send
|
|
1358
|
+
* them. The server decides which ones the response holds — those in progress or just
|
|
1359
|
+
* ended, on the workspaces the user can write to — so none is filtered by role or date
|
|
1360
|
+
* here; only, with a workspace open, those that belong to it are kept. Whether the user
|
|
1361
|
+
* started one is read from `initiatedBy`, which is their `userId`, so it holds after a
|
|
1362
|
+
* page reload too.
|
|
1298
1363
|
*/
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1364
|
+
async remoteJobs(response, signal) {
|
|
1365
|
+
const me = (await this.currentUser.whenReady())?.id;
|
|
1366
|
+
if (signal.aborted)
|
|
1367
|
+
return [];
|
|
1368
|
+
const entries = [];
|
|
1369
|
+
for (const { job, containerId, containerName } of containerJobs(response)) {
|
|
1370
|
+
if (this.focusedWorkspaceId && this.focusedWorkspaceId !== containerId)
|
|
1302
1371
|
continue;
|
|
1303
|
-
this.
|
|
1304
|
-
|
|
1372
|
+
const workspaceName = this.workspaces.workspace(containerId)?.name ?? containerName;
|
|
1373
|
+
entries.push({ job, workspaceId: containerId, workspaceName, mine: !!me && job.initiatedBy === me });
|
|
1305
1374
|
}
|
|
1375
|
+
return entries;
|
|
1306
1376
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
if (!document)
|
|
1319
|
-
continue;
|
|
1320
|
-
const key = this.keyOf(document);
|
|
1321
|
-
// From now on the store holds the id the server will recognise: delete and move send it.
|
|
1322
|
-
if (document.id !== wire.id)
|
|
1323
|
-
this.workspaces.patchDocument(workspaceId, document.id, { id: wire.id });
|
|
1324
|
-
// While tus is still sending, the local byte-level progress is ahead of anything
|
|
1325
|
-
// the server can report: it owns the percentage until tus hands over. An error
|
|
1326
|
-
// is the one thing worth taking early.
|
|
1327
|
-
if (this.inFlight.has(key) && document.status === "uploading") {
|
|
1328
|
-
if (next === "error") {
|
|
1329
|
-
this.inFlight.delete(key);
|
|
1330
|
-
this.markError(document);
|
|
1331
|
-
changed = true;
|
|
1332
|
-
}
|
|
1333
|
-
continue;
|
|
1334
|
-
}
|
|
1335
|
-
if (next === "indexing" && document.status !== "indexing") {
|
|
1336
|
-
this.markIndexing(document);
|
|
1337
|
-
changed = true;
|
|
1338
|
-
}
|
|
1339
|
-
else if (next === null && document.status !== null) {
|
|
1340
|
-
this.inFlight.delete(key);
|
|
1341
|
-
this.markComplete(document);
|
|
1342
|
-
changed = true;
|
|
1343
|
-
}
|
|
1344
|
-
else if (next === "error" && document.status !== "error") {
|
|
1345
|
-
this.inFlight.delete(key);
|
|
1346
|
-
this.markError(document);
|
|
1347
|
-
changed = true;
|
|
1348
|
-
}
|
|
1349
|
-
else if (next === "uploading" && typeof wire.percent === "number" && document.percent !== wire.percent) {
|
|
1350
|
-
// Not ours to transfer (another user's upload, or our own seen again after a
|
|
1351
|
-
// reload): the server's percentage is all there is.
|
|
1352
|
-
this.workspaces.patchDocument(workspaceId, wire.id, { percent: wire.percent });
|
|
1353
|
-
this.tracker.setProgress(key, wire.percent);
|
|
1354
|
-
changed = true;
|
|
1355
|
-
}
|
|
1377
|
+
/** Re-reads the workspace's listing. @returns Whether it differs from what was shown. */
|
|
1378
|
+
async refreshListing(workspaceId, signal) {
|
|
1379
|
+
const before = JSON.stringify(this.workspaces.documentsOf(workspaceId));
|
|
1380
|
+
await this.workspaces.loadDocuments(workspaceId, signal);
|
|
1381
|
+
return JSON.stringify(this.workspaces.documentsOf(workspaceId)) !== before;
|
|
1382
|
+
}
|
|
1383
|
+
/** The job-level status can finish a file this client was still holding the poller for: release it. */
|
|
1384
|
+
settleFinishedUploads() {
|
|
1385
|
+
for (const upload of this.tracker.uploads()) {
|
|
1386
|
+
if (upload.status === "done")
|
|
1387
|
+
this.inFlight.delete(upload.id);
|
|
1356
1388
|
}
|
|
1357
|
-
return { pending, changed };
|
|
1358
1389
|
}
|
|
1359
1390
|
// ── connection loss & session recovery ──
|
|
1360
1391
|
//
|
|
@@ -2314,6 +2345,62 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
2314
2345
|
args: [{ name: "highlightMatch" }]
|
|
2315
2346
|
}] });
|
|
2316
2347
|
|
|
2348
|
+
/**
|
|
2349
|
+
* Keeps a view fresh while it stays open, without the user ever asking: `tick`
|
|
2350
|
+
* re-fetches whatever the view shows and reports whether the result differs
|
|
2351
|
+
* from before — the loop speeds back up on a change and backs off while idle,
|
|
2352
|
+
* exactly like {@link StatusPoller} already does for upload status.
|
|
2353
|
+
*
|
|
2354
|
+
* Paused while the browser reports no network (resumed the moment it is back),
|
|
2355
|
+
* and fully stopped once the injecting component is destroyed. The very first
|
|
2356
|
+
* scheduled tick is a no-op: whatever calls `start()` has normally just
|
|
2357
|
+
* fetched on its own, so re-fetching the same thing instantly would be
|
|
2358
|
+
* redundant — the first real poll happens one `intervalMs` later.
|
|
2359
|
+
*
|
|
2360
|
+
* Must be called from an injection context.
|
|
2361
|
+
*/
|
|
2362
|
+
function injectLiveRefresh(tick, options = {}) {
|
|
2363
|
+
assertInInjectionContext(injectLiveRefresh);
|
|
2364
|
+
let shouldRun = false;
|
|
2365
|
+
let first = true;
|
|
2366
|
+
const poller = new StatusPoller({
|
|
2367
|
+
intervalMs: options.intervalMs,
|
|
2368
|
+
maxIntervalMs: options.maxIntervalMs,
|
|
2369
|
+
tick: async (signal) => {
|
|
2370
|
+
if (first) {
|
|
2371
|
+
first = false;
|
|
2372
|
+
return { pending: true, changed: true };
|
|
2373
|
+
}
|
|
2374
|
+
return { pending: true, changed: await tick(signal) };
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
if (typeof window !== "undefined") {
|
|
2378
|
+
const resume = () => {
|
|
2379
|
+
if (shouldRun)
|
|
2380
|
+
poller.start();
|
|
2381
|
+
};
|
|
2382
|
+
const pause = () => poller.stop();
|
|
2383
|
+
window.addEventListener("online", resume);
|
|
2384
|
+
window.addEventListener("offline", pause);
|
|
2385
|
+
inject(DestroyRef).onDestroy(() => {
|
|
2386
|
+
window.removeEventListener("online", resume);
|
|
2387
|
+
window.removeEventListener("offline", pause);
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
inject(DestroyRef).onDestroy(() => poller.stop());
|
|
2391
|
+
return {
|
|
2392
|
+
start() {
|
|
2393
|
+
shouldRun = true;
|
|
2394
|
+
if (typeof navigator === "undefined" || navigator.onLine)
|
|
2395
|
+
poller.start();
|
|
2396
|
+
},
|
|
2397
|
+
stop() {
|
|
2398
|
+
shouldRun = false;
|
|
2399
|
+
poller.stop();
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2317
2404
|
/**
|
|
2318
2405
|
* Maps a caught error to the message shown in an inline error banner.
|
|
2319
2406
|
*
|
|
@@ -3063,7 +3150,9 @@ const TABS = [
|
|
|
3063
3150
|
* Each time it is shown it refreshes: workspaces created by others since the last
|
|
3064
3151
|
* time appear, and the document counts match the server. The list already on
|
|
3065
3152
|
* screen stays until the answer lands, and a failure leaves it untouched behind
|
|
3066
|
-
* an inline error.
|
|
3153
|
+
* an inline error. While it stays shown, it keeps polling in the background
|
|
3154
|
+
* (`injectLiveRefresh`) so a workspace created by someone else — one the current
|
|
3155
|
+
* user only reads, say — appears on its own, with no action needed.
|
|
3067
3156
|
*/
|
|
3068
3157
|
class WorkspaceList {
|
|
3069
3158
|
/** A workspace was chosen for opening. */
|
|
@@ -3072,6 +3161,7 @@ class WorkspaceList {
|
|
|
3072
3161
|
createRequested = output();
|
|
3073
3162
|
store = inject(WorkspacesStore);
|
|
3074
3163
|
dialogs = inject(DialogService);
|
|
3164
|
+
uploads = inject(UploadService);
|
|
3075
3165
|
transloco = inject(TranslocoService);
|
|
3076
3166
|
tabs = TABS;
|
|
3077
3167
|
viewMode = signal("grid", ...(ngDevMode ? [{ debugName: "viewMode" }] : []));
|
|
@@ -3088,7 +3178,24 @@ class WorkspaceList {
|
|
|
3088
3178
|
key: (workspace) => workspace.id,
|
|
3089
3179
|
selectable: workspace => canWrite(workspace.currentUserRole)
|
|
3090
3180
|
});
|
|
3181
|
+
// Background polling, started once the list has something to compare against:
|
|
3182
|
+
// each tick re-reloads and compares the full result to what was cached before
|
|
3183
|
+
// it, not just the set of ids — a workspace renamed, re-tagged or whose access
|
|
3184
|
+
// changed keeps the same id, and would otherwise look unchanged forever, which
|
|
3185
|
+
// would both hide the edit and widen the backoff on ticks that are not actually idle.
|
|
3186
|
+
liveRefresh = injectLiveRefresh(async () => {
|
|
3187
|
+
const before = JSON.stringify(this.store.workspaces());
|
|
3188
|
+
await this.store.reload();
|
|
3189
|
+
const changed = JSON.stringify(this.store.workspaces()) !== before;
|
|
3190
|
+
if (changed)
|
|
3191
|
+
this.selection.exit(); // the ticks belonged to the list that was there before
|
|
3192
|
+
// An upload someone else is running on a workspace listed here: only the status API tells.
|
|
3193
|
+
// It stops by itself after a tick when nothing is moving.
|
|
3194
|
+
this.uploads.startPolling();
|
|
3195
|
+
return changed;
|
|
3196
|
+
}, { intervalMs: 20_000, maxIntervalMs: 120_000 });
|
|
3091
3197
|
constructor() {
|
|
3198
|
+
this.uploads.startPolling();
|
|
3092
3199
|
void this.load();
|
|
3093
3200
|
}
|
|
3094
3201
|
/** Shows what is cached at once and refreshes behind it, or loads for the first time. */
|
|
@@ -3097,12 +3204,14 @@ class WorkspaceList {
|
|
|
3097
3204
|
if (this.store.loaded()) {
|
|
3098
3205
|
this.status.set("ready");
|
|
3099
3206
|
await this.refresh();
|
|
3207
|
+
this.liveRefresh.start();
|
|
3100
3208
|
return;
|
|
3101
3209
|
}
|
|
3102
3210
|
this.status.set("loading");
|
|
3103
3211
|
try {
|
|
3104
3212
|
await this.store.ensureLoaded();
|
|
3105
3213
|
this.status.set("ready");
|
|
3214
|
+
this.liveRefresh.start();
|
|
3106
3215
|
}
|
|
3107
3216
|
catch (error) {
|
|
3108
3217
|
this.loadError.set(workspaceErrorMessage(error));
|
|
@@ -3421,6 +3530,15 @@ function truncateName(name, max) {
|
|
|
3421
3530
|
}
|
|
3422
3531
|
return `${name.slice(0, max - 1)}…`;
|
|
3423
3532
|
}
|
|
3533
|
+
/**
|
|
3534
|
+
* What to call a tracked row: the file's name — or, for a job started elsewhere whose files
|
|
3535
|
+
* the status API does not list, whose job it is.
|
|
3536
|
+
*/
|
|
3537
|
+
function uploadLabel(upload, max) {
|
|
3538
|
+
if (upload.name)
|
|
3539
|
+
return { text: truncateName(upload.name, max) };
|
|
3540
|
+
return trackerMessage(upload.remote?.mine ? "ownUpload" : "remoteUpload");
|
|
3541
|
+
}
|
|
3424
3542
|
/** `m:ss` for a duration in ms, clamped at 0 so a late tick cannot go negative. */
|
|
3425
3543
|
function formatClock(ms) {
|
|
3426
3544
|
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
@@ -3889,6 +4007,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
3889
4007
|
}]
|
|
3890
4008
|
}], propDecorators: { document: [{ type: i0.Input, args: [{ isSignal: true, alias: "document", required: true }] }], workspaceRole: [{ type: i0.Input, args: [{ isSignal: true, alias: "workspaceRole", required: true }] }], query: [{ type: i0.Input, args: [{ isSignal: true, alias: "query", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], selecting: [{ type: i0.Input, args: [{ isSignal: true, alias: "selecting", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], opened: [{ type: i0.Output, args: ["opened"] }], deleteRequested: [{ type: i0.Output, args: ["deleteRequested"] }], selectedChange: [{ type: i0.Output, args: ["selectedChange"] }] } });
|
|
3891
4009
|
|
|
4010
|
+
/**
|
|
4011
|
+
* The file-search glyph on a teal tile — the same tile `EmptyFolderIcon` uses
|
|
4012
|
+
* for an empty workspace or an empty list, for a workspace or document the
|
|
4013
|
+
* server no longer has.
|
|
4014
|
+
*/
|
|
4015
|
+
class NotFoundIcon {
|
|
4016
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NotFoundIcon, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
4017
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.29", type: NotFoundIcon, isStandalone: true, selector: "not-found-icon", host: { attributes: { "data-testid": "not-found-icon" }, classAttribute: "mb-1 inline-flex size-14 shrink-0 items-center justify-center rounded-(--radius-xl) bg-(--teal-100,#E0F4F1) text-(--teal-400,#4EBFB0)" }, ngImport: i0, template: `<file-search-icon class="size-6" />`, isInline: true, dependencies: [{ kind: "component", type: FileSearchIcon, selector: "file-search-icon, FileSearchIcon, filesearchicon", inputs: ["class"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
4018
|
+
}
|
|
4019
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NotFoundIcon, decorators: [{
|
|
4020
|
+
type: Component,
|
|
4021
|
+
args: [{
|
|
4022
|
+
selector: "not-found-icon",
|
|
4023
|
+
imports: [FileSearchIcon],
|
|
4024
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
4025
|
+
host: {
|
|
4026
|
+
class: "mb-1 inline-flex size-14 shrink-0 items-center justify-center rounded-(--radius-xl) bg-(--teal-100,#E0F4F1) text-(--teal-400,#4EBFB0)",
|
|
4027
|
+
"data-testid": "not-found-icon"
|
|
4028
|
+
},
|
|
4029
|
+
template: `<file-search-icon class="size-6" />`
|
|
4030
|
+
}]
|
|
4031
|
+
}] });
|
|
4032
|
+
|
|
3892
4033
|
/** The files staged for upload, before anything is sent. */
|
|
3893
4034
|
class UploadFileList {
|
|
3894
4035
|
files = input.required(...(ngDevMode ? [{ debugName: "files" }] : []));
|
|
@@ -5643,6 +5784,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
5643
5784
|
* shown: it says what the user asked for (`back`, `deleted`, `askFollowUp`) and whoever
|
|
5644
5785
|
* hosts it decides. The document open in the preview is the `previewedDocumentId` model.
|
|
5645
5786
|
*
|
|
5787
|
+
* While a workspace stays open, it keeps polling its documents in the background
|
|
5788
|
+
* (`injectLiveRefresh`), so a document another user adds to it shows up on its own.
|
|
5789
|
+
*
|
|
5646
5790
|
* Only someone who can write sees the edit, share, delete, upload and select controls.
|
|
5647
5791
|
*/
|
|
5648
5792
|
class WorkspaceDetail {
|
|
@@ -5673,11 +5817,13 @@ class WorkspaceDetail {
|
|
|
5673
5817
|
refreshing = signal(false, ...(ngDevMode ? [{ debugName: "refreshing" }] : []));
|
|
5674
5818
|
loadingMore = signal(false, ...(ngDevMode ? [{ debugName: "loadingMore" }] : []));
|
|
5675
5819
|
loadMoreError = signal(null, ...(ngDevMode ? [{ debugName: "loadMoreError" }] : []));
|
|
5820
|
+
/** The background refresh found the workspace gone from the server (deleted elsewhere). */
|
|
5821
|
+
deletedElsewhere = signal(false, ...(ngDevMode ? [{ debugName: "deletedElsewhere" }] : []));
|
|
5676
5822
|
workspace = computed(() => this.store.workspace(this.workspaceId()), ...(ngDevMode ? [{ debugName: "workspace" }] : []));
|
|
5677
|
-
//
|
|
5678
|
-
// belong to the upload tracker, not to the workspace. A document with no
|
|
5679
|
-
// all is one the server does not track — it is simply there, so it stays listed.
|
|
5680
|
-
documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => d.status
|
|
5823
|
+
// The list is the index: only a document the listing returns as indexed is shown. Transfers,
|
|
5824
|
+
// indexing and failures belong to the upload tracker, not to the workspace. A document with no
|
|
5825
|
+
// status at all is one the server does not track — it is simply there, so it stays listed.
|
|
5826
|
+
documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => !d.status), ...(ngDevMode ? [{ debugName: "documents" }] : []));
|
|
5681
5827
|
previewed = computed(() => this.documents().find(d => d.id === this.previewedDocumentId()) ?? null, ...(ngDevMode ? [{ debugName: "previewed" }] : []));
|
|
5682
5828
|
/** The width the user dragged the preview to, if they did. */
|
|
5683
5829
|
userPreviewWidth = signal(null, ...(ngDevMode ? [{ debugName: "userPreviewWidth" }] : []));
|
|
@@ -5698,6 +5844,43 @@ class WorkspaceDetail {
|
|
|
5698
5844
|
// Selecting only serves deleting, which a reader cannot do.
|
|
5699
5845
|
selectable: () => this.writable()
|
|
5700
5846
|
});
|
|
5847
|
+
// Background polling, re-scoped to whatever `workspaceId` is current on each
|
|
5848
|
+
// tick (no restart needed when the user switches workspaces): refreshes both
|
|
5849
|
+
// the workspace's own metadata (title, description, tags, owners/readers,
|
|
5850
|
+
// the current user's role) and its documents, so an edit or an access change
|
|
5851
|
+
// made elsewhere shows up here too, not just additions to the document list.
|
|
5852
|
+
// Comparing full snapshots (not just ids) catches an in-place edit that an
|
|
5853
|
+
// id-only diff would miss — a renamed workspace, a demoted role — and keeps
|
|
5854
|
+
// the backoff from widening on a tick that only looks unchanged by id.
|
|
5855
|
+
liveRefresh = injectLiveRefresh(async (signal) => {
|
|
5856
|
+
const id = this.workspaceId();
|
|
5857
|
+
const beforeWorkspace = JSON.stringify(this.store.workspace(id));
|
|
5858
|
+
const beforeDocuments = JSON.stringify(this.store.documentsOf(id));
|
|
5859
|
+
try {
|
|
5860
|
+
await Promise.all([this.store.fetch(id), this.store.loadDocuments(id, signal)]);
|
|
5861
|
+
}
|
|
5862
|
+
catch (error) {
|
|
5863
|
+
// Someone else deleted it: flag it and stop polling — there is nothing
|
|
5864
|
+
// left here to discover. The workspace stays in the shared cache (so its
|
|
5865
|
+
// name/description keep showing instead of the view going blank); `leave()`
|
|
5866
|
+
// drops it once the user actually goes back to the list.
|
|
5867
|
+
if (error instanceof ApiError && error.status === 404 && this.workspaceId() === id) {
|
|
5868
|
+
this.deletedElsewhere.set(true);
|
|
5869
|
+
this.selection.exit();
|
|
5870
|
+
this.liveRefresh.stop();
|
|
5871
|
+
return true;
|
|
5872
|
+
}
|
|
5873
|
+
throw error;
|
|
5874
|
+
}
|
|
5875
|
+
if (this.workspaceId() !== id)
|
|
5876
|
+
return false; // the user moved to another workspace meanwhile
|
|
5877
|
+
const documentsChanged = JSON.stringify(this.store.documentsOf(id)) !== beforeDocuments;
|
|
5878
|
+
const workspaceChanged = JSON.stringify(this.store.workspace(id)) !== beforeWorkspace;
|
|
5879
|
+
// Every tick, changed or not: an upload someone else just started shows no document yet, and the
|
|
5880
|
+
// tracker only learns of it from the status API. It stops by itself after a tick when nothing is moving.
|
|
5881
|
+
this.uploads.watch(id);
|
|
5882
|
+
return documentsChanged || workspaceChanged;
|
|
5883
|
+
}, { intervalMs: 15_000, maxIntervalMs: 90_000 });
|
|
5701
5884
|
constructor() {
|
|
5702
5885
|
effect(() => {
|
|
5703
5886
|
this.workspaceId();
|
|
@@ -5723,8 +5906,19 @@ class WorkspaceDetail {
|
|
|
5723
5906
|
this.query.set("");
|
|
5724
5907
|
this.refreshError.set(null);
|
|
5725
5908
|
this.loadMoreError.set(null);
|
|
5909
|
+
this.deletedElsewhere.set(false);
|
|
5726
5910
|
await this.load();
|
|
5727
5911
|
}
|
|
5912
|
+
/**
|
|
5913
|
+
* Leaving from here: once the background refresh found the workspace gone,
|
|
5914
|
+
* the cache still holds it (so its name kept showing) — drop it now, so a
|
|
5915
|
+
* stale card does not show it back on the list.
|
|
5916
|
+
*/
|
|
5917
|
+
leave() {
|
|
5918
|
+
if (this.deletedElsewhere())
|
|
5919
|
+
this.store.forget(this.workspaceId());
|
|
5920
|
+
this.back.emit();
|
|
5921
|
+
}
|
|
5728
5922
|
/** Reads the workspace (when it is not cached) and its documents, for the workspace currently shown. */
|
|
5729
5923
|
async load() {
|
|
5730
5924
|
const id = this.workspaceId();
|
|
@@ -5747,6 +5941,7 @@ class WorkspaceDetail {
|
|
|
5747
5941
|
return;
|
|
5748
5942
|
this.status.set("ready");
|
|
5749
5943
|
this.uploads.watch(id);
|
|
5944
|
+
this.liveRefresh.start();
|
|
5750
5945
|
}
|
|
5751
5946
|
/** Reads the documents again without leaving the view; a failure leaves the ones shown as they are. */
|
|
5752
5947
|
async refresh() {
|
|
@@ -5846,7 +6041,7 @@ class WorkspaceDetail {
|
|
|
5846
6041
|
return "key" in message ? this.transloco.translate(message.key, message.params) : message.text;
|
|
5847
6042
|
}
|
|
5848
6043
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceDetail, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
5849
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceDetail, isStandalone: true, selector: "workspace-detail", inputs: { workspaceId: { classPropertyName: "workspaceId", publicName: "workspaceId", isSignal: true, isRequired: true, transformFunction: null }, uploadEndpoint: { classPropertyName: "uploadEndpoint", publicName: "uploadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, previewedDocumentId: { classPropertyName: "previewedDocumentId", publicName: "previewedDocumentId", isSignal: true, isRequired: false, transformFunction: null }, queryName: { classPropertyName: "queryName", publicName: "queryName", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { previewedDocumentId: "previewedDocumentIdChange", back: "back", deleted: "deleted", workspaceUpdated: "workspaceUpdated", askFollowUp: "askFollowUp" }, host: { classAttribute: "flex h-full min-h-0" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"@container flex min-h-0 min-w-0 flex-1 flex-col p-3 pb-2 md:p-8 md:pb-4\">\n <div class=\"mb-3 flex flex-wrap items-center gap-x-3 gap-y-2\">\n <button class=\"w-6\" size=\"sm\"\n variant=\"ghost\"\n [iconOnly]=\"true\"\n [attr.aria-label]=\"'documentWorkspaces.detail.back' | transloco\"\n [tooltip]=\"'documentWorkspaces.detail.back' | transloco\"\n (click)=\"back.emit()\">\n <chevron-down-icon class=\"rotate-90\" />\n </button>\n\n @if (workspace(); as workspace) {\n <h1 class=\"min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)\">{{ workspace.name }}</h1>\n\n <!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->\n <div class=\"ml-auto flex shrink-0 items-center gap-1\">\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\"\n [writerOnly]=\"workspace.currentUserRole\"\n (click)=\"confirmDeleteWorkspace(workspace)\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"share(workspace)\">\n <user-round-icon />\n {{ \"documentWorkspaces.share.title\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"edit(workspace)\">\n <pen-to-square-icon />\n {{ \"documentWorkspaces.common.edit\" | transloco }}\n </button>\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"w-6\"\n [iconOnly]=\"true\"\n [disabled]=\"refreshing()\"\n [attr.aria-label]=\"'documentWorkspaces.common.refresh' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.refresh' | transloco\"\n (click)=\"refresh()\">\n <refresh-icon />\n </button>\n </div>\n }\n </div>\n\n @if (workspace(); as workspace) {\n @if (workspace.description) {\n <p class=\"mb-3 text-base text-(--font-primary-base)\">{{ workspace.description }}</p>\n }\n @if (workspace.tags.length) {\n <p class=\"mb-6 flex items-center gap-2 text-sm text-(--font-primary-base)\" data-testid=\"tags\">\n <tag-icon class=\"size-4\" />\n {{ workspace.tags.join(\", \") }}\n </p>\n }\n\n <div class=\"mb-3 flex flex-wrap items-center gap-2\">\n <div role=\"group\" class=\"inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5\">\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'grid'\"\n [attr.aria-label]=\"'documentWorkspaces.common.gridView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.gridView' | transloco\"\n (click)=\"viewMode.set('grid')\">\n <grid-icon class=\"size-3.5\" />\n </button>\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'list'\"\n [attr.aria-label]=\"'documentWorkspaces.common.listView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.listView' | transloco\"\n (click)=\"viewMode.set('list')\">\n <list-icon class=\"size-3.5\" />\n </button>\n </div>\n\n <div\n role=\"search\"\n class=\"flex h-9 min-w-32 flex-[0_1_15rem] items-center gap-2 rounded-full border border-(--stroke-primary-light) bg-(--bg-neutral-white) px-3 focus-within:border-(--stroke-primary-base)\">\n <magnifying-glass-icon class=\"size-4 shrink-0 text-(--font-primary-muted)\" />\n <input\n type=\"text\"\n class=\"min-w-0 flex-1 bg-transparent text-xs text-(--font-neutral-black) outline-none placeholder:text-(--font-primary-muted)\"\n [value]=\"query()\"\n [attr.placeholder]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"query.set('')\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\n\n <button class=\"gap-1.5\" size=\"sm\" variant=\"primary\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"upload(workspace)\">\n <upload-icon />\n {{ \"documentWorkspaces.detail.upload\" | transloco }}\n </button>\n\n @if (selection.selectableItems().length) {\n @if (selection.active()) {\n <div class=\"ml-auto flex items-center gap-2 @max-xl:order-last @max-xl:ml-0 @max-xl:basis-full\" data-testid=\"selection-bar\">\n <button variant=\"ghost\" size=\"xs\" (click)=\"selection.toggleAll()\">\n {{ (selection.allSelected() ? \"documentWorkspaces.common.unselectAll\" : \"documentWorkspaces.common.selectAll\") | transloco }}\n </button>\n <button class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\" variant=\"ghost\" size=\"xs\" [disabled]=\"!selection.count()\" (click)=\"deleteSelected()\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n </div>\n }\n <button variant=\"ghost\" size=\"xs\" class=\"@max-xl:ml-auto\" [class.ml-auto]=\"!selection.active()\" (click)=\"selection.toggleMode()\">\n {{ (selection.active() ? \"documentWorkspaces.common.cancel\" : \"documentWorkspaces.common.select\") | transloco }}\n </button>\n }\n </div>\n\n <p class=\"mb-6 text-sm text-(--font-primary-base)\" aria-live=\"polite\" data-testid=\"count\">\n @if (selection.active()) {\n {{ \"documentWorkspaces.detail.selectedDocumentCount\" | transloco: { count: selection.count() } }}\n } @else {\n {{ \"documentWorkspaces.detail.fileCount\" | transloco: { count: workspace.documentCount } }}\n }\n </p>\n }\n\n @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the documents scroll: the title, the toolbar and the count stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-(--font-primary-muted) text-sm\">{{ \"documentWorkspaces.detail.docsLoading\" | transloco }}</p>\n }\n @case (\"error\") {\n <div class=\"flex items-center gap-3 text-sm\" data-testid=\"load-error\">\n <span class=\"text-(--font-error-base)\">\n @if (loadError(); as message) {\n <workspace-message [message]=\"message\" />\n } @else {\n {{ \"documentWorkspaces.detail.docsLoadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (workspace(); as workspace) {\n @if (!documents().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <empty-folder-icon />\n <h5 class=\"text-sm font-semibold text-(--font-primary-dark)\">{{ \"documentWorkspaces.detail.emptyTitle\" | transloco }}</h5>\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.detail.emptySubtitle\" | transloco }}</p>\n </div>\n } @else if (!filtered().length) {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"no-match\">{{ \"documentWorkspaces.detail.noMatch\" | transloco }}</p>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] content-start gap-4 pb-8\">\n @for (document of filtered(); track document.id) {\n <document-card\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n } @else {\n <div role=\"table\" class=\"flex flex-col gap-3 pb-8\">\n <div role=\"row\" [attr.data-selecting]=\"selection.active() ? '' : null\" class=\"grid grid-cols-[minmax(0,1fr)_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] py-1 text-xs font-semibold text-(--font-primary-muted)\">\n @if (selection.active()) {\n <div role=\"columnheader\">\n <input\n type=\"checkbox\"\n class=\"accent-(--bg-primary-base) size-4\"\n [checked]=\"selection.allSelected()\"\n [indeterminate]=\"selection.partiallySelected()\"\n [attr.aria-label]=\"'documentWorkspaces.common.selectAll' | transloco\"\n (change)=\"selection.toggleAll()\" />\n\n </div>\n }\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnName\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSource\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSize\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnDate\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (document of filtered(); track document.id) {\n <document-row\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n }\n\n @if (hasMore()) {\n <div class=\"flex flex-col items-center gap-2\">\n @if (loadMoreError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.loadMoreError\" | transloco: { error: message } }}</p>\n }\n <button variant=\"secondary\" size=\"xs\" [disabled]=\"loadingMore()\" (click)=\"loadMore()\">\n {{ \"documentWorkspaces.detail.loadMore\" | transloco }}\n </button>\n </div>\n }\n } @else {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"not-found\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n }\n }\n }\n </div>\n </div>\n\n <!-- The list gives up part of its width to the preview, and takes it back when the preview closes. -->\n <div\n #handle=\"resizeHandle\"\n class=\"group/handle flex w-3 shrink-0 cursor-col-resize items-center justify-center max-md:hidden\"\n [class.hidden]=\"!previewed()\"\n [resizeHandle]=\"previewWidth()\"\n (resizeHandleChange)=\"userPreviewWidth.set($event)\"\n [resizeMin]=\"280\"\n [resizeMax]=\"maxPreviewWidth()\"\n [attr.aria-label]=\"'documentWorkspaces.preview.resize' | transloco\">\n <span class=\"h-6 w-0.5 rounded-full bg-(--stroke-primary-light) opacity-0 group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-data-resizing/handle:opacity-100\"></span>\n </div>\n <aside\n class=\"sticky top-3 my-3 mr-3 flex h-[calc(100dvh-1.5rem)] max-h-[calc(100%-1.5rem)] shrink-0 self-start overflow-hidden rounded-(--radius-xl) bg-(--bg-neutral-white) transition-[width] duration-[350ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none data-open:border data-open:border-(--stroke-primary-lighter) data-open:shadow-[0_4px_6px_-2px_rgba(45,57,58,0.05),0_10px_15px_-3px_rgba(45,57,58,0.1)] max-md:data-open:fixed max-md:data-open:inset-0 max-md:data-open:z-50 max-md:data-open:m-0 max-md:data-open:w-full! max-md:data-open:max-h-none max-md:data-open:rounded-none\"\n [class.transition-none]=\"handle.resizing()\"\n [style.width.px]=\"previewed() ? previewWidth() : 0\"\n [attr.data-open]=\"previewed() ? '' : null\"\n [attr.aria-hidden]=\"previewed() ? null : 'true'\"\n data-testid=\"preview\">\n @if (previewed(); as previewed) {\n <!-- Its own width, so the content does not reflow while the card opens. -->\n <document-preview-panel\n class=\"shrink-0 max-md:w-full!\"\n [style.width.px]=\"previewWidth()\"\n [document]=\"previewed\"\n [queryName]=\"queryName()\"\n (closed)=\"previewedDocumentId.set(null)\"\n (askFollowUp)=\"askFollowUp.emit($event)\" />\n }\n </aside>\n ", isInline: true, dependencies: [{ kind: "component", type: ChevronDownIcon, selector: "chevron-down, ChevronDown, chevrondown, chevron-down-icon, ChevronDownIcon, chevrondownicon", inputs: ["class"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: DocumentCard, selector: "document-card", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: DocumentPreviewPanel, selector: "document-preview-panel", inputs: ["document", "queryName"], outputs: ["closed", "askFollowUp"] }, { kind: "component", type: DocumentRow, selector: "document-row", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: EmptyFolderIcon, selector: "empty-folder-icon" }, { kind: "component", type: GridIcon, selector: "grid-icon", inputs: ["class"] }, { kind: "component", type: ListIcon, selector: "list-icon, ListIcon", inputs: ["class"] }, { kind: "component", type: MagnifyingGlassIcon, selector: "magnifying-glass-icon, MagnifyingGlassIcon", inputs: ["class"] }, { kind: "component", type: PenToSquareIcon, selector: "pen-to-square-icon, PenToSquareIcon", inputs: ["class"] }, { kind: "component", type: RefreshIcon, selector: "refresh-icon, RefreshIcon, refreshicon", inputs: ["class"] }, { kind: "directive", type: ResizeHandle, selector: "[resizeHandle]", inputs: ["resizeHandle", "resizeMin", "resizeMax", "resizeStep", "resizeEdge"], outputs: ["resizeHandleChange"], exportAs: ["resizeHandle"] }, { kind: "component", type: TagIcon, selector: "tag-icon, TagIcon", inputs: ["class"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip]", inputs: ["tooltip", "strategy", "tooltip-position", "tooltip-delay", "tooltip-duration", "tooltip-offset"] }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: UploadIcon, selector: "upload-icon, UploadIcon, uploadicon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "directive", type: WriterOnly, selector: "[writerOnly]", inputs: ["writerOnly"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
6044
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceDetail, isStandalone: true, selector: "workspace-detail", inputs: { workspaceId: { classPropertyName: "workspaceId", publicName: "workspaceId", isSignal: true, isRequired: true, transformFunction: null }, uploadEndpoint: { classPropertyName: "uploadEndpoint", publicName: "uploadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, previewedDocumentId: { classPropertyName: "previewedDocumentId", publicName: "previewedDocumentId", isSignal: true, isRequired: false, transformFunction: null }, queryName: { classPropertyName: "queryName", publicName: "queryName", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { previewedDocumentId: "previewedDocumentIdChange", back: "back", deleted: "deleted", workspaceUpdated: "workspaceUpdated", askFollowUp: "askFollowUp" }, host: { classAttribute: "flex h-full min-h-0" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"@container flex min-h-0 min-w-0 flex-1 flex-col p-3 pb-2 md:p-8 md:pb-4\">\n <div class=\"mb-3 flex flex-wrap items-center gap-x-3 gap-y-2\">\n <button class=\"w-6\" size=\"sm\"\n variant=\"ghost\"\n [iconOnly]=\"true\"\n [attr.aria-label]=\"'documentWorkspaces.detail.back' | transloco\"\n [tooltip]=\"'documentWorkspaces.detail.back' | transloco\"\n (click)=\"leave()\">\n <chevron-down-icon class=\"rotate-90\" />\n </button>\n\n @if (workspace(); as workspace) {\n <h1 class=\"min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)\">{{ workspace.name }}</h1>\n\n <!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->\n @if (!deletedElsewhere()) {\n <div class=\"ml-auto flex shrink-0 items-center gap-1\">\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\"\n [writerOnly]=\"workspace.currentUserRole\"\n (click)=\"confirmDeleteWorkspace(workspace)\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"share(workspace)\">\n <user-round-icon />\n {{ \"documentWorkspaces.share.title\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"edit(workspace)\">\n <pen-to-square-icon />\n {{ \"documentWorkspaces.common.edit\" | transloco }}\n </button>\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"w-6\"\n [iconOnly]=\"true\"\n [disabled]=\"refreshing()\"\n [attr.aria-label]=\"'documentWorkspaces.common.refresh' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.refresh' | transloco\"\n (click)=\"refresh()\">\n <refresh-icon />\n </button>\n </div>\n }\n }\n </div>\n\n @if (workspace(); as workspace) {\n @if (workspace.description) {\n <p class=\"mb-3 text-base text-(--font-primary-base)\">{{ workspace.description }}</p>\n }\n @if (workspace.tags.length) {\n <p class=\"mb-6 flex items-center gap-2 text-sm text-(--font-primary-base)\" data-testid=\"tags\">\n <tag-icon class=\"size-4\" />\n {{ workspace.tags.join(\", \") }}\n </p>\n }\n\n @if (!deletedElsewhere()) {\n <div class=\"mb-3 flex flex-wrap items-center gap-2\">\n <div role=\"group\" class=\"inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5\">\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'grid'\"\n [attr.aria-label]=\"'documentWorkspaces.common.gridView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.gridView' | transloco\"\n (click)=\"viewMode.set('grid')\">\n <grid-icon class=\"size-3.5\" />\n </button>\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'list'\"\n [attr.aria-label]=\"'documentWorkspaces.common.listView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.listView' | transloco\"\n (click)=\"viewMode.set('list')\">\n <list-icon class=\"size-3.5\" />\n </button>\n </div>\n\n <div\n role=\"search\"\n class=\"flex h-9 min-w-32 flex-[0_1_15rem] items-center gap-2 rounded-full border border-(--stroke-primary-light) bg-(--bg-neutral-white) px-3 focus-within:border-(--stroke-primary-base)\">\n <magnifying-glass-icon class=\"size-4 shrink-0 text-(--font-primary-muted)\" />\n <input\n type=\"text\"\n class=\"min-w-0 flex-1 bg-transparent text-xs text-(--font-neutral-black) outline-none placeholder:text-(--font-primary-muted)\"\n [value]=\"query()\"\n [attr.placeholder]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"query.set('')\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\n\n <button class=\"gap-1.5\" size=\"sm\" variant=\"primary\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"upload(workspace)\">\n <upload-icon />\n {{ \"documentWorkspaces.detail.upload\" | transloco }}\n </button>\n\n @if (selection.selectableItems().length) {\n @if (selection.active()) {\n <div class=\"ml-auto flex items-center gap-2 @max-xl:order-last @max-xl:ml-0 @max-xl:basis-full\" data-testid=\"selection-bar\">\n <button variant=\"ghost\" size=\"xs\" (click)=\"selection.toggleAll()\">\n {{ (selection.allSelected() ? \"documentWorkspaces.common.unselectAll\" : \"documentWorkspaces.common.selectAll\") | transloco }}\n </button>\n <button class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\" variant=\"ghost\" size=\"xs\" [disabled]=\"!selection.count()\" (click)=\"deleteSelected()\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n </div>\n }\n <button variant=\"ghost\" size=\"xs\" class=\"@max-xl:ml-auto\" [class.ml-auto]=\"!selection.active()\" (click)=\"selection.toggleMode()\">\n {{ (selection.active() ? \"documentWorkspaces.common.cancel\" : \"documentWorkspaces.common.select\") | transloco }}\n </button>\n }\n </div>\n\n <p class=\"mb-6 text-sm text-(--font-primary-base)\" aria-live=\"polite\" data-testid=\"count\">\n @if (selection.active()) {\n {{ \"documentWorkspaces.detail.selectedDocumentCount\" | transloco: { count: selection.count() } }}\n } @else {\n {{ \"documentWorkspaces.detail.fileCount\" | transloco: { count: workspace.documentCount } }}\n }\n </p>\n }\n }\n\n @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the documents scroll: the title, the toolbar and the count stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-(--font-primary-muted) text-sm\">{{ \"documentWorkspaces.detail.docsLoading\" | transloco }}</p>\n }\n @case (\"error\") {\n <div class=\"flex items-center gap-3 text-sm\" data-testid=\"load-error\">\n <span class=\"text-(--font-error-base)\">\n @if (loadError(); as message) {\n <workspace-message [message]=\"message\" />\n } @else {\n {{ \"documentWorkspaces.detail.docsLoadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (deletedElsewhere()) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"not-found\">\n <not-found-icon />\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n <button variant=\"secondary\" size=\"sm\" class=\"mt-2\" (click)=\"leave()\">{{ \"documentWorkspaces.detail.back\" | transloco }}</button>\n </div>\n } @else if (workspace(); as workspace) {\n @if (!documents().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <empty-folder-icon />\n <h5 class=\"text-sm font-semibold text-(--font-primary-dark)\">{{ \"documentWorkspaces.detail.emptyTitle\" | transloco }}</h5>\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.detail.emptySubtitle\" | transloco }}</p>\n </div>\n } @else if (!filtered().length) {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"no-match\">{{ \"documentWorkspaces.detail.noMatch\" | transloco }}</p>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] content-start gap-4 pb-8\">\n @for (document of filtered(); track document.id) {\n <document-card\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n } @else {\n <div role=\"table\" class=\"flex flex-col gap-3 pb-8\">\n <div role=\"row\" [attr.data-selecting]=\"selection.active() ? '' : null\" class=\"grid grid-cols-[minmax(0,1fr)_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] py-1 text-xs font-semibold text-(--font-primary-muted)\">\n @if (selection.active()) {\n <div role=\"columnheader\">\n <input\n type=\"checkbox\"\n class=\"accent-(--bg-primary-base) size-4\"\n [checked]=\"selection.allSelected()\"\n [indeterminate]=\"selection.partiallySelected()\"\n [attr.aria-label]=\"'documentWorkspaces.common.selectAll' | transloco\"\n (change)=\"selection.toggleAll()\" />\n\n </div>\n }\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnName\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSource\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSize\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnDate\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (document of filtered(); track document.id) {\n <document-row\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n }\n\n @if (hasMore()) {\n <div class=\"flex flex-col items-center gap-2\">\n @if (loadMoreError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.loadMoreError\" | transloco: { error: message } }}</p>\n }\n <button variant=\"secondary\" size=\"xs\" [disabled]=\"loadingMore()\" (click)=\"loadMore()\">\n {{ \"documentWorkspaces.detail.loadMore\" | transloco }}\n </button>\n </div>\n }\n } @else {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"not-found\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n }\n }\n }\n </div>\n </div>\n\n <!-- The list gives up part of its width to the preview, and takes it back when the preview closes. -->\n <div\n #handle=\"resizeHandle\"\n class=\"group/handle flex w-3 shrink-0 cursor-col-resize items-center justify-center max-md:hidden\"\n [class.hidden]=\"!previewed()\"\n [resizeHandle]=\"previewWidth()\"\n (resizeHandleChange)=\"userPreviewWidth.set($event)\"\n [resizeMin]=\"280\"\n [resizeMax]=\"maxPreviewWidth()\"\n [attr.aria-label]=\"'documentWorkspaces.preview.resize' | transloco\">\n <span class=\"h-6 w-0.5 rounded-full bg-(--stroke-primary-light) opacity-0 group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-data-resizing/handle:opacity-100\"></span>\n </div>\n <aside\n class=\"sticky top-3 my-3 mr-3 flex h-[calc(100dvh-1.5rem)] max-h-[calc(100%-1.5rem)] shrink-0 self-start overflow-hidden rounded-(--radius-xl) bg-(--bg-neutral-white) transition-[width] duration-[350ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none data-open:border data-open:border-(--stroke-primary-lighter) data-open:shadow-[0_4px_6px_-2px_rgba(45,57,58,0.05),0_10px_15px_-3px_rgba(45,57,58,0.1)] max-md:data-open:fixed max-md:data-open:inset-0 max-md:data-open:z-50 max-md:data-open:m-0 max-md:data-open:w-full! max-md:data-open:max-h-none max-md:data-open:rounded-none\"\n [class.transition-none]=\"handle.resizing()\"\n [style.width.px]=\"previewed() ? previewWidth() : 0\"\n [attr.data-open]=\"previewed() ? '' : null\"\n [attr.aria-hidden]=\"previewed() ? null : 'true'\"\n data-testid=\"preview\">\n @if (previewed(); as previewed) {\n <!-- Its own width, so the content does not reflow while the card opens. -->\n <document-preview-panel\n class=\"shrink-0 max-md:w-full!\"\n [style.width.px]=\"previewWidth()\"\n [document]=\"previewed\"\n [queryName]=\"queryName()\"\n (closed)=\"previewedDocumentId.set(null)\"\n (askFollowUp)=\"askFollowUp.emit($event)\" />\n }\n </aside>\n ", isInline: true, dependencies: [{ kind: "component", type: ChevronDownIcon, selector: "chevron-down, ChevronDown, chevrondown, chevron-down-icon, ChevronDownIcon, chevrondownicon", inputs: ["class"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: DocumentCard, selector: "document-card", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: DocumentPreviewPanel, selector: "document-preview-panel", inputs: ["document", "queryName"], outputs: ["closed", "askFollowUp"] }, { kind: "component", type: DocumentRow, selector: "document-row", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: EmptyFolderIcon, selector: "empty-folder-icon" }, { kind: "component", type: GridIcon, selector: "grid-icon", inputs: ["class"] }, { kind: "component", type: ListIcon, selector: "list-icon, ListIcon", inputs: ["class"] }, { kind: "component", type: MagnifyingGlassIcon, selector: "magnifying-glass-icon, MagnifyingGlassIcon", inputs: ["class"] }, { kind: "component", type: NotFoundIcon, selector: "not-found-icon" }, { kind: "component", type: PenToSquareIcon, selector: "pen-to-square-icon, PenToSquareIcon", inputs: ["class"] }, { kind: "component", type: RefreshIcon, selector: "refresh-icon, RefreshIcon, refreshicon", inputs: ["class"] }, { kind: "directive", type: ResizeHandle, selector: "[resizeHandle]", inputs: ["resizeHandle", "resizeMin", "resizeMax", "resizeStep", "resizeEdge"], outputs: ["resizeHandleChange"], exportAs: ["resizeHandle"] }, { kind: "component", type: TagIcon, selector: "tag-icon, TagIcon", inputs: ["class"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip]", inputs: ["tooltip", "strategy", "tooltip-position", "tooltip-delay", "tooltip-duration", "tooltip-offset"] }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: UploadIcon, selector: "upload-icon, UploadIcon, uploadicon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "directive", type: WriterOnly, selector: "[writerOnly]", inputs: ["writerOnly"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
5850
6045
|
}
|
|
5851
6046
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceDetail, decorators: [{
|
|
5852
6047
|
type: Component,
|
|
@@ -5863,6 +6058,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
5863
6058
|
GridIcon,
|
|
5864
6059
|
ListIcon,
|
|
5865
6060
|
MagnifyingGlassIcon,
|
|
6061
|
+
NotFoundIcon,
|
|
5866
6062
|
PenToSquareIcon,
|
|
5867
6063
|
RefreshIcon,
|
|
5868
6064
|
ResizeHandle,
|
|
@@ -5886,7 +6082,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
5886
6082
|
[iconOnly]="true"
|
|
5887
6083
|
[attr.aria-label]="'documentWorkspaces.detail.back' | transloco"
|
|
5888
6084
|
[tooltip]="'documentWorkspaces.detail.back' | transloco"
|
|
5889
|
-
(click)="
|
|
6085
|
+
(click)="leave()">
|
|
5890
6086
|
<chevron-down-icon class="rotate-90" />
|
|
5891
6087
|
</button>
|
|
5892
6088
|
|
|
@@ -5894,34 +6090,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
5894
6090
|
<h1 class="min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)">{{ workspace.name }}</h1>
|
|
5895
6091
|
|
|
5896
6092
|
<!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->
|
|
5897
|
-
|
|
5898
|
-
<
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
<
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
<
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
6093
|
+
@if (!deletedElsewhere()) {
|
|
6094
|
+
<div class="ml-auto flex shrink-0 items-center gap-1">
|
|
6095
|
+
<button size="sm"
|
|
6096
|
+
variant="ghost"
|
|
6097
|
+
class="gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)"
|
|
6098
|
+
[writerOnly]="workspace.currentUserRole"
|
|
6099
|
+
(click)="confirmDeleteWorkspace(workspace)">
|
|
6100
|
+
<trash-icon />
|
|
6101
|
+
{{ "documentWorkspaces.common.delete" | transloco }}
|
|
6102
|
+
</button>
|
|
6103
|
+
<button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="share(workspace)">
|
|
6104
|
+
<user-round-icon />
|
|
6105
|
+
{{ "documentWorkspaces.share.title" | transloco }}
|
|
6106
|
+
</button>
|
|
6107
|
+
<button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="edit(workspace)">
|
|
6108
|
+
<pen-to-square-icon />
|
|
6109
|
+
{{ "documentWorkspaces.common.edit" | transloco }}
|
|
6110
|
+
</button>
|
|
6111
|
+
<button size="sm"
|
|
6112
|
+
variant="ghost"
|
|
6113
|
+
class="w-6"
|
|
6114
|
+
[iconOnly]="true"
|
|
6115
|
+
[disabled]="refreshing()"
|
|
6116
|
+
[attr.aria-label]="'documentWorkspaces.common.refresh' | transloco"
|
|
6117
|
+
[tooltip]="'documentWorkspaces.common.refresh' | transloco"
|
|
6118
|
+
(click)="refresh()">
|
|
6119
|
+
<refresh-icon />
|
|
6120
|
+
</button>
|
|
6121
|
+
</div>
|
|
6122
|
+
}
|
|
5925
6123
|
}
|
|
5926
6124
|
</div>
|
|
5927
6125
|
|
|
@@ -5936,6 +6134,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
5936
6134
|
</p>
|
|
5937
6135
|
}
|
|
5938
6136
|
|
|
6137
|
+
@if (!deletedElsewhere()) {
|
|
5939
6138
|
<div class="mb-3 flex flex-wrap items-center gap-2">
|
|
5940
6139
|
<div role="group" class="inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5">
|
|
5941
6140
|
<button
|
|
@@ -6006,6 +6205,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
6006
6205
|
{{ "documentWorkspaces.detail.fileCount" | transloco: { count: workspace.documentCount } }}
|
|
6007
6206
|
}
|
|
6008
6207
|
</p>
|
|
6208
|
+
}
|
|
6009
6209
|
}
|
|
6010
6210
|
|
|
6011
6211
|
@if (refreshError(); as message) {
|
|
@@ -6031,7 +6231,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
6031
6231
|
</div>
|
|
6032
6232
|
}
|
|
6033
6233
|
@default {
|
|
6034
|
-
@if (
|
|
6234
|
+
@if (deletedElsewhere()) {
|
|
6235
|
+
<div class="flex flex-col items-center gap-1 py-8 text-center" data-testid="not-found">
|
|
6236
|
+
<not-found-icon />
|
|
6237
|
+
<p class="max-w-70 text-sm text-(--font-primary-base)">{{ "documentWorkspaces.errors.notFound" | transloco }}</p>
|
|
6238
|
+
<button variant="secondary" size="sm" class="mt-2" (click)="leave()">{{ "documentWorkspaces.detail.back" | transloco }}</button>
|
|
6239
|
+
</div>
|
|
6240
|
+
} @else if (workspace(); as workspace) {
|
|
6035
6241
|
@if (!documents().length) {
|
|
6036
6242
|
<div class="flex flex-col items-center gap-1 py-8 text-center" data-testid="empty">
|
|
6037
6243
|
<empty-folder-icon />
|
|
@@ -6937,6 +7143,10 @@ class UploadTrackerSection {
|
|
|
6937
7143
|
const uploads = this.uploads();
|
|
6938
7144
|
return uploads.length ? Math.round(uploads.reduce((sum, u) => sum + u.percent, 0) / uploads.length) : 0;
|
|
6939
7145
|
}, ...(ngDevMode ? [{ debugName: "average" }] : []));
|
|
7146
|
+
/** The translation key standing for a row with no file name: a job started elsewhere. Only read when `name` is empty. */
|
|
7147
|
+
label(upload) {
|
|
7148
|
+
return upload.remote?.mine ? "documentWorkspaces.tracker.ownUpload" : "documentWorkspaces.tracker.remoteUpload";
|
|
7149
|
+
}
|
|
6940
7150
|
round(value) {
|
|
6941
7151
|
return Math.round(value);
|
|
6942
7152
|
}
|
|
@@ -7013,8 +7223,8 @@ class UploadTrackerSection {
|
|
|
7013
7223
|
<span
|
|
7014
7224
|
class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
|
|
7015
7225
|
[class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
|
|
7016
|
-
[attr.title]="upload.name"
|
|
7017
|
-
>{{ upload.name }}</span
|
|
7226
|
+
[attr.title]="upload.name || (label(upload) | transloco)"
|
|
7227
|
+
>{{ upload.name || (label(upload) | transloco) }}</span
|
|
7018
7228
|
>
|
|
7019
7229
|
@if (upload.message) {
|
|
7020
7230
|
<span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
|
|
@@ -7023,9 +7233,12 @@ class UploadTrackerSection {
|
|
|
7023
7233
|
@switch (upload.status) {
|
|
7024
7234
|
@case ("uploading") {
|
|
7025
7235
|
<span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7236
|
+
<!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
|
|
7237
|
+
@if (!upload.remote) {
|
|
7238
|
+
<button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
|
|
7239
|
+
<x-mark-icon />
|
|
7240
|
+
</button>
|
|
7241
|
+
}
|
|
7029
7242
|
}
|
|
7030
7243
|
@case ("indexing") {
|
|
7031
7244
|
<!-- No percentage here: the server only reports one per job, shown by the bar above. -->
|
|
@@ -7155,8 +7368,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
7155
7368
|
<span
|
|
7156
7369
|
class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
|
|
7157
7370
|
[class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
|
|
7158
|
-
[attr.title]="upload.name"
|
|
7159
|
-
>{{ upload.name }}</span
|
|
7371
|
+
[attr.title]="upload.name || (label(upload) | transloco)"
|
|
7372
|
+
>{{ upload.name || (label(upload) | transloco) }}</span
|
|
7160
7373
|
>
|
|
7161
7374
|
@if (upload.message) {
|
|
7162
7375
|
<span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
|
|
@@ -7165,9 +7378,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
7165
7378
|
@switch (upload.status) {
|
|
7166
7379
|
@case ("uploading") {
|
|
7167
7380
|
<span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7381
|
+
<!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
|
|
7382
|
+
@if (!upload.remote) {
|
|
7383
|
+
<button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
|
|
7384
|
+
<x-mark-icon />
|
|
7385
|
+
</button>
|
|
7386
|
+
}
|
|
7171
7387
|
}
|
|
7172
7388
|
@case ("indexing") {
|
|
7173
7389
|
<!-- No percentage here: the server only reports one per job, shown by the bar above. -->
|
|
@@ -7208,7 +7424,9 @@ const SECTION_ORDER = ["uploading", "indexing", "done", "error", "rejected"];
|
|
|
7208
7424
|
const FLASH_MS = 3000;
|
|
7209
7425
|
const PEEK_MS = 3500;
|
|
7210
7426
|
const WORKSPACE_COMPLETE_MS = 3500;
|
|
7211
|
-
|
|
7427
|
+
// How long a panel whose uploads are all over stays: long enough to read the outcome, even of an
|
|
7428
|
+
// upload someone else started and the user was not watching.
|
|
7429
|
+
const AUTO_DISMISS_MS = FINISHED_UPLOAD_VISIBLE_MS;
|
|
7212
7430
|
const DISMISS_ANIMATION_MS = 320;
|
|
7213
7431
|
const HOVER_LEAVE_MS = 250;
|
|
7214
7432
|
// The text on the strip leaves, then the new one comes: half a transition each.
|
|
@@ -7295,7 +7513,7 @@ class UploadTracker {
|
|
|
7295
7513
|
const failed = this.store.failed();
|
|
7296
7514
|
const severalWorkspaces = this.store.workspaces().length > 1;
|
|
7297
7515
|
// One file is named, several are counted.
|
|
7298
|
-
const label = (uploads, key, max) => uploads.length === 1 ?
|
|
7516
|
+
const label = (uploads, key, max) => uploads.length === 1 ? uploadLabel(uploads[0], max) : trackerMessage(key, { count: uploads.length });
|
|
7299
7517
|
const chips = [];
|
|
7300
7518
|
if (groups.uploading.length)
|
|
7301
7519
|
chips.push({ kind: "uploading", busy: true, message: trackerMessage("uploadingCount", { count: groups.uploading.length }) });
|
|
@@ -7315,11 +7533,11 @@ class UploadTracker {
|
|
|
7315
7533
|
return peek.message;
|
|
7316
7534
|
const failed = this.store.failed();
|
|
7317
7535
|
if (failed.length)
|
|
7318
|
-
return failed.length === 1 ?
|
|
7536
|
+
return failed.length === 1 ? uploadLabel(failed[0], 22) : trackerMessage("errorCount", { count: failed.length });
|
|
7319
7537
|
const { uploading, indexing, done } = this.store.byStatus();
|
|
7320
7538
|
const active = uploading.length + indexing.length;
|
|
7321
7539
|
if (done.length && !active)
|
|
7322
|
-
return done.length === 1 ?
|
|
7540
|
+
return done.length === 1 ? uploadLabel(done[0], 22) : trackerMessage("indexedCount", { count: done.length });
|
|
7323
7541
|
return trackerMessage("fileCount", { count: active });
|
|
7324
7542
|
}, ...(ngDevMode ? [{ debugName: "pillMessage" }] : []));
|
|
7325
7543
|
state = computed(() => (this.mode() !== "minimized" ? "normal" : this.hover() ? "minimized-hover" : "minimized"), ...(ngDevMode ? [{ debugName: "state" }] : []));
|
|
@@ -7349,10 +7567,12 @@ class UploadTracker {
|
|
|
7349
7567
|
this.completeWorkspace(complete);
|
|
7350
7568
|
});
|
|
7351
7569
|
});
|
|
7352
|
-
// Everything
|
|
7570
|
+
// Everything is over: hold the outcome for a while, then go away — unless a failure of the
|
|
7571
|
+
// user's own is there to be read, which stays until they close it. A new upload cancels
|
|
7572
|
+
// this (nothing is settled any more), and the delay starts afresh when it ends.
|
|
7353
7573
|
effect(() => {
|
|
7354
|
-
const
|
|
7355
|
-
untracked(() => (
|
|
7574
|
+
const leaves = this.store.settled() && !this.store.authorFailed();
|
|
7575
|
+
untracked(() => (leaves ? this.startAutoDismiss() : this.cancelAutoDismiss()));
|
|
7356
7576
|
});
|
|
7357
7577
|
// The strip of a minimized panel says what is going on; when that changes, the text is swapped, not cut.
|
|
7358
7578
|
effect(() => {
|
|
@@ -7413,9 +7633,9 @@ class UploadTracker {
|
|
|
7413
7633
|
}
|
|
7414
7634
|
this.mode.update(mode => (mode === "expanded" ? "inline" : "expanded"));
|
|
7415
7635
|
}
|
|
7416
|
-
/** Minimizes while there is work going on; once everything is over, closes for good. */
|
|
7636
|
+
/** Minimizes while there is work going on; once everything is over (failures included), closes for good. */
|
|
7417
7637
|
dismiss() {
|
|
7418
|
-
if (this.store.
|
|
7638
|
+
if (this.store.settled() || this.mode() === "minimized") {
|
|
7419
7639
|
this.reset();
|
|
7420
7640
|
}
|
|
7421
7641
|
else {
|
|
@@ -7471,14 +7691,14 @@ class UploadTracker {
|
|
|
7471
7691
|
const failed = finished.filter(isFailed);
|
|
7472
7692
|
if (done.length) {
|
|
7473
7693
|
const last = done[done.length - 1];
|
|
7474
|
-
const message =
|
|
7694
|
+
const message = uploadLabel(last, this.mode() === "minimized" ? 22 : 30);
|
|
7475
7695
|
if (this.mode() === "minimized")
|
|
7476
7696
|
this.notify(message, "success");
|
|
7477
7697
|
else if (this.mode() === "inline" && !this.store.allDone())
|
|
7478
7698
|
this.showFlash(message);
|
|
7479
7699
|
}
|
|
7480
7700
|
if (failed.length && this.mode() === "minimized") {
|
|
7481
|
-
this.notify(
|
|
7701
|
+
this.notify(uploadLabel(failed[failed.length - 1], 22), "error");
|
|
7482
7702
|
}
|
|
7483
7703
|
}
|
|
7484
7704
|
/**
|
|
@@ -7533,8 +7753,9 @@ class UploadTracker {
|
|
|
7533
7753
|
startAutoDismiss() {
|
|
7534
7754
|
if (this.dismissTimer)
|
|
7535
7755
|
return;
|
|
7536
|
-
// The success message is in the header: make sure it can be seen.
|
|
7537
|
-
this.
|
|
7756
|
+
// The success message is in the header: make sure it can be seen. Failures are in the sections: leave those open.
|
|
7757
|
+
if (this.store.allDone())
|
|
7758
|
+
this.mode.set("inline");
|
|
7538
7759
|
this.dismissTimer = this.replaceTimer(null, AUTO_DISMISS_MS, () => {
|
|
7539
7760
|
this.dismissTimer = null;
|
|
7540
7761
|
this.dismissing.set(true);
|
|
@@ -7670,8 +7891,8 @@ class UploadTracker {
|
|
|
7670
7891
|
variant="ghost"
|
|
7671
7892
|
size="xs"
|
|
7672
7893
|
[iconOnly]="true"
|
|
7673
|
-
[attr.aria-label]="(mode() === 'minimized' || store.
|
|
7674
|
-
[tooltip]="(mode() === 'minimized' || store.
|
|
7894
|
+
[attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
|
|
7895
|
+
[tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
|
|
7675
7896
|
(click)="dismiss(); $event.stopPropagation()">
|
|
7676
7897
|
<x-mark-icon />
|
|
7677
7898
|
</button>
|
|
@@ -7876,8 +8097,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
|
|
|
7876
8097
|
variant="ghost"
|
|
7877
8098
|
size="xs"
|
|
7878
8099
|
[iconOnly]="true"
|
|
7879
|
-
[attr.aria-label]="(mode() === 'minimized' || store.
|
|
7880
|
-
[tooltip]="(mode() === 'minimized' || store.
|
|
8100
|
+
[attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
|
|
8101
|
+
[tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
|
|
7881
8102
|
(click)="dismiss(); $event.stopPropagation()">
|
|
7882
8103
|
<x-mark-icon />
|
|
7883
8104
|
</button>
|