@sinequa/atomic-angular 1.15.0 → 1.15.1

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.
@@ -33,6 +33,9 @@
33
33
  "deleteWorkspacesTitle": "{count, plural, one {{count} Arbeitsbereich löschen?} other {{count} Arbeitsbereiche löschen?}}",
34
34
  "deleteTitle": "Arbeitsbereich löschen?",
35
35
  "loadError": "Arbeitsbereiche konnten nicht geladen werden.",
36
+ "loadMore": "Mehr laden",
37
+ "loadMoreError": "Weitere Arbeitsbereiche konnten nicht geladen werden: {error}",
38
+ "partial": "{shown} von {total} Arbeitsbereichen angezeigt",
36
39
  "loading": "Arbeitsbereiche werden geladen…",
37
40
  "refreshError": "Arbeitsbereiche konnten nicht aktualisiert werden: {error}",
38
41
  "roleOwner": "Eigentümer",
@@ -33,6 +33,9 @@
33
33
  "deleteWorkspacesTitle": "{count, plural, one {Delete {count} workspace?} other {Delete {count} workspaces?}}",
34
34
  "deleteTitle": "Delete workspace?",
35
35
  "loadError": "Couldn’t load workspaces.",
36
+ "loadMore": "Load more",
37
+ "loadMoreError": "Couldn’t load more workspaces: {error}",
38
+ "partial": "{shown} of {total} workspaces shown",
36
39
  "loading": "Loading workspaces…",
37
40
  "refreshError": "Couldn’t refresh workspaces: {error}",
38
41
  "roleOwner": "Owner",
@@ -33,6 +33,9 @@
33
33
  "deleteWorkspacesTitle": "{count, plural, one {Supprimer {count} espace de travail ?} many {Supprimer {count} espaces de travail ?} other {Supprimer {count} espaces de travail ?}}",
34
34
  "deleteTitle": "Supprimer l’espace de travail ?",
35
35
  "loadError": "Impossible de charger les espaces de travail.",
36
+ "loadMore": "Charger plus",
37
+ "loadMoreError": "Impossible de charger d’autres espaces de travail : {error}",
38
+ "partial": "{shown} espaces de travail affichés sur {total}",
36
39
  "loading": "Chargement des espaces de travail…",
37
40
  "refreshError": "Impossible d’actualiser les espaces de travail : {error}",
38
41
  "roleOwner": "Propriétaire",
@@ -33,6 +33,9 @@
33
33
  "deleteWorkspacesTitle": "{count, plural, other {{count} 件のワークスペースを削除しますか?}}",
34
34
  "deleteTitle": "ワークスペースを削除しますか?",
35
35
  "loadError": "ワークスペースを読み込めませんでした。",
36
+ "loadMore": "さらに読み込む",
37
+ "loadMoreError": "ワークスペースをさらに読み込めませんでした: {error}",
38
+ "partial": "{total} 件中 {shown} 件のワークスペースを表示",
36
39
  "loading": "ワークスペースを読み込んでいます…",
37
40
  "refreshError": "ワークスペースを更新できませんでした: {error}",
38
41
  "roleOwner": "所有者",
