Compare commits

..
20 changed files with 723 additions and 467 deletions
+3 -3
View File
@@ -50,18 +50,18 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.4 rev: v0.16.1
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.28.1" rev: "v2.26.0"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli] additional_dependencies: [tomli]
# Dockerfile hooks # Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py - repo: https://github.com/AleksaC/hadolint-py
rev: v2.15.1 rev: v2.14.0
hooks: hooks:
- id: hadolint - id: hadolint
# Shell script hooks # Shell script hooks
+140 -140
View File
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,8 @@ export class TrashComponent
private modalService = inject(NgbModal) private modalService = inject(NgbModal)
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private router = inject(Router) private router = inject(Router)
private readonly emptyTrashDelaySetting =
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
readonly documentsInTrash = signal<Document[]>([]) readonly documentsInTrash = signal<Document[]>([])
readonly selectedDocuments = signal<Set<number>>(new Set()) readonly selectedDocuments = signal<Set<number>>(new Set())
@@ -200,8 +202,7 @@ export class TrashComponent
} }
getDaysRemaining(document: Document): number { getDaysRemaining(document: Document): number {
this.settingsService.trackChanges() const delay = this.emptyTrashDelaySetting()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime() const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24)) const days = Math.ceil(diff / (1000 * 3600 * 24))
return delay - days return delay - days
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
expect(savedViewSpy).toHaveBeenCalled() expect(savedViewSpy).toHaveBeenCalled()
}) })
it('should update reinitialized signal-backed settings without manual change detection', async () => {
settingsService.initializeSettings().subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush({
settings: { app_title: 'Reactive title' },
user: {},
permissions: [],
})
await fixture.whenStable()
expect(
fixture.nativeElement.querySelector('.brand-title').textContent
).toBe('Reactive title')
})
it('should check for update if enabled', () => { it('should check for update if enabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates') const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
updateCheckSpy.mockImplementation(() => { updateCheckSpy.mockImplementation(() => {
@@ -98,6 +98,29 @@ export class AppFrameComponent
readonly isMenuCollapsed = signal(true) readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false) readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false) readonly mobileSearchHidden = signal(false)
private readonly versionSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.VERSION
)
private readonly appTitleSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_TITLE
)
private readonly appLogoSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.APP_LOGO
)
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SLIM_SIDEBAR
)
private readonly attributesSectionsCollapsedSetting =
this.settingsService.getSignal<CollapsibleSection[]>(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly sidebarViewsShowCountSetting =
this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
)
private lastScrollY: number = 0 private lastScrollY: number = 0
constructor() { constructor() {
@@ -191,33 +214,23 @@ export class AppFrameComponent
} }
get versionString(): string { get versionString(): string {
this.settingsService.trackChanges() return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
get appTitle(): string { get appTitle(): string {
this.settingsService.trackChanges() return this.appTitleSetting() || environment.appTitle
return (
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
)
} }
get customAppTitle(): string { get customAppTitle(): string {
this.settingsService.trackChanges() return this.appTitleSetting()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
get hasCustomBranding(): boolean { get hasCustomBranding(): boolean {
this.settingsService.trackChanges() return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
return !!(
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
)
} }
get customAppLogo(): string { get customAppLogo(): string {
this.settingsService.trackChanges() const logo = this.appLogoSetting()
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
return logo?.length return logo?.length
? environment.apiBaseUrl.replace(/\/api\/$/, logo) ? environment.apiBaseUrl.replace(/\/api\/$/, logo)
: null : null
@@ -262,8 +275,7 @@ export class AppFrameComponent
} }
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges() return this.slimSidebarSetting()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
set slimSidebarEnabled(enabled: boolean) { set slimSidebarEnabled(enabled: boolean) {
@@ -286,10 +298,9 @@ export class AppFrameComponent
} }
get attributesSectionsCollapsed(): boolean { get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges() return this.attributesSectionsCollapsedSetting()?.includes(
return this.settingsService CollapsibleSection.ATTRIBUTES
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED) )
?.includes(CollapsibleSection.ATTRIBUTES)
} }
set attributesSectionsCollapsed(collapsed: boolean) { set attributesSectionsCollapsed(collapsed: boolean) {
@@ -312,8 +323,7 @@ export class AppFrameComponent
} }
get aiEnabled(): boolean { get aiEnabled(): boolean {
this.settingsService.trackChanges() return this.aiEnabledSetting()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }
@HostListener('window:resize') @HostListener('window:resize')
@@ -480,9 +490,8 @@ export class AppFrameComponent
} }
get showSidebarCounts(): boolean { get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return ( return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) && this.sidebarViewsShowCountSetting() &&
!this.settingsService.organizingSidebarSavedViews() !this.settingsService.organizingSidebarSavedViews()
) )
} }
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
private hotkeyService = inject(HotKeyService) private hotkeyService = inject(HotKeyService)
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private locationStrategy = inject(LocationStrategy) private locationStrategy = inject(LocationStrategy)
private readonly searchFullTypeSetting =
this.settingsService.getSignal<GlobalSearchType>(
SETTINGS_KEYS.SEARCH_FULL_TYPE
)
public DataType = DataType public DataType = DataType
readonly query = signal<string>(null) readonly query = signal<string>(null)
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef> @ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean { get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges() return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
} }
constructor() { constructor() {
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
function setActionSettings({
email = true,
remoteOcr = true,
ai = true,
} = {}) {
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
}
it('should support create and edit modes, support adding triggers and actions on new workflow', () => { it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode.set(EditDialogMode.CREATE)
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should return source options, type options, type name, schedule date field options', () => { it('should return source options, type options, type name, schedule date field options', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
component.ngOnInit() component.ngOnInit()
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS) expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS) expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
) )
// Email, remote OCR and AI all disabled // Email, remote OCR and AI all disabled
jest.spyOn(settingsService, 'get').mockReturnValue(false) setActionSettings({ email: false, remoteOcr: false, ai: false })
component.ngOnInit() component.ngOnInit()
expect(component.actionTypeOptions).toEqual( expect(component.actionTypeOptions).toEqual(
WORKFLOW_ACTION_OPTIONS.filter( WORKFLOW_ACTION_OPTIONS.filter(
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer remote OCR only for consumption workflows', () => { it('should offer remote OCR only for consumption workflows', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// A consumption trigger makes the action reachable // A consumption trigger makes the action reachable
component.object = { component.object = {
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer remote OCR on a trigger added to a new workflow', () => { it('should offer remote OCR on a trigger added to a new workflow', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
component.ngOnInit() component.ngOnInit()
// Nothing for the action to apply to yet // Nothing for the action to apply to yet
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should keep remote OCR listed when an action already uses it', () => { it('should keep remote OCR listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Otherwise changing the trigger would silently blank the selection // Otherwise changing the trigger would silently blank the selection
component.object = { component.object = {
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should not offer remote OCR when no engine is configured', () => { it('should not offer remote OCR when no engine is configured', () => {
jest setActionSettings({ remoteOcr: false })
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
component.object = { component.object = {
name: 'Workflow 1', name: 'Workflow 1',
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should offer apply AI suggestions unless every trigger is consumption', () => { it('should offer apply AI suggestions unless every trigger is consumption', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Consumption runs before the document has been parsed, so there would be // Consumption runs before the document has been parsed, so there would be
// no content to make suggestions from // no content to make suggestions from
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should keep apply AI suggestions listed when an action already uses it', () => { it('should keep apply AI suggestions listed when an action already uses it', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(true) setActionSettings()
// Otherwise changing the trigger would silently blank the selection // Otherwise changing the trigger would silently blank the selection
component.object = { component.object = {
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
}) })
it('should not offer apply AI suggestions when AI is disabled', () => { it('should not offer apply AI suggestions when AI is disabled', () => {
jest setActionSettings({ ai: false })
.spyOn(settingsService, 'get')
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
component.object = { component.object = {
name: 'Workflow 1', name: 'Workflow 1',
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
readonly dateCustomFields = computed(() => readonly dateCustomFields = computed(() =>
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date) this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
) )
private readonly emailEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
private readonly remoteOcrConfiguredSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
expandedItem: number = null expandedItem: number = null
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
private getAllowedActionTypes() { private getAllowedActionTypes() {
let allowed = WORKFLOW_ACTION_OPTIONS let allowed = WORKFLOW_ACTION_OPTIONS
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) { if (!this.emailEnabledSetting()) {
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email) allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
} }
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
// offered for workflows that run at consumption. // offered for workflows that run at consumption.
const formWorkflow: Workflow = this.objectForm?.value const formWorkflow: Workflow = this.objectForm?.value
const remoteOcrUsable = const remoteOcrUsable =
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) && this.remoteOcrConfiguredSetting() &&
(formWorkflow?.triggers?.some( (formWorkflow?.triggers?.some(
(trigger) => trigger.type === WorkflowTriggerType.Consumption (trigger) => trigger.type === WorkflowTriggerType.Consumption
) || ) ||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
// once every trigger is consumption, so it stays offered on a workflow // once every trigger is consumption, so it stays offered on a workflow
// that has no triggers yet. // that has no triggers yet.
const aiSuggestionsUsable = const aiSuggestionsUsable =
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) && this.aiEnabledSetting() &&
(!formWorkflow?.triggers?.length || (!formWorkflow?.triggers?.length ||
formWorkflow.triggers.some( formWorkflow.triggers.some(
(trigger) => trigger.type !== WorkflowTriggerType.Consumption (trigger) => trigger.type !== WorkflowTriggerType.Consumption
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
} }
get actionTypeOptions() { get actionTypeOptions() {
this.settingsService.trackChanges()
// Computed on read rather than cached // Computed on read rather than cached
return this.getAllowedActionTypes() return this.getAllowedActionTypes()
} }
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [memoRoot] selectionModel.items = [memoRoot]
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }] selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(memoRoot.id)).toEqual(9) expect(getRootDocCount(memoRoot.id)).toEqual(9)
selectionModel.documentCounts = [] selectionModel.documentCounts = []
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutSelection] selectionModel.items = [rootWithoutSelection]
selectionModel.documentCounts = [] selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4) expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
}) })
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.items = [rootWithoutCounts] selectionModel.items = [rootWithoutCounts]
selectionModel.documentCounts = [] selectionModel.documentCounts = []
const getRootDocCount = (selectionModel as any).createRootDocCounter() const getRootDocCount = (selectionModel as any).createRootDocCounter(
selectionModel.items
)
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0) expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
}) })
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel['temporarySelectionStates'].set(id, state) component.selectionModel['temporarySelectionStates'].set(id, state)
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next') const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
component.selectionModel.exclude(id) component.selectionModel.exclude(id)
expect(component.selectionModel.temporaryLogicalOperator).toBe( expect(component.selectionModel.temporaryLogicalOperator()).toBe(
LogicalOperator.And LogicalOperator.And
) )
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe( expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
manyToOne = false manyToOne = false
singleSelect = false singleSelect = false
private _logicalOperator: LogicalOperator = LogicalOperator.And
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
private _documentCounts: SelectionDataItem[] = [] private readonly _logicalOperator = signal(LogicalOperator.And)
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
private readonly _intersection = signal(Intersection.Include)
readonly temporaryIntersection = signal(Intersection.Include)
private readonly _documentCounts = signal<SelectionDataItem[]>([])
private readonly _items = signal<MatchingModel[]>([])
private readonly _selectionStates = signal(
new Map<number, ToggleableItemState>()
)
private readonly _temporarySelectionStates = signal(
new Map<number, ToggleableItemState>()
)
public documentCountSortingEnabled = false public documentCountSortingEnabled = false
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
return this._selectionStates()
}
private get temporarySelectionStates(): ReadonlyMap<
number,
ToggleableItemState
> {
return this._temporarySelectionStates()
}
public set documentCounts(counts: SelectionDataItem[]) { public set documentCounts(counts: SelectionDataItem[]) {
this._documentCounts = counts this._documentCounts.set(counts)
if (this.documentCountSortingEnabled) { if (this.documentCountSortingEnabled) {
this.sortItems() this._items.set(this.sortItems(this.items))
} }
} }
private _items: MatchingModel[] = []
get items(): MatchingModel[] { get items(): MatchingModel[] {
return this._items return this._items()
} }
set items(items: MatchingModel[]) { set items(items: MatchingModel[]) {
if (items) { if (items) {
this._items = Array.from(items) this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
this.sortItems()
this.setNullItem()
} }
} }
private setNullItem() { private withNullItem(items: MatchingModel[]): MatchingModel[] {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) { if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
if (this._items[0]?.id === null) { return items[0]?.id === null ? items.slice(1) : items
this._items.shift()
}
return
} }
const item = { const nullItem = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`, name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: id:
this.manyToOne || this.intersection === Intersection.Include this.manyToOne || this.intersection === Intersection.Include
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
: NEGATIVE_NULL_FILTER_VALUE, : NEGATIVE_NULL_FILTER_VALUE,
} }
if ( return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
this._items[0]?.id === null || ? [nullItem, ...items.slice(1)]
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE : [nullItem, ...items]
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
} }
constructor(manyToOne: boolean = false) { constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne this.manyToOne = manyToOne
} }
private sortItems() { private sortItems(items: MatchingModel[]): MatchingModel[] {
this._items.sort((a, b) => { const sorted = [...items].sort((a, b) => {
if ( if (
(a.id == null && b.id != null) || (a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE && (a.id == NEGATIVE_NULL_FILTER_VALUE &&
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
) { ) {
return -1 return -1
} else if ( } else if (
this._documentCounts.length && this._documentCounts().length &&
this.getDocumentCount(b.id) === 0 && this.getDocumentCount(b.id) === 0 &&
this.getDocumentCount(a.id) > this.getDocumentCount(b.id) this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
) { ) {
return -1 return -1
} else if ( } else if (
this._documentCounts.length && this._documentCounts().length &&
this.getDocumentCount(a.id) === 0 && this.getDocumentCount(a.id) === 0 &&
this.getDocumentCount(a.id) < this.getDocumentCount(b.id) this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) { ) {
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
} }
}) })
if (this._documentCounts.length) { return this._documentCounts().length
this.promoteBranchesWithDocumentCounts() ? this.promoteBranchesWithDocumentCounts(sorted)
} : sorted
} }
private selectionStates = new Map<number, ToggleableItemState>()
private temporarySelectionStates = new Map<number, ToggleableItemState>()
getSelectedItems() { getSelectedItems() {
return this.items.filter( return this.items.filter(
(i) => (i) =>
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
} }
set(id: number, state: ToggleableItemState, fireEvent = true) { set(id: number, state: ToggleableItemState, fireEvent = true) {
const states = new Map(this.temporarySelectionStates)
if (state == ToggleableItemState.NotSelected) { if (state == ToggleableItemState.NotSelected) {
this.temporarySelectionStates.delete(id) states.delete(id)
} else { } else {
this.temporarySelectionStates.set(id, state) states.set(id, state)
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
} }
toggle(id: number, fireEvent = true) { toggle(id: number, fireEvent = true) {
let state = this.temporarySelectionStates.get(id) const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
if ( if (
state == undefined || state == undefined ||
(state != ToggleableItemState.Selected && (state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded) state != ToggleableItemState.Excluded)
) { ) {
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Selected) states.set(id, ToggleableItemState.Selected)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key != id) { if (key != id) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} }
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
) { ) {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) states.set(id, newState)
} }
} else if ( } else if (
state == ToggleableItemState.Selected || state == ToggleableItemState.Selected ||
state == ToggleableItemState.Excluded state == ToggleableItemState.Excluded
) { ) {
this.temporarySelectionStates.delete(id) states.delete(id)
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
if (!id) { if (!id) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key) { if (key) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} else { } else {
this.temporarySelectionStates.delete(null) states.delete(null)
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
} }
exclude(id: number, fireEvent: boolean = true) { exclude(id: number, fireEvent: boolean = true) {
let state = this.temporarySelectionStates.get(id) const states = new Map(this.temporarySelectionStates)
let state = states.get(id)
if (id && (state == null || state != ToggleableItemState.Excluded)) { if (id && (state == null || state != ToggleableItemState.Excluded)) {
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
? LogicalOperator.And this.temporaryLogicalOperator.set(operator)
: LogicalOperator.Or this._logicalOperator.set(operator)
if (this.manyToOne || this.singleSelect) { if (this.manyToOne || this.singleSelect) {
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded) states.set(id, ToggleableItemState.Excluded)
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
if (this.singleSelect) { if (this.singleSelect) {
for (let key of this.temporarySelectionStates.keys()) { for (let key of states.keys()) {
if (key != id) { if (key != id) {
this.temporarySelectionStates.delete(key) states.delete(key)
} }
} }
} }
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
) { ) {
newState = ToggleableItemState.NotSelected newState = ToggleableItemState.NotSelected
} }
this.temporarySelectionStates.set(id, newState) states.set(id, newState)
if (newState == ToggleableItemState.Excluded) { if (newState == ToggleableItemState.Excluded) {
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
} }
} else if (!id || state == ToggleableItemState.Excluded) { } else if (!id || state == ToggleableItemState.Excluded) {
this.temporarySelectionStates.delete(id) states.delete(id)
if (id) { if (id) {
this.clearDescendantSelections(id) this.clearDescendantSelections(states, id)
} }
} }
this._temporarySelectionStates.set(states)
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
return this.selectionStates.get(id) || ToggleableItemState.NotSelected return this.selectionStates.get(id) || ToggleableItemState.NotSelected
} }
private clearDescendantSelections(id: number) { private clearDescendantSelections(
states: Map<number, ToggleableItemState>,
id: number
) {
for (const descendantID of this.getDescendantIDs(id)) { for (const descendantID of this.getDescendantIDs(id)) {
this.temporarySelectionStates.delete(descendantID) states.delete(descendantID)
} }
} }
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
while (queue.length) { while (queue.length) {
const parentID = queue.shift() const parentID = queue.shift()
for (const item of this._items) { for (const item of this.items) {
if ( if (
typeof item?.id === 'number' && typeof item?.id === 'number' &&
typeof (item as any)['parent'] === 'number' && typeof (item as any)['parent'] === 'number' &&
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
} }
get logicalOperator(): LogicalOperator { get logicalOperator(): LogicalOperator {
return this.temporaryLogicalOperator return this.temporaryLogicalOperator()
} }
set logicalOperator(operator: LogicalOperator) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator.set(operator)
this.setNullItem() this._items.set(this.withNullItem(this.items))
} }
toggleOperator() { toggleOperator() {
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
} }
get intersection(): Intersection { get intersection(): Intersection {
return this.temporaryIntersection return this.temporaryIntersection()
} }
set intersection(intersection: Intersection) { set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection this.temporaryIntersection.set(intersection)
this.setNullItem() this._items.set(this.withNullItem(this.items))
} }
toggleIntersection() { toggleIntersection() {
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
? ToggleableItemState.Selected ? ToggleableItemState.Selected
: ToggleableItemState.Excluded : ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => { const states = new Map(this.temporarySelectionStates)
states.forEach((state, key) => {
if (key === null && this.intersection === Intersection.Exclude) { if (key === null && this.intersection === Intersection.Exclude) {
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState) states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if ( } else if (
key === NEGATIVE_NULL_FILTER_VALUE && key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include this.intersection === Intersection.Include
) { ) {
this.temporarySelectionStates.set(null, newState) states.set(null, newState)
} else { } else {
this.temporarySelectionStates.set(key, newState) states.set(key, newState)
} }
}) })
this._temporarySelectionStates.set(states)
this.changed.next(this) this.changed.next(this)
} }
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
} }
clear(fireEvent = true) { clear(fireEvent = true) {
this.temporarySelectionStates.clear() this._temporarySelectionStates.set(new Map())
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And this.temporaryLogicalOperator.set(LogicalOperator.And)
this.temporaryIntersection = this._intersection = Intersection.Include this._logicalOperator.set(LogicalOperator.And)
this.setNullItem() this.temporaryIntersection.set(Intersection.Include)
this._intersection.set(Intersection.Include)
this._items.set(this.withNullItem(this.items))
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
) )
) { ) {
return true return true
} else if (this.temporaryLogicalOperator !== this._logicalOperator) { } else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
return true return true
} else if (this.temporaryIntersection !== this._intersection) { } else if (this.temporaryIntersection() !== this._intersection()) {
return true return true
} else { } else {
return false return false
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
} }
getDocumentCount(id: number) { getDocumentCount(id: number) {
return this._documentCounts.find((c) => c.id === id)?.document_count return this._documentCounts().find((c) => c.id === id)?.document_count
} }
private promoteBranchesWithDocumentCounts() { private promoteBranchesWithDocumentCounts(
const parentById = this.buildParentById() items: MatchingModel[]
): MatchingModel[] {
const parentById = this.buildParentById(items)
const findRootId = this.createRootFinder(parentById) const findRootId = this.createRootFinder(parentById)
const getRootDocCount = this.createRootDocCounter() const getRootDocCount = this.createRootDocCounter(items)
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount) const summaries = this.buildBranchSummaries(
items,
findRootId,
getRootDocCount
)
const orderedBranches = this.orderBranchesByPriority(summaries) const orderedBranches = this.orderBranchesByPriority(summaries)
this._items = orderedBranches.flatMap((summary) => summary.items) return orderedBranches.flatMap((summary) => summary.items)
} }
private buildParentById(): Map<number, number | null> { private buildParentById(items: MatchingModel[]): Map<number, number | null> {
const parentById = new Map<number, number | null>() const parentById = new Map<number, number | null>()
for (const item of this._items) { for (const item of items) {
if (typeof item?.id === 'number') { if (typeof item?.id === 'number') {
const parentValue = (item as any)['parent'] const parentValue = (item as any)['parent']
parentById.set( parentById.set(
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
return findRootId return findRootId
} }
private createRootDocCounter(): (rootId: number) => number { private createRootDocCounter(
items: MatchingModel[]
): (rootId: number) => number {
const docCountMemo = new Map<number, number>() const docCountMemo = new Map<number, number>()
return (rootId: number): number => { return (rootId: number): number => {
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
return explicit return explicit
} }
const rootItem = this._items.find((i) => i.id === rootId) const rootItem = items.find((i) => i.id === rootId)
const fallback = const fallback =
typeof (rootItem as any)?.['document_count'] === 'number' typeof (rootItem as any)?.['document_count'] === 'number'
? (rootItem as any)['document_count'] ? (rootItem as any)['document_count']
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
} }
private buildBranchSummaries( private buildBranchSummaries(
items: MatchingModel[],
findRootId: (id: number) => number, findRootId: (id: number) => number,
getRootDocCount: (rootId: number) => number getRootDocCount: (rootId: number) => number
): Map<string, BranchSummary> { ): Map<string, BranchSummary> {
const summaries = new Map<string, BranchSummary>() const summaries = new Map<string, BranchSummary>()
for (const [index, item] of this._items.entries()) { for (const [index, item] of items.entries()) {
const { key, special, rootId } = this.describeBranchItem( const { key, special, rootId } = this.describeBranchItem(
item, item,
index, index,
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
} }
init(map: Map<number, ToggleableItemState>) { init(map: Map<number, ToggleableItemState>) {
this.temporarySelectionStates = map this._temporarySelectionStates.set(new Map(map))
this.apply() this.apply()
} }
apply() { apply() {
this.selectionStates.clear() this._selectionStates.set(new Map(this.temporarySelectionStates))
this.temporarySelectionStates.forEach((value, key) => { this._logicalOperator.set(this.temporaryLogicalOperator())
this.selectionStates.set(key, value) this._intersection.set(this.temporaryIntersection())
}) this._items.set(this.sortItems(this.items))
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
} }
reset(complete: boolean = false) { reset(complete: boolean = false) {
this.temporarySelectionStates.clear()
if (complete) { if (complete) {
this.selectionStates.clear() this._selectionStates.set(new Map())
this._temporarySelectionStates.set(new Map())
} else { } else {
this.selectionStates.forEach((value, key) => { this._temporarySelectionStates.set(new Map(this.selectionStates))
this.temporarySelectionStates.set(key, value)
})
} }
} }
@@ -7,7 +7,7 @@
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) { @if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -17,7 +17,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -27,7 +27,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -37,7 +37,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) { @if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -47,7 +47,7 @@
</button> </button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled"> <button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) { @if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -57,7 +57,7 @@
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled"> <button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
<div class="selected-icon me-1"> <div class="selected-icon me-1">
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) { @if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
<i-bs width="1em" height="1em" name="check"></i-bs> <i-bs width="1em" height="1em" name="check"></i-bs>
} }
</div> </div>
@@ -65,7 +65,8 @@
<ng-select <ng-select
name="user" name="user"
class="user-select small" class="user-select small"
[(ngModel)]="selectionModel.includeUsers" [ngModel]="selectionModel.includeUsers()"
(ngModelChange)="selectionModel.includeUsers.set($event)"
[disabled]="disabled" [disabled]="disabled"
[clearable]="false" [clearable]="false"
[items]="users()" [items]="users()"
@@ -78,10 +79,10 @@
</ng-select> </ng-select>
</div> </div>
</button> </button>
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { @if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0"> <div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
<div class="form-check form-switch w-100"> <div class="form-check form-switch w-100">
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled"> <input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label> <label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
</div> </div>
</div> </div>
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.setFilter(OwnerFilterType.NONE) component.setFilter(OwnerFilterType.NONE)
component.selectionModel.hideUnowned = true component.selectionModel.hideUnowned.set(true)
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
}) })
it('should describe concrete user filters honestly', () => { it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID = 1 component.selectionModel.userID.set(1)
expect(component.ownerFilterLabel).toEqual('Owned by user1') expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers = [1] component.selectionModel.excludeUsers.set([1])
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1') expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID = 1 component.selectionModel.userID.set(1)
expect(component.sharedByFilterLabel).toEqual('Shared by user1') expect(component.sharedByFilterLabel).toEqual('Shared by user1')
}) })
it('should describe concrete filters when usernames are unavailable', () => { it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
component.selectionModel.userID = 99 component.selectionModel.userID.set(99)
expect(component.ownerFilterLabel).toEqual('Owned by another user') expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
component.selectionModel.excludeUsers = [99] component.selectionModel.excludeUsers.set([99])
expect(component.ownerExclusionFilterLabel).toEqual( expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user' 'Not owned by another user'
) )
component.selectionModel.excludeUsers = [98, 99] component.selectionModel.excludeUsers.set([98, 99])
expect(component.ownerExclusionFilterLabel).toEqual( expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users' 'Not owned by selected users'
) )
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
component.selectionModel.userID = 99 component.selectionModel.userID.set(99)
expect(component.sharedByFilterLabel).toEqual('Shared by another user') expect(component.sharedByFilterLabel).toEqual('Shared by another user')
}) })
it('should retain relative labels for filters bound to the current user', () => { it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID = currentUserID component.selectionModel.userID.set(currentUserID)
expect(component.ownerFilterLabel).toEqual('My documents') expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me') expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID] component.selectionModel.excludeUsers.set([currentUserID])
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me') expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
}) })
it('should retain relative labels for inactive filter choices', () => { it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
expect(component.ownerFilterLabel).toEqual('My documents') expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me') expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
it('should support reset', () => { it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS) component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual( expect(component.selectionModel.ownerFilter()).not.toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.reset() component.reset()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE) expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
}) })
it('should toggle owner filter type when users selected', () => { it('should toggle owner filter type when users selected', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
// this would normally be done by select component // this would normally be done by select component
component.selectionModel.includeUsers = [12] component.selectionModel.includeUsers.set([12])
component.onUserSelect() component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS) expect(component.selectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS
)
// this would normally be done by select component // this would normally be done by select component
component.selectionModel.includeUsers = null component.selectionModel.includeUsers.set(null)
component.onUserSelect() component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE) expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
}) })
it('should emit a selection model depending on the type of owner filter set', () => { it('should emit a selection model depending on the type of owner filter set', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE const emitted = () => ({
excludeUsers: ownerFilterSetResult.excludeUsers(),
hideUnowned: ownerFilterSetResult.hideUnowned(),
includeUsers: ownerFilterSetResult.includeUsers(),
ownerFilter: ownerFilterSetResult.ownerFilter(),
userID: ownerFilterSetResult.userID(),
})
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
component.setFilter(OwnerFilterType.SELF) component.setFilter(OwnerFilterType.SELF)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.NOT_SELF) component.setFilter(OwnerFilterType.NOT_SELF)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [currentUserID], excludeUsers: [currentUserID],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.NONE) component.setFilter(OwnerFilterType.NONE)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.SHARED_BY_ME) component.setFilter(OwnerFilterType.SHARED_BY_ME)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
}) })
component.setFilter(OwnerFilterType.UNOWNED) component.setFilter(OwnerFilterType.UNOWNED)
expect(ownerFilterSetResult).toEqual({ expect(emitted()).toEqual({
excludeUsers: [], excludeUsers: [],
hideUnowned: false, hideUnowned: false,
includeUsers: [], includeUsers: [],
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export class PermissionsSelectionModel { export class PermissionsSelectionModel {
ownerFilter: OwnerFilterType readonly ownerFilter = signal(OwnerFilterType.NONE)
hideUnowned: boolean readonly hideUnowned = signal(false)
userID: number readonly userID = signal<number>(null)
includeUsers: number[] readonly includeUsers = signal<number[]>([])
excludeUsers: number[] readonly excludeUsers = signal<number[]>([])
clear() { clear() {
this.ownerFilter = OwnerFilterType.NONE this.ownerFilter.set(OwnerFilterType.NONE)
this.userID = null this.userID.set(null)
this.hideUnowned = false this.hideUnowned.set(false)
this.includeUsers = [] this.includeUsers.set([])
this.excludeUsers = [] this.excludeUsers.set([])
} }
} }
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
readonly users = signal<User[]>([]) readonly users = signal<User[]>([])
hideUnowned: boolean
get isActive(): boolean { get isActive(): boolean {
return ( return (
this.selectionModel.ownerFilter !== OwnerFilterType.NONE || this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
this.selectionModel.hideUnowned this.selectionModel.hideUnowned()
) )
} }
get ownerFilterLabel(): string { get ownerFilterLabel(): string {
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF || this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id this.selectionModel?.userID() === this.settingsService.currentUser()?.id
) { ) {
return $localize`My documents` return $localize`My documents`
} }
const username = this.getUsername(this.selectionModel?.userID) const username = this.getUsername(this.selectionModel?.userID())
return username return username
? $localize`Owned by ${username}` ? $localize`Owned by ${username}`
: $localize`Owned by another user` : $localize`Owned by another user`
} }
get ownerExclusionFilterLabel(): string { get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers ?? [] const excludedUsers = this.selectionModel?.excludeUsers() ?? []
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF || this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 && (excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id) excludedUsers[0] === this.settingsService.currentUser()?.id)
) { ) {
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
get sharedByFilterLabel(): string { get sharedByFilterLabel(): string {
if ( if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME || this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id this.selectionModel?.userID() === this.settingsService.currentUser()?.id
) { ) {
return $localize`Shared by me` return $localize`Shared by me`
} }
const username = this.getUsername(this.selectionModel?.userID) const username = this.getUsername(this.selectionModel?.userID())
return username return username
? $localize`Shared by ${username}` ? $localize`Shared by ${username}`
: $localize`Shared by another user` : $localize`Shared by another user`
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
setFilter(type: OwnerFilterType) { setFilter(type: OwnerFilterType) {
this.selectionModel.ownerFilter = type this.selectionModel.ownerFilter.set(type)
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) { if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.userID = this.settingsService.currentUser().id this.selectionModel.userID.set(this.settingsService.currentUser().id)
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { } else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID = null this.selectionModel.userID.set(null)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id] this.selectionModel.excludeUsers.set([
this.selectionModel.hideUnowned = false this.settingsService.currentUser().id,
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) { ])
this.selectionModel.userID = null this.selectionModel.hideUnowned.set(false)
this.selectionModel.includeUsers = [] } else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
this.selectionModel.excludeUsers = [] this.selectionModel.userID.set(null)
this.selectionModel.hideUnowned = false this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned.set(false)
} else if ( } else if (
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
) { ) {
this.selectionModel.userID = this.settingsService.currentUser()?.id this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) { } else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
this.selectionModel.userID = null this.selectionModel.userID.set(null)
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers.set([])
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers.set([])
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned.set(false)
} }
this.onChange() this.onChange()
} }
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} }
onUserSelect() { onUserSelect() {
if (this.selectionModel.includeUsers?.length) { this.selectionModel.ownerFilter.set(
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS this.selectionModel.includeUsers()?.length
} else { ? OwnerFilterType.OTHERS
this.selectionModel.ownerFilter = OwnerFilterType.NONE : OwnerFilterType.NONE
} )
this.onChange() this.onChange()
} }
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull() expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
}) })
it('should display built-in pdf viewer if not disabled', () => { it('should display built-in pdf viewer if not disabled', async () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
expect(component.useNativePdfViewer).toBeFalsy() expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges() await fixture.whenStable()
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull() expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
}) })
it('should display native pdf viewer if enabled', () => { it('should display native pdf viewer if enabled', () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true) settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
expect(component.useNativePdfViewer).toBeTruthy() expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull() expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
}) })
it('should reflect signal-backed document detail display settings', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
component.DocumentDetailFieldID.Correspondent,
])
expect(component.showThumbnailOverlay).toBeFalsy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
).toBeFalsy()
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
expect(component.showThumbnailOverlay).toBeTruthy()
expect(
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
).toBeFalsy()
})
it('should attempt to retrieve metadata', () => { it('should attempt to retrieve metadata', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata') const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true })) metadataSpy.mockReturnValue(of({ has_archive_version: true }))
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
it('should change preview element by render type', () => { it('should change preview element by render type', () => {
initNormally() initNormally()
component.document().archived_file_name = 'file.pdf' component.document.update((document) => ({
...document,
archived_file_name: 'file.pdf',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.PDF component.ContentRenderType.PDF
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('pdf-viewer-container')) fixture.debugElement.query(By.css('pdf-viewer-container'))
).not.toBeUndefined() ).not.toBeUndefined()
component.document().archived_file_name = undefined component.document.update((document) => ({
component.document().mime_type = 'text/plain' ...document,
archived_file_name: undefined,
mime_type: 'text/plain',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Text component.ContentRenderType.Text
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
fixture.debugElement.query(By.css('div.preview-sticky')) fixture.debugElement.query(By.css('div.preview-sticky'))
).not.toBeUndefined() ).not.toBeUndefined()
component.document().mime_type = 'image/jpeg' component.document.update((document) => ({
...document,
mime_type: 'image/jpeg',
}))
fixture.detectChanges() fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Image component.ContentRenderType.Image
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
expect( expect(
fixture.debugElement.query(By.css('.preview-sticky img')) fixture.debugElement.query(By.css('.preview-sticky img'))
).not.toBeUndefined() ).not.toBeUndefined()
;((component.document().mime_type = component.document.update((document) => ({
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), ...document,
fixture.detectChanges()) mime_type:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}))
fixture.detectChanges()
expect(component.archiveContentRenderType).toEqual( expect(component.archiveContentRenderType).toEqual(
component.ContentRenderType.Other component.ContentRenderType.Other
) )
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
private deviceDetectorService = inject(DeviceDetectorService) private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private readonly websocketStatusService = inject(WebsocketStatusService) private readonly websocketStatusService = inject(WebsocketStatusService)
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
)
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
SETTINGS_KEYS.AI_ENABLED
)
private readonly showThumbnailOverlaySetting =
this.settings.getSignal<boolean>(
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
)
private readonly hiddenFieldsSetting = this.settings.getSignal<
DocumentDetailFieldID[]
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
@ViewChild('inputTitle') @ViewChild('inputTitle')
titleInput: TextComponent titleInput: TextComponent
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
} }
get useNativePdfViewer(): boolean { get useNativePdfViewer(): boolean {
this.settings.trackChanges() return this.useNativePdfViewerSetting()
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
} }
get isMobile(): boolean { get isMobile(): boolean {
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
} }
get aiEnabled(): boolean { get aiEnabled(): boolean {
this.settings.trackChanges() return this.aiEnabledSetting()
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
} }
get archiveContentRenderType(): ContentRenderType { get archiveContentRenderType(): ContentRenderType {
this.settings.trackChanges()
const hasArchiveVersion = const hasArchiveVersion =
this.metadata()?.has_archive_version ?? this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name !!this.document()?.archived_file_name
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
} }
get originalContentRenderType(): ContentRenderType { get originalContentRenderType(): ContentRenderType {
this.settings.trackChanges()
return this.getRenderType( return this.getRenderType(
this.metadata()?.original_mime_type || this.document()?.mime_type this.metadata()?.original_mime_type || this.document()?.mime_type
) )
} }
get showThumbnailOverlay(): boolean { get showThumbnailOverlay(): boolean {
this.settings.trackChanges() return this.showThumbnailOverlaySetting()
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
} }
isFieldHidden(fieldId: DocumentDetailFieldID): boolean { isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
this.settings.trackChanges() return this.hiddenFieldsSetting().includes(fieldId)
return this.settings
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
.includes(fieldId)
} }
private getRenderType(mimeType: string): ContentRenderType { private getRenderType(mimeType: string): ContentRenderType {
@@ -121,6 +121,8 @@ export class DocumentListComponent
settingsService = inject(SettingsService) settingsService = inject(SettingsService)
private hotKeyService = inject(HotKeyService) private hotKeyService = inject(HotKeyService)
permissionService = inject(PermissionsService) permissionService = inject(PermissionsService)
private readonly notesEnabledSetting =
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
DisplayField = DisplayField DisplayField = DisplayField
DisplayMode = DisplayMode DisplayMode = DisplayMode
@@ -574,8 +576,7 @@ export class DocumentListComponent
} }
get notesEnabled(): boolean { get notesEnabled(): boolean {
this.settingsService.trackChanges() return this.notesEnabledSetting()
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
} }
resetFilters() { resetFilters() {
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
component.toggleTag(2) // coverage component.toggleTag(2) // coverage
}) })
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-filterable-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
// switching to a view with a tag filter
component.filterRules = [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '2',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).not.toBeNull()
// and back to a view without one
component.filterRules = [
{
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: '12',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
expect(
dropdown.nativeElement.querySelector('pngx-clearable-badge')
).toBeNull()
})
it('should ingest filter rules for has any tags', () => { it('should ingest filter rules for has any tags', () => {
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0) expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [ component.filterRules = [
@@ -1078,7 +1115,7 @@ describe('FilterEditorComponent', () => {
}) })
it('should ingest filter rules for owner', () => { it('should ingest filter rules for owner', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1087,15 +1124,38 @@ describe('FilterEditorComponent', () => {
value: '100', value: '100',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.SELF OwnerFilterType.SELF
) )
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy() expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
expect(component.permissionsSelectionModel.userID).toEqual(100) expect(component.permissionsSelectionModel.userID()).toEqual(100)
})
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
const dropdown = fixture.debugElement.query(
By.css('pngx-permissions-filter-dropdown')
)
const toggle = dropdown.nativeElement.querySelector('button')
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
// switching to a view with an owner filter
component.filterRules = [
{
rule_type: FILTER_OWNER,
value: '100',
},
]
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
// and back to a view without one
component.filterRules = []
fixture.detectChanges()
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
}) })
it('should ingest filter rules for owner is others', () => { it('should ingest filter rules for owner is others', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1104,14 +1164,14 @@ describe('FilterEditorComponent', () => {
value: '50', value: '50',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.OTHERS OwnerFilterType.OTHERS
) )
expect(component.permissionsSelectionModel.includeUsers).toContain(50) expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
}) })
it('should ingest filter rules for owner does not include others', () => { it('should ingest filter rules for owner does not include others', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1120,14 +1180,14 @@ describe('FilterEditorComponent', () => {
value: '50', value: '50',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NOT_SELF OwnerFilterType.NOT_SELF
) )
expect(component.permissionsSelectionModel.excludeUsers).toContain(50) expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
}) })
it('should ingest filter rules for owner is null', () => { it('should ingest filter rules for owner is null', () => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
) )
component.filterRules = [ component.filterRules = [
@@ -1136,10 +1196,10 @@ describe('FilterEditorComponent', () => {
value: 'true', value: 'true',
}, },
] ]
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
OwnerFilterType.UNOWNED OwnerFilterType.UNOWNED
) )
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy() expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
}) })
it('should ingest filter rules for owner is not null', () => { it('should ingest filter rules for owner is not null', () => {
@@ -1149,14 +1209,14 @@ describe('FilterEditorComponent', () => {
value: 'false', value: 'false',
}, },
] ]
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy() expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
value: '0', value: '0',
}, },
] ]
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy() expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
}) })
it('should ingest filter rules for shared by me', () => { it('should ingest filter rules for shared by me', () => {
@@ -1166,7 +1226,7 @@ describe('FilterEditorComponent', () => {
value: '2', value: '2',
}, },
] ]
expect(component.permissionsSelectionModel.userID).toEqual(2) expect(component.permissionsSelectionModel.userID()).toEqual(2)
}) })
// GET filterRules // GET filterRules
@@ -1932,7 +1992,10 @@ describe('FilterEditorComponent', () => {
value: '1', value: '1',
}, },
]) ])
component.permissionsSelectionModel.excludeUsers.push(2) component.permissionsSelectionModel.excludeUsers.update((users) => [
...users,
2,
])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -1982,8 +2045,11 @@ describe('FilterEditorComponent', () => {
// TODO: mock input in code // TODO: mock input in code
// userSelect.query(By.css('input')).nativeElement.value = '3' // userSelect.query(By.css('input')).nativeElement.value = '3'
// userSelect.triggerEventHandler('change') // userSelect.triggerEventHandler('change')
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
component.permissionsSelectionModel.includeUsers.push(3) component.permissionsSelectionModel.includeUsers.update((users) => [
...users,
3,
])
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -2003,7 +2069,7 @@ describe('FilterEditorComponent', () => {
ownerToggle.nativeElement.checked = true ownerToggle.nativeElement.checked = true
// ownerToggle.triggerEventHandler('change') // ownerToggle.triggerEventHandler('change')
// TODO: ngModel isn't doing this here // TODO: ngModel isn't doing this here
component.permissionsSelectionModel.hideUnowned = true component.permissionsSelectionModel.hideUnowned.set(true)
fixture.detectChanges() fixture.detectChanges()
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
@@ -735,38 +735,50 @@ export class FilterEditorComponent
this._textFilter = rule.value this._textFilter = rule.value
break break
case FILTER_OWNER: case FILTER_OWNER:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
this.permissionsSelectionModel.hideUnowned = false this.permissionsSelectionModel.hideUnowned.set(false)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10) this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
break break
case FILTER_OWNER_ANY: case FILTER_OWNER_ANY:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.includeUsers.push( this.permissionsSelectionModel.includeUsers.update((users) => [
parseInt(rule.value, 10) ...users,
) Number.parseInt(rule.value, 10),
])
break break
case FILTER_OWNER_DOES_NOT_INCLUDE: case FILTER_OWNER_DOES_NOT_INCLUDE:
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.NOT_SELF
)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.excludeUsers.push( this.permissionsSelectionModel.excludeUsers.update((users) => [
parseInt(rule.value, 10) ...users,
) Number.parseInt(rule.value, 10),
])
break break
case FILTER_SHARED_BY_USER: case FILTER_SHARED_BY_USER:
this.permissionsSelectionModel.ownerFilter = this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.SHARED_BY_ME OwnerFilterType.SHARED_BY_ME
)
if (rule.value) if (rule.value)
this.permissionsSelectionModel.userID = parseInt(rule.value, 10) this.permissionsSelectionModel.userID.set(
Number.parseInt(rule.value, 10)
)
break break
case FILTER_OWNER_ISNULL: case FILTER_OWNER_ISNULL:
if (rule.value === 'true' || rule.value === '1') { if (rule.value === 'true' || rule.value === '1') {
this.permissionsSelectionModel.hideUnowned = false this.permissionsSelectionModel.hideUnowned.set(false)
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED this.permissionsSelectionModel.ownerFilter.set(
OwnerFilterType.UNOWNED
)
} else { } else {
this.permissionsSelectionModel.hideUnowned = this.permissionsSelectionModel.hideUnowned.set(
rule.value === 'false' || rule.value === '0' rule.value === 'false' || rule.value === '0'
)
break break
} }
} }
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
}) })
} }
} }
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) { if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER, rule_type: FILTER_OWNER,
value: this.permissionsSelectionModel.userID.toString(), value: this.permissionsSelectionModel.userID().toString(),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE, rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
value: this.permissionsSelectionModel.excludeUsers?.join(','), value: this.permissionsSelectionModel.excludeUsers()?.join(','),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ANY, rule_type: FILTER_OWNER_ANY,
value: this.permissionsSelectionModel.includeUsers?.join(','), value: this.permissionsSelectionModel.includeUsers()?.join(','),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME this.permissionsSelectionModel.ownerFilter() ==
OwnerFilterType.SHARED_BY_ME
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_SHARED_BY_USER, rule_type: FILTER_SHARED_BY_USER,
value: this.permissionsSelectionModel.userID.toString(), value: this.permissionsSelectionModel.userID().toString(),
}) })
} else if ( } else if (
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
}) })
} }
if (this.permissionsSelectionModel.hideUnowned) { if (this.permissionsSelectionModel.hideUnowned()) {
filterRules.push({ filterRules.push({
rule_type: FILTER_OWNER_ISNULL, rule_type: FILTER_OWNER_ISNULL,
value: 'false', value: 'false',
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000') expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
}) })
it('provides stable signals that update when settings change', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const notesEnabled = settingsService.getSignal<boolean>(
SETTINGS_KEYS.NOTES_ENABLED
)
expect(notesEnabled()).toBeTruthy()
expect(
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
).toBe(notesEnabled)
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
expect(notesEnabled()).toBeFalsy()
})
it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush(ui_settings)
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
settingsService.initializeSettings().subscribe()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
)
req.flush({
...ui_settings,
settings: {
...ui_settings.settings,
app_title: 'Updated title',
},
})
expect(appTitle()).toBe('Updated title')
})
it('sets django cookie for languages', () => { it('sets django cookie for languages', () => {
httpTestingController httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`) .expectOne(`${environment.apiBaseUrl}ui_settings/`)
+16 -4
View File
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
import { import {
DOCUMENT, DOCUMENT,
EventEmitter, EventEmitter,
Signal,
computed,
inject, inject,
Injectable, Injectable,
LOCALE_ID, LOCALE_ID,
@@ -297,6 +299,7 @@ export class SettingsService {
private settings: Record<string, any> = {} private settings: Record<string, any> = {}
private readonly settingsVersion = signal(0) private readonly settingsVersion = signal(0)
private readonly settingSignals = new Map<string, Signal<unknown>>()
readonly currentUser = signal<User>(undefined) readonly currentUser = signal<User>(undefined)
public settingsSaved: EventEmitter<any> = new EventEmitter() public settingsSaved: EventEmitter<any> = new EventEmitter()
@@ -326,10 +329,6 @@ export class SettingsService {
return !UNSAFE_OBJECT_KEYS.has(key) return !UNSAFE_OBJECT_KEYS.has(key)
} }
public trackChanges(): void {
this.settingsVersion()
}
private assignSafeSettings(source: Record<string, any>) { private assignSafeSettings(source: Record<string, any>) {
if (!source || typeof source !== 'object' || Array.isArray(source)) { if (!source || typeof source !== 'object' || Array.isArray(source)) {
return return
@@ -339,6 +338,7 @@ export class SettingsService {
if (!this.isSafeObjectKey(key)) continue if (!this.isSafeObjectKey(key)) continue
this.settings[key] = source[key] this.settings[key] = source[key]
} }
this.settingsVersion.update((version) => version + 1)
} }
// this is called by the app initializer in app.module // this is called by the app initializer in app.module
@@ -594,6 +594,18 @@ export class SettingsService {
} }
} }
getSignal<T = any>(key: string): Signal<T> {
let settingSignal = this.settingSignals.get(key)
if (!settingSignal) {
settingSignal = computed(() => {
this.settingsVersion()
return this.get(key)
})
this.settingSignals.set(key, settingSignal)
}
return settingSignal as Signal<T>
}
set(key: string, value: any) { set(key: string, value: any) {
// parse key:key:key into nested object // parse key:key:key into nested object
let settingObj = this.settings let settingObj = this.settings