@@ -198,6 +198,7 @@ declare const WorkspacesStore: _angular_core.Type<{
198
198
  documentPagination: _angular_core.Signal<Record<string, Pagination>>;
199
199
  selectedIds: _angular_core.Signal<string[]>;
200
200
  hasMore: _angular_core.Signal<boolean>;
201
+ truncated: _angular_core.Signal<boolean>;
201
202
  selectedCount: _angular_core.Signal<number>;
202
203
  selectedIdSet: _angular_core.Signal<Set<string>>;
203
204
  workspace: (id: string) => Workspace | undefined;
@@ -883,6 +884,7 @@ declare class WorkspaceList {
883
884
  documentPagination: _angular_core.Signal<Record<string, _sinequa_atomic_angular_document_workspaces.Pagination>>;
884
885
  selectedIds: _angular_core.Signal<string[]>;
885
886
  hasMore: _angular_core.Signal<boolean>;
887
+ truncated: _angular_core.Signal<boolean>;
886
888
  selectedCount: _angular_core.Signal<number>;
887
889
  selectedIdSet: _angular_core.Signal<Set<string>>;
888
890
  workspace: (id: string) => Workspace | undefined;
@@ -924,6 +926,8 @@ declare class WorkspaceList {
924
926
  protected readonly status: _angular_core.WritableSignal<"error" | "loading" | "ready">;
925
927
  protected readonly loadError: _angular_core.WritableSignal<Message | null>;
926
928
  protected readonly refreshError: _angular_core.WritableSignal<string | null>;
929
+ protected readonly loadingMore: _angular_core.WritableSignal<boolean>;
930
+ protected readonly loadMoreError: _angular_core.WritableSignal<string | null>;
927
931
  protected readonly filter: {
928
932
  tab: _angular_core.WritableSignal<WorkspaceTab>;
929
933
  query: _angular_core.WritableSignal<string>;
@@ -936,6 +940,7 @@ declare class WorkspaceList {
936
940
  /** Shows what is cached at once and refreshes behind it, or loads for the first time. */
937
941
  protected load(): Promise<void>;
938
942
  private refresh;
943
+ protected loadMore(): Promise<void>;
939
944
  protected onSearch(event: Event): void;
940
945
  protected deleteSelected(): void;
941
946
  /**
@@ -1894,7 +1899,7 @@ declare class UploadTracker {
1894
1899
  /** What the strip of a minimized panel shows, which is not always what it should: a change swaps it. */
1895
1900
  protected readonly peekShown: _angular_core.WritableSignal<Message>;
1896
1901
  protected readonly peekTone: _angular_core.WritableSignal<"error" | "success" | null>;
1897
- protected readonly peekSwap: _angular_core.WritableSignal<"shown" | "hidden" | "below">;
1902
+ protected readonly peekSwap: _angular_core.WritableSignal<"hidden" | "shown" | "below">;
1898
1903
  private peekKey;
1899
1904
  private hoverTimer;
1900
1905
  private readonly timers;
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { computed, signal, Injectable, InjectionToken, inject, DestroyRef, assertInInjectionContext, effect, untracked, output, input, booleanAttribute, Directive, model, Pipe, viewChild, ChangeDetectionStrategy, Component, ElementRef, ViewEncapsulation } from '@angular/core';
3
3
  import { withDevtools } from '@angular-architects/ngrx-toolkit';
4
4
  import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
5
- import { listContainers, addContainerDocuments, deleteContainerDocuments, listContainerDocuments, deleteContainers, updateContainer, createContainer, getContainer, fetchPrincipal, globalConfig, tryAutoAuthentication, createHeaders, onAuthEvent, ApiError, fetchUploadStatus, searchPrincipals, fetchPrincipalsByIds, fetchPreview } from '@sinequa/atomic';
5
+ import { listContainerDocuments, listContainers, addContainerDocuments, deleteContainerDocuments, deleteContainers, updateContainer, createContainer, getContainer, fetchPrincipal, globalConfig, tryAutoAuthentication, createHeaders, onAuthEvent, ApiError, fetchUploadStatus, searchPrincipals, fetchPrincipalsByIds, fetchPreview } from '@sinequa/atomic';
6
6
  import { Upload } from 'tus-js-client';
7
7
  import { toSignal } from '@angular/core/rxjs-interop';
8
8
  import { TranslocoService, TranslocoPipe, provideTranslocoScope } from '@jsverse/transloco';
@@ -102,6 +102,11 @@ function mapContainerDocumentToWorkspaceDocument(dto) {
102
102
 
103
103
  /** API maximum page size. */
104
104
  const PAGE_SIZE = 100;
105
+ /**
106
+ * Most workspaces the list keeps loaded: "load more" stops there, so the list never puts an
107
+ * unbounded number of cards in the DOM. Past it, {@link WorkspacesStore.truncated} is true.
108
+ */
109
+ const MAX_LOADED_WORKSPACES = 3 * PAGE_SIZE;
105
110
  const initialState$1 = {
106
111
  workspaces: [],
107
112
  loaded: false,
@@ -131,19 +136,56 @@ function withDocumentCountAdjusted(workspaces, id, delta) {
131
136
  * pre-transfer cache update.
132
137
  */
133
138
  const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Workspaces"), withState(initialState$1), withComputed(({ workspaces, pagination, selectedIds }) => ({
134
- hasMore: computed(() => workspaces().length < pagination().total),
139
+ hasMore: computed(() => workspaces().length < pagination().total && workspaces().length < MAX_LOADED_WORKSPACES),
140
+ /** The server holds more workspaces than the list is allowed to load. */
141
+ truncated: computed(() => workspaces().length < pagination().total && workspaces().length >= MAX_LOADED_WORKSPACES),
135
142
  selectedCount: computed(() => selectedIds().length),
136
143
  selectedIdSet: computed(() => new Set(selectedIds()))
137
144
  })), withMethods(store => {
138
145
  let loadingPromise = null;
146
+ let loadingMorePromise = null;
139
147
  const setDocuments = (workspaceId, documents) => {
140
148
  patchState(store, state => ({ documents: { ...state.documents, [workspaceId]: documents } }));
141
149
  };
142
150
  // Counts are moved by a delta rather than reset to the cached list's length:
143
151
  // the cache holds at most the pages loaded so far, while `documentCount` is
144
152
  // the server's total.
153
+ // The same goes for the documents' pagination total, which `hasMoreDocuments` compares
154
+ // to the cached list: left as it was loaded, a workspace emptied of its documents would
155
+ // still offer to load more.
145
156
  const adjustDocumentCount = (workspaceId, delta) => {
146
- patchState(store, state => ({ workspaces: withDocumentCountAdjusted(state.workspaces, workspaceId, delta) }));
157
+ patchState(store, state => {
158
+ const pagination = state.documentPagination[workspaceId];
159
+ return {
160
+ workspaces: withDocumentCountAdjusted(state.workspaces, workspaceId, delta),
161
+ documentPagination: pagination
162
+ ? { ...state.documentPagination, [workspaceId]: { ...pagination, total: Math.max(0, pagination.total + delta) } }
163
+ : state.documentPagination
164
+ };
165
+ });
166
+ };
167
+ const loadDocuments = async (workspaceId) => {
168
+ const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE });
169
+ const documents = res.documents.map(mapContainerDocumentToWorkspaceDocument);
170
+ patchState(store, state => ({
171
+ documentPagination: { ...state.documentPagination, [workspaceId]: { page: res.page, total: res.total } }
172
+ }));
173
+ setDocuments(workspaceId, documents);
174
+ return documents;
175
+ };
176
+ // Deleting everything that was loaded does not mean the workspace is empty: the server may hold
177
+ // pages that were never loaded. Show them rather than an empty state next to a "load more".
178
+ // The deletion already succeeded, so a failed refill is not an error here: the cache stays empty
179
+ // and "load more" (which then asks for the first page) lets the user retry.
180
+ const refillIfEmptied = async (workspaceId) => {
181
+ if ((store.documents()[workspaceId] ?? []).length || !(store.documentPagination()[workspaceId]?.total ?? 0))
182
+ return;
183
+ try {
184
+ await loadDocuments(workspaceId);
185
+ }
186
+ catch {
187
+ // see above
188
+ }
147
189
  };
148
190
  const load = async () => {
149
191
  patchState(store, { loading: true });
@@ -194,15 +236,30 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
194
236
  loadingPromise ??= load();
195
237
  return loadingPromise;
196
238
  },
197
- async loadMore() {
239
+ /** Shares an in-flight call, so a double click does not fetch (and append) the same page twice. */
240
+ loadMore() {
198
241
  if (!store.hasMore())
199
- return store.workspaces();
200
- const res = await listContainers({ page: store.pagination().page + 1, pageSize: PAGE_SIZE });
201
- patchState(store, state => ({
202
- workspaces: [...state.workspaces, ...res.containers.map(mapContainerToWorkspace)],
203
- pagination: { page: res.page, total: res.total }
204
- }));
205
- return store.workspaces();
242
+ return Promise.resolve(store.workspaces());
243
+ loadingMorePromise ??= (async () => {
244
+ try {
245
+ const current = store.workspaces();
246
+ // Deleting workspaces shifts the server's pages: `page + 1` would skip as many as were
247
+ // deleted since. Ask for the page holding the first one not loaded yet (it may overlap
248
+ // the loaded tail, hence the dedup).
249
+ const page = Math.min(store.pagination().page + 1, Math.floor(current.length / PAGE_SIZE) + 1);
250
+ const res = await listContainers({ page, pageSize: PAGE_SIZE });
251
+ patchState(store, state => {
252
+ const loaded = new Set(state.workspaces.map(w => w.id));
253
+ const fresh = res.containers.map(mapContainerToWorkspace).filter(w => !loaded.has(w.id));
254
+ return { workspaces: [...state.workspaces, ...fresh], pagination: { page: res.page, total: res.total } };
255
+ });
256
+ return store.workspaces();
257
+ }
258
+ finally {
259
+ loadingMorePromise = null;
260
+ }
261
+ })();
262
+ return loadingMorePromise;
206
263
  },
207
264
  /**
208
265
  * Fallback for a deep link opened before the list was loaded, or for an id
@@ -220,6 +277,8 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
220
277
  const workspace = mapContainerToWorkspace(await createContainer({ name, description, tags, owners, readers }));
221
278
  patchState(store, state => ({
222
279
  workspaces: [...state.workspaces, workspace],
280
+ // The server's total grew by one too: left alone, a full list would offer to load a phantom workspace.
281
+ pagination: { ...state.pagination, total: state.pagination.total + 1 },
223
282
  documents: { ...state.documents, [workspace.id]: [] }
224
283
  }));
225
284
  return workspace;
@@ -248,28 +307,28 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
248
307
  const succeeded = new Set(result.succeeded);
249
308
  patchState(store, state => ({
250
309
  workspaces: state.workspaces.filter(w => !succeeded.has(w.id)),
310
+ // Followed like the documents' total: an emptied list must not offer to load more.
311
+ pagination: { ...state.pagination, total: Math.max(0, state.pagination.total - result.succeeded.length) },
251
312
  documents: Object.fromEntries(Object.entries(state.documents).filter(([id]) => !succeeded.has(id))),
252
313
  selectedIds: state.selectedIds.filter(id => !succeeded.has(id))
253
314
  }));
254
315
  return { succeeded: result.succeeded, failed: result.failed.map(f => ({ id: f.containerId, error: f.error })) };
255
316
  },
256
- async loadDocuments(workspaceId) {
257
- const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE });
258
- const documents = res.documents.map(mapContainerDocumentToWorkspaceDocument);
259
- patchState(store, state => ({
260
- documentPagination: { ...state.documentPagination, [workspaceId]: { page: res.page, total: res.total } }
261
- }));
262
- setDocuments(workspaceId, documents);
263
- return documents;
264
- },
317
+ loadDocuments,
265
318
  /** Without this, a workspace with more than one page of documents is silently truncated. */
266
319
  async loadMoreDocuments(workspaceId) {
267
320
  const current = store.documents()[workspaceId] ?? [];
268
321
  const pagination = store.documentPagination()[workspaceId];
269
322
  if (!pagination || current.length >= pagination.total)
270
323
  return current;
271
- const res = await listContainerDocuments(workspaceId, { page: pagination.page + 1, pageSize: PAGE_SIZE });
272
- const merged = [...current, ...res.documents.map(mapContainerDocumentToWorkspaceDocument)];
324
+ // Deleting documents shifts the server's pages: `pagination.page + 1` would skip as many
325
+ // documents as were deleted since. Ask for the page holding the first one not loaded yet
326
+ // (it may overlap the loaded tail, hence the dedup).
327
+ const page = Math.min(pagination.page + 1, Math.floor(current.length / PAGE_SIZE) + 1);
328
+ const res = await listContainerDocuments(workspaceId, { page, pageSize: PAGE_SIZE });
329
+ const loaded = new Set(current.map(d => d.id));
330
+ const fresh = res.documents.map(mapContainerDocumentToWorkspaceDocument).filter(d => !loaded.has(d.id));
331
+ const merged = [...current, ...fresh];
273
332
  patchState(store, state => ({
274
333
  documentPagination: { ...state.documentPagination, [workspaceId]: { page: res.page, total: res.total } }
275
334
  }));
@@ -322,6 +381,7 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
322
381
  const after = before.filter(d => !documentIds.includes(d.id));
323
382
  setDocuments(workspaceId, after);
324
383
  adjustDocumentCount(workspaceId, after.length - before.length);
384
+ await refillIfEmptied(workspaceId);
325
385
  },
326
386
  /**
327
387
  * "Move to workspace". There is no dedicated re-parent endpoint, so this
@@ -341,6 +401,7 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
341
401
  const remaining = from.filter(d => !documentIds.includes(d.id));
342
402
  setDocuments(fromWorkspaceId, remaining);
343
403
  adjustDocumentCount(fromWorkspaceId, remaining.length - from.length);
404
+ await refillIfEmptied(fromWorkspaceId);
344
405
  },
345
406
  toggleSelected(id, checked) {
346
407
  patchState(store, state => {
@@ -3017,6 +3078,8 @@ class WorkspaceList {
3017
3078
  status = signal("loading", ...(ngDevMode ? [{ debugName: "status" }] : []));
3018
3079
  loadError = signal(null, ...(ngDevMode ? [{ debugName: "loadError" }] : []));
3019
3080
  refreshError = signal(null, ...(ngDevMode ? [{ debugName: "refreshError" }] : []));
3081
+ loadingMore = signal(false, ...(ngDevMode ? [{ debugName: "loadingMore" }] : []));
3082
+ loadMoreError = signal(null, ...(ngDevMode ? [{ debugName: "loadMoreError" }] : []));
3020
3083
  filter = injectWorkspaceFilter(this.store.workspaces);
3021
3084
  // A workspace shared read-only cannot be deleted: it is not offered for selection either, so the
3022
3085
  // count, "select all" and the Delete button never include one.
@@ -3057,6 +3120,21 @@ class WorkspaceList {
3057
3120
  this.refreshError.set(this.translate(workspaceErrorMessage(error)));
3058
3121
  }
3059
3122
  }
3123
+ async loadMore() {
3124
+ if (this.loadingMore())
3125
+ return;
3126
+ this.loadMoreError.set(null);
3127
+ this.loadingMore.set(true);
3128
+ try {
3129
+ await this.store.loadMore();
3130
+ }
3131
+ catch (error) {
3132
+ this.loadMoreError.set(this.translate(workspaceErrorMessage(error)));
3133
+ }
3134
+ finally {
3135
+ this.loadingMore.set(false);
3136
+ }
3137
+ }
3060
3138
  onSearch(event) {
3061
3139
  this.filter.query.set(event.target.value);
3062
3140
  }
@@ -3101,7 +3179,7 @@ class WorkspaceList {
3101
3179
  return "key" in message ? this.transloco.translate(message.key, message.params) : message.text;
3102
3180
  }
3103
3181
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceList, deps: [], target: i0.ɵɵFactoryTarget.Component });
3104
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceList, isStandalone: true, selector: "workspace-list", outputs: { opened: "opened", createRequested: "createRequested" }, host: { classAttribute: "@container flex h-full min-h-0 flex-col p-3 pb-2 md:p-8 md:pb-4" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"mb-1 flex items-center justify-between gap-4\">\n <h1 class=\"text-2xl leading-[31px] font-bold text-(--font-neutral-black)\">{{ \"documentWorkspaces.list.title\" | transloco }}</h1>\n <button size=\"sm\" variant=\"primary\" class=\"gap-1.5 rounded-full\" (click)=\"createRequested.emit()\">\n <plus-icon />\n {{ \"documentWorkspaces.list.addWorkspace\" | transloco }}\n </button>\n </div>\n <p class=\"mb-6 text-base text-(--font-primary-base)\">{{ \"documentWorkspaces.list.subtitle\" | transloco }}</p>\n\n <div role=\"tablist\" class=\"mb-3 flex w-fit max-w-full overflow-x-auto border-b border-(--stroke-primary-lighter)\">\n @for (item of tabs; track item.tab) {\n <button\n variant=\"none\"\n role=\"tab\"\n type=\"button\"\n class=\"flex h-11 items-center gap-1 rounded-t-lg border-b-2 border-transparent px-3 text-sm font-semibold whitespace-nowrap text-(--font-primary-base) hover:bg-(--bg-primary-lightest) aria-selected:border-(--stroke-primary-base) aria-selected:bg-(--bg-neutral-white)\"\n [attr.aria-selected]=\"filter.tab() === item.tab\"\n (click)=\"filter.tab.set(item.tab)\">\n @switch (item.tab) {\n @case (\"editor\") {\n <user-icon class=\"size-4\" />\n }\n @case (\"reader\") {\n <user-round-icon class=\"size-4\" />\n }\n }\n {{ item.labelKey | transloco }}\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-(--bg-primary-light) px-1 text-[10px] leading-[10px] font-semibold\"\n [attr.data-testid]=\"'badge-' + item.tab\"\n >{{ filter.counts()[item.tab] }}</span\n >\n </button>\n }\n </div>\n\n <div class=\"mb-6 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]=\"filter.query()\"\n [attr.placeholder]=\"'documentWorkspaces.list.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.list.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (filter.query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"filter.clearQuery()\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\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 <span class=\"text-sm text-(--font-primary-base)\" aria-live=\"polite\">{{ \"documentWorkspaces.list.selectedCount\" | transloco: { count: selection.count() } }}</span>\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 @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-sm text-(--font-error-base)\">{{ \"documentWorkspaces.list.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the workspaces scroll: the title, the tabs and the toolbar stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-sm text-(--font-primary-muted)\">{{ \"documentWorkspaces.list.loading\" | 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.list.loadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (!filter.filtered().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <!-- The illustration is for a list with nothing in it, not for a search that found nothing. -->\n @if (!store.workspaces().length) {\n <empty-folder-icon />\n }\n <p class=\"text-sm text-(--font-primary-muted)\">\n {{ (store.workspaces().length ? \"documentWorkspaces.list.noMatch\" : \"documentWorkspaces.list.empty\") | transloco }}\n </p>\n </div>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] content-start gap-4 pb-8\">\n @for (workspace of filter.filtered(); track workspace.id) {\n <workspace-card\n [workspace]=\"workspace\"\n [query]=\"filter.query()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(workspace)\"\n [selectable]=\"selection.canSelect(workspace)\"\n (opened)=\"opened.emit(workspace)\"\n (deleteRequested)=\"confirmDelete([workspace])\"\n (selectedChange)=\"selection.toggle(workspace, $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_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_auto_2.5rem] @xl:grid-cols-[minmax(0,1fr)_4.5rem_6.5rem_6.5rem_2.5rem] @xl:data-selecting:grid-cols-[2rem_minmax(0,1fr)_4.5rem_6.5rem_6.5rem_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_5.5rem_7.5rem_9rem_minmax(5rem,1fr)_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_5.5rem_7.5rem_9rem_minmax(5rem,1fr)_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.list.columnName\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.list.columnContent\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.list.columnOwner\" | transloco }}</div>\n <div role=\"columnheader\" class=\"hidden @xl:block\">{{ \"documentWorkspaces.list.columnDateUpdated\" | transloco }}</div>\n <div role=\"columnheader\" class=\"hidden @3xl:block\">{{ \"documentWorkspaces.list.columnTags\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (workspace of filter.filtered(); track workspace.id) {\n <workspace-row\n [workspace]=\"workspace\"\n [query]=\"filter.query()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(workspace)\"\n [selectable]=\"selection.canSelect(workspace)\"\n (opened)=\"opened.emit(workspace)\"\n (deleteRequested)=\"confirmDelete([workspace])\"\n (selectedChange)=\"selection.toggle(workspace, $event)\" />\n }\n </div>\n }\n }\n }\n </div>\n ", isInline: true, dependencies: [{ kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { 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: PlusIcon, selector: "plus-icon, PlusIcon", 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: UserIcon, selector: "user-icon, UserIcon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceCard, selector: "workspace-card", inputs: ["workspace", "query", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "component", type: WorkspaceRow, selector: "workspace-row", inputs: ["workspace", "query", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3182
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceList, isStandalone: true, selector: "workspace-list", outputs: { opened: "opened", createRequested: "createRequested" }, host: { classAttribute: "@container flex h-full min-h-0 flex-col p-3 pb-2 md:p-8 md:pb-4" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"mb-1 flex items-center justify-between gap-4\">\n <h1 class=\"text-2xl leading-[31px] font-bold text-(--font-neutral-black)\">{{ \"documentWorkspaces.list.title\" | transloco }}</h1>\n <button size=\"sm\" variant=\"primary\" class=\"gap-1.5 rounded-full\" (click)=\"createRequested.emit()\">\n <plus-icon />\n {{ \"documentWorkspaces.list.addWorkspace\" | transloco }}\n </button>\n </div>\n <p class=\"mb-6 text-base text-(--font-primary-base)\">{{ \"documentWorkspaces.list.subtitle\" | transloco }}</p>\n\n <div role=\"tablist\" class=\"mb-3 flex w-fit max-w-full overflow-x-auto border-b border-(--stroke-primary-lighter)\">\n @for (item of tabs; track item.tab) {\n <button\n variant=\"none\"\n role=\"tab\"\n type=\"button\"\n class=\"flex h-11 items-center gap-1 rounded-t-lg border-b-2 border-transparent px-3 text-sm font-semibold whitespace-nowrap text-(--font-primary-base) hover:bg-(--bg-primary-lightest) aria-selected:border-(--stroke-primary-base) aria-selected:bg-(--bg-neutral-white)\"\n [attr.aria-selected]=\"filter.tab() === item.tab\"\n (click)=\"filter.tab.set(item.tab)\">\n @switch (item.tab) {\n @case (\"editor\") {\n <user-icon class=\"size-4\" />\n }\n @case (\"reader\") {\n <user-round-icon class=\"size-4\" />\n }\n }\n {{ item.labelKey | transloco }}\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-(--bg-primary-light) px-1 text-[10px] leading-[10px] font-semibold\"\n [attr.data-testid]=\"'badge-' + item.tab\"\n >{{ filter.counts()[item.tab] }}</span\n >\n </button>\n }\n </div>\n\n <div class=\"mb-6 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]=\"filter.query()\"\n [attr.placeholder]=\"'documentWorkspaces.list.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.list.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (filter.query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"filter.clearQuery()\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\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 <span class=\"text-sm text-(--font-primary-base)\" aria-live=\"polite\">{{ \"documentWorkspaces.list.selectedCount\" | transloco: { count: selection.count() } }}</span>\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 @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-sm text-(--font-error-base)\">{{ \"documentWorkspaces.list.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the workspaces scroll: the title, the tabs and the toolbar stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-sm text-(--font-primary-muted)\">{{ \"documentWorkspaces.list.loading\" | 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.list.loadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (!filter.filtered().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <!-- The illustration is for a list with nothing in it, not for a search that found nothing. -->\n @if (!store.workspaces().length) {\n <empty-folder-icon />\n }\n <p class=\"text-sm text-(--font-primary-muted)\">\n {{ (store.workspaces().length ? \"documentWorkspaces.list.noMatch\" : \"documentWorkspaces.list.empty\") | transloco }}\n </p>\n </div>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] content-start gap-4 pb-8\">\n @for (workspace of filter.filtered(); track workspace.id) {\n <workspace-card\n [workspace]=\"workspace\"\n [query]=\"filter.query()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(workspace)\"\n [selectable]=\"selection.canSelect(workspace)\"\n (opened)=\"opened.emit(workspace)\"\n (deleteRequested)=\"confirmDelete([workspace])\"\n (selectedChange)=\"selection.toggle(workspace, $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_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_auto_2.5rem] @xl:grid-cols-[minmax(0,1fr)_4.5rem_6.5rem_6.5rem_2.5rem] @xl:data-selecting:grid-cols-[2rem_minmax(0,1fr)_4.5rem_6.5rem_6.5rem_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_5.5rem_7.5rem_9rem_minmax(5rem,1fr)_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_5.5rem_7.5rem_9rem_minmax(5rem,1fr)_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.list.columnName\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.list.columnContent\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.list.columnOwner\" | transloco }}</div>\n <div role=\"columnheader\" class=\"hidden @xl:block\">{{ \"documentWorkspaces.list.columnDateUpdated\" | transloco }}</div>\n <div role=\"columnheader\" class=\"hidden @3xl:block\">{{ \"documentWorkspaces.list.columnTags\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (workspace of filter.filtered(); track workspace.id) {\n <workspace-row\n [workspace]=\"workspace\"\n [query]=\"filter.query()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(workspace)\"\n [selectable]=\"selection.canSelect(workspace)\"\n (opened)=\"opened.emit(workspace)\"\n (deleteRequested)=\"confirmDelete([workspace])\"\n (selectedChange)=\"selection.toggle(workspace, $event)\" />\n }\n </div>\n }\n\n @if (store.hasMore() || store.truncated()) {\n <div class=\"flex flex-col items-center gap-2 pb-8\" data-testid=\"load-more\">\n <p class=\"text-sm text-(--font-primary-muted)\">\n {{ \"documentWorkspaces.list.partial\" | transloco: { shown: store.workspaces().length, total: store.pagination().total } }}\n </p>\n @if (loadMoreError(); as message) {\n <p role=\"alert\" class=\"text-sm text-(--font-error-base)\">{{ \"documentWorkspaces.list.loadMoreError\" | transloco: { error: message } }}</p>\n }\n @if (store.hasMore()) {\n <button variant=\"secondary\" size=\"sm\" [disabled]=\"loadingMore()\" (click)=\"loadMore()\">\n {{ \"documentWorkspaces.list.loadMore\" | transloco }}\n </button>\n }\n </div>\n }\n }\n }\n </div>\n ", isInline: true, dependencies: [{ kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { 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: PlusIcon, selector: "plus-icon, PlusIcon", 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: UserIcon, selector: "user-icon, UserIcon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceCard, selector: "workspace-card", inputs: ["workspace", "query", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "component", type: WorkspaceRow, selector: "workspace-row", inputs: ["workspace", "query", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3105
3183
  }
3106
3184
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceList, decorators: [{
3107
3185
  type: Component,
@@ -3305,6 +3383,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
3305
3383
  }
3306
3384
  </div>
3307
3385
  }
3386
+
3387
+ @if (store.hasMore() || store.truncated()) {
3388
+ <div class="flex flex-col items-center gap-2 pb-8" data-testid="load-more">
3389
+ <p class="text-sm text-(--font-primary-muted)">
3390
+ {{ "documentWorkspaces.list.partial" | transloco: { shown: store.workspaces().length, total: store.pagination().total } }}
3391
+ </p>
3392
+ @if (loadMoreError(); as message) {
3393
+ <p role="alert" class="text-sm text-(--font-error-base)">{{ "documentWorkspaces.list.loadMoreError" | transloco: { error: message } }}</p>
3394
+ }
3395
+ @if (store.hasMore()) {
3396
+ <button variant="secondary" size="sm" [disabled]="loadingMore()" (click)="loadMore()">
3397
+ {{ "documentWorkspaces.list.loadMore" | transloco }}
3398
+ </button>
3399
+ }
3400
+ </div>
3401
+ }
3308
3402
  }
3309
3403
  }
3310
3404
  </div>