diff --git a/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.html b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.html new file mode 100644 index 000000000..cfa40029b --- /dev/null +++ b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.html @@ -0,0 +1,22 @@ +
+ + +

+ {{ item().title }} + @if (item().isCurrentResource) { + + {{ 'myProjects.settings.viewOnlyLinkCurrentProject' | translate }} + + } +

+ + @if (item().disabled && !item().isCurrentResource) { + + } +
diff --git a/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.scss b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.scss new file mode 100644 index 000000000..cba08dc27 --- /dev/null +++ b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.scss @@ -0,0 +1,3 @@ +.disabled .item-title { + opacity: 0.5; +} diff --git a/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.spec.ts b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.spec.ts new file mode 100644 index 000000000..d0e56e9e7 --- /dev/null +++ b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ComponentCheckboxItemComponent } from './component-checkbox-item.component'; + +describe.skip('ComponentCheckboxItemComponent', () => { + let component: ComponentCheckboxItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ComponentCheckboxItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ComponentCheckboxItemComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.ts b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.ts new file mode 100644 index 000000000..3cd97d454 --- /dev/null +++ b/src/app/features/project/contributors/components/component-checkbox-item/component-checkbox-item.component.ts @@ -0,0 +1,26 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Checkbox } from 'primeng/checkbox'; + +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { InfoIconComponent } from '@osf/shared/components'; + +import { ViewOnlyLinkComponentItem } from '../../models'; + +@Component({ + selector: 'osf-component-checkbox-item', + imports: [Checkbox, FormsModule, InfoIconComponent, TranslatePipe], + templateUrl: './component-checkbox-item.component.html', + styleUrl: './component-checkbox-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ComponentCheckboxItemComponent { + item = input.required(); + checkboxChange = output(); + + onCheckboxChange(): void { + this.checkboxChange.emit(); + } +} diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html index 4aae1299e..ec1de2b40 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html @@ -33,42 +33,8 @@ } @else {
- @for (item of allComponents; track item.id) { -
- - -

- {{ item.title }} - @if (item.isCurrentResource) { - - {{ 'myProjects.settings.viewOnlyLinkCurrentProject' | translate }} - - } -

-
- } - @if (allComponents.length > 1) { -
- - -
+ @for (item of componentsList(); track item.id) { + }
} diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts index 79bff9fec..da23abcb4 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts @@ -6,16 +6,16 @@ import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal, WritableSignal } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { LoadingSpinnerComponent, TextInputComponent } from '@osf/shared/components'; import { InputLimits } from '@osf/shared/constants'; import { CustomValidators } from '@osf/shared/helpers'; -import { CurrentResourceSelectors, GetResourceChildren } from '@osf/shared/stores'; -import { ViewOnlyLinkChildren } from '@shared/models'; +import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores'; -import { ResourceInfoModel } from '../../models'; +import { ResourceInfoModel, ViewOnlyLinkComponentItem } from '../../models'; +import { ComponentCheckboxItemComponent } from '../component-checkbox-item/component-checkbox-item.component'; @Component({ selector: 'osf-create-view-link-dialog', @@ -27,6 +27,7 @@ import { ResourceInfoModel } from '../../models'; Checkbox, TextInputComponent, LoadingSpinnerComponent, + ComponentCheckboxItemComponent, ], templateUrl: './create-view-link-dialog.component.html', styleUrl: './create-view-link-dialog.component.scss', @@ -38,122 +39,126 @@ export class CreateViewLinkDialogComponent implements OnInit { readonly inputLimits = InputLimits; linkName = new FormControl('', { nonNullable: true, validators: [CustomValidators.requiredTrimmed()] }); - anonymous = signal(true); - selectedComponents = signal>({}); - components = select(CurrentResourceSelectors.getResourceChildren); - isLoading = select(CurrentResourceSelectors.isResourceChildrenLoading); - - actions = createDispatchMap({ getComponents: GetResourceChildren }); - - get currentResource() { - return this.config.data as ResourceInfoModel; - } - - get allComponents(): ViewOnlyLinkChildren[] { - const currentResourceData = this.currentResource; - const components = this.components(); - const result: ViewOnlyLinkChildren[] = []; + readonly components = select(CurrentResourceSelectors.getResourceWithChildren); + readonly isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); + readonly actions = createDispatchMap({ getComponents: GetResourceWithChildren }); - if (currentResourceData) { - result.push({ - id: currentResourceData.id, - title: currentResourceData.title, - isCurrentResource: true, - }); - } - - components.forEach((comp) => { - result.push({ - id: comp.id, - title: comp.title, - isCurrentResource: false, - }); - }); - - return result; - } + componentsList: WritableSignal = signal([]); constructor() { effect(() => { - const components = this.allComponents; - if (components.length) { - this.initializeSelection(); - } + const currentResource = this.config.data as ResourceInfoModel; + const components = this.components(); + + const items: ViewOnlyLinkComponentItem[] = components.map((item) => ({ + id: item.id, + title: item.title, + isCurrentResource: currentResource.id === item.id, + parentId: item.parentId, + checked: currentResource.id === item.id, + disabled: currentResource.id === item.id, + })); + + const updatedItems = items.map((item) => ({ + ...item, + disabled: item.isCurrentResource ? item.disabled : !this.isParentChecked(item, items), + })); + + this.componentsList.set(updatedItems); }); } ngOnInit(): void { - const projectId = this.currentResource.id; + const currentResource = this.config.data as ResourceInfoModel; + const { id, type } = currentResource; - if (projectId) { - this.actions.getComponents(projectId, this.currentResource.type); - } else { - this.initializeSelection(); + if (id) { + this.actions.getComponents(id, type); } } - private initializeSelection(): void { - const initialState: Record = {}; + onCheckboxChange(changedItem: ViewOnlyLinkComponentItem): void { + this.componentsList.update((items) => { + let updatedItems = [...items]; - this.allComponents.forEach((component) => { - initialState[component.id] = component.isCurrentResource; - }); + if (!changedItem.checked) { + updatedItems = this.uncheckChildren(changedItem.id, updatedItems); + } - this.selectedComponents.set(initialState); + return updatedItems.map((item) => ({ + ...item, + disabled: item.isCurrentResource ? item.disabled : !this.isParentChecked(item, updatedItems), + })); + }); } addLink(): void { if (this.linkName.invalid) return; - const selectedIds = Object.entries(this.selectedComponents()) - .filter(([, checked]) => checked) - .map(([id]) => id); + const currentResource = this.config.data as ResourceInfoModel; + const selectedIds = this.componentsList() + .filter((x) => x.checked) + .map((x) => x.id); - const rootProjectId = this.currentResource.id; - const rootProject = selectedIds.includes(rootProjectId) ? [{ id: rootProjectId, type: 'nodes' }] : []; + const data = this.buildLinkData(selectedIds, currentResource.id, this.linkName.value, this.anonymous()); + + this.dialogRef.close(data); + } + + private isParentChecked(item: ViewOnlyLinkComponentItem, items: ViewOnlyLinkComponentItem[]): boolean { + if (!item.parentId) { + return true; + } + + const parent = items.find((x) => x.id === item.parentId); + return parent?.checked ?? true; + } + + private uncheckChildren(parentId: string, items: ViewOnlyLinkComponentItem[]): ViewOnlyLinkComponentItem[] { + let updatedItems = items.map((item) => { + if (item.parentId === parentId) { + return { ...item, checked: false }; + } + return item; + }); + + const directChildren = updatedItems.filter((item) => item.parentId === parentId); + + for (const child of directChildren) { + updatedItems = this.uncheckChildren(child.id, updatedItems); + } + + return updatedItems; + } + + private buildLinkData( + selectedIds: string[], + rootProjectId: string, + linkName: string, + isAnonymous: boolean + ): Record { + const rootProject = selectedIds.includes(rootProjectId) ? [{ id: rootProjectId, type: 'nodes' }] : []; const relationshipComponents = selectedIds .filter((id) => id !== rootProjectId) .map((id) => ({ id, type: 'nodes' })); const data: Record = { attributes: { - name: this.linkName.value, - anonymous: this.anonymous(), + name: linkName, + anonymous: isAnonymous, }, nodes: rootProject, }; if (relationshipComponents.length) { data['relationships'] = { - nodes: { - data: relationshipComponents, - }, + nodes: { data: relationshipComponents }, }; } - this.dialogRef.close(data); - } - - onCheckboxToggle(id: string, checked: boolean): void { - this.selectedComponents.update((prev) => ({ ...prev, [id]: checked })); - } - - selectAllComponents(): void { - const allIds: Record = {}; - this.allComponents.forEach((component) => { - allIds[component.id] = true; - }); - this.selectedComponents.set(allIds); - } - - deselectAllComponents(): void { - const allIds: Record = {}; - this.allComponents.forEach((component) => { - allIds[component.id] = component.isCurrentResource; - }); - this.selectedComponents.set(allIds); + return data; } } diff --git a/src/app/features/project/contributors/components/index.ts b/src/app/features/project/contributors/components/index.ts index ba0ccc9b0..b607db949 100644 --- a/src/app/features/project/contributors/components/index.ts +++ b/src/app/features/project/contributors/components/index.ts @@ -1 +1,2 @@ +export { ComponentCheckboxItemComponent } from './component-checkbox-item/component-checkbox-item.component'; export { CreateViewLinkDialogComponent } from './create-view-link-dialog/create-view-link-dialog.component'; diff --git a/src/app/features/project/contributors/models/index.ts b/src/app/features/project/contributors/models/index.ts index 45133bf35..83d6f898d 100644 --- a/src/app/features/project/contributors/models/index.ts +++ b/src/app/features/project/contributors/models/index.ts @@ -1 +1,2 @@ export * from './resource-info.model'; +export * from './view-only-components.models'; diff --git a/src/app/features/project/contributors/models/view-only-components.models.ts b/src/app/features/project/contributors/models/view-only-components.models.ts new file mode 100644 index 000000000..23856c22c --- /dev/null +++ b/src/app/features/project/contributors/models/view-only-components.models.ts @@ -0,0 +1,8 @@ +export interface ViewOnlyLinkComponentItem { + id: string; + title: string; + isCurrentResource?: boolean; + disabled: boolean; + checked: boolean; + parentId?: string | null; +} diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index f6ca9b6d1..25f4f3f67 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -1,10 +1,18 @@ -import { BaseNodeDataJsonApi, BaseNodeModel } from '@osf/shared/models'; +import { BaseNodeDataJsonApi, BaseNodeModel, NodeShortInfoModel } from '@osf/shared/models'; export class BaseNodeMapper { static getNodesData(data: BaseNodeDataJsonApi[]): BaseNodeModel[] { return data.map((item) => this.getNodeData(item)); } + static getNodesWithChildren(data: BaseNodeDataJsonApi[]): NodeShortInfoModel[] { + return data.map((item) => ({ + id: item.id, + title: item.attributes.title, + parentId: item.relationships.parent?.data?.id, + })); + } + static getNodeData(data: BaseNodeDataJsonApi): BaseNodeModel { return { id: data.id, diff --git a/src/app/shared/models/nodes/base-node-data-json-api.model.ts b/src/app/shared/models/nodes/base-node-data-json-api.model.ts index e7cf252df..d02117230 100644 --- a/src/app/shared/models/nodes/base-node-data-json-api.model.ts +++ b/src/app/shared/models/nodes/base-node-data-json-api.model.ts @@ -1,9 +1,11 @@ import { BaseNodeAttributesJsonApi } from './base-node-attributes-json-api.model'; import { BaseNodeLinksJsonApi } from './base-node-links-json-api.model'; +import { BaseNodeRelationships } from './base-node-relationships-json-api.model'; export interface BaseNodeDataJsonApi { id: string; type: 'nodes'; attributes: BaseNodeAttributesJsonApi; links: BaseNodeLinksJsonApi; + relationships: BaseNodeRelationships; } diff --git a/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts b/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts index 6f885fb13..3cc72d45c 100644 --- a/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts +++ b/src/app/shared/models/nodes/base-node-relationships-json-api.model.ts @@ -46,5 +46,5 @@ export interface RelationshipWithLinks { related: RelationshipLink; self?: RelationshipLink; }; - data?: RelationshipData | RelationshipData[]; + data?: RelationshipData; } diff --git a/src/app/shared/models/nodes/index.ts b/src/app/shared/models/nodes/index.ts index 99031cb19..6f4b86606 100644 --- a/src/app/shared/models/nodes/index.ts +++ b/src/app/shared/models/nodes/index.ts @@ -4,4 +4,5 @@ export * from './base-node-data-json-api.model'; export * from './base-node-embeds-json-api.model'; export * from './base-node-links-json-api.model'; export * from './base-node-relationships-json-api.model'; +export * from './node-with-children.model'; export * from './nodes-json-api.model'; diff --git a/src/app/shared/models/nodes/node-with-children.model.ts b/src/app/shared/models/nodes/node-with-children.model.ts new file mode 100644 index 000000000..3fc1ed08d --- /dev/null +++ b/src/app/shared/models/nodes/node-with-children.model.ts @@ -0,0 +1,5 @@ +export interface NodeShortInfoModel { + id: string; + title: string; + parentId?: string; +} diff --git a/src/app/shared/models/view-only-links/view-only-link.model.ts b/src/app/shared/models/view-only-links/view-only-link.model.ts index f51b5fadf..85aa100b8 100644 --- a/src/app/shared/models/view-only-links/view-only-link.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link.model.ts @@ -20,12 +20,6 @@ export interface ViewOnlyLinkModel { anonymous: boolean; } -export interface ViewOnlyLinkChildren { - id: string; - title: string; - isCurrentResource: boolean; -} - export interface PaginatedViewOnlyLinksModel { items: ViewOnlyLinkModel[]; total: number; diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index a94a4c233..2ae735cef 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -8,6 +8,7 @@ import { BaseNodeModel, CurrentResource, GuidedResponseJsonApi, + NodeShortInfoModel, ResponseDataJsonApi, ResponseJsonApi, } from '@osf/shared/models'; @@ -64,11 +65,11 @@ export class ResourceGuidService { .pipe(map((response) => BaseNodeMapper.getNodeData(response.data))); } - getResourceChildren(resourceId: string, resourceType: ResourceType): Observable { + getResourceWithChildren(resourceId: string, resourceType: ResourceType): Observable { const resourcePath = this.urlMap.get(resourceType); return this.jsonApiService - .get>(`${environment.apiUrl}/${resourcePath}/${resourceId}/children/`) - .pipe(map((response) => BaseNodeMapper.getNodesData(response.data))); + .get>(`${environment.apiUrl}/${resourcePath}/?filter[root]=${resourceId}`) + .pipe(map((response) => BaseNodeMapper.getNodesWithChildren(response.data.reverse()))); } } diff --git a/src/app/shared/stores/current-resource/current-resource.actions.ts b/src/app/shared/stores/current-resource/current-resource.actions.ts index f79b9d078..3e1c6300b 100644 --- a/src/app/shared/stores/current-resource/current-resource.actions.ts +++ b/src/app/shared/stores/current-resource/current-resource.actions.ts @@ -13,8 +13,8 @@ export class GetResourceDetails { ) {} } -export class GetResourceChildren { - static readonly type = '[Current Resource] Get Resource Children'; +export class GetResourceWithChildren { + static readonly type = '[Current Resource] Get Resource With Children'; constructor( public resourceId: string, public resourceType: ResourceType diff --git a/src/app/shared/stores/current-resource/current-resource.model.ts b/src/app/shared/stores/current-resource/current-resource.model.ts index 26612c5ab..49fa2de55 100644 --- a/src/app/shared/stores/current-resource/current-resource.model.ts +++ b/src/app/shared/stores/current-resource/current-resource.model.ts @@ -1,10 +1,10 @@ -import { BaseNodeModel, CurrentResource } from '@osf/shared/models'; +import { BaseNodeModel, CurrentResource, NodeShortInfoModel } from '@osf/shared/models'; import { AsyncStateModel } from '@shared/models/store'; export interface CurrentResourceStateModel { currentResource: AsyncStateModel; resourceDetails: AsyncStateModel; - resourceChildren: AsyncStateModel; + resourceChildren: AsyncStateModel; } export const CURRENT_RESOURCE_DEFAULTS: CurrentResourceStateModel = { diff --git a/src/app/shared/stores/current-resource/current-resource.selectors.ts b/src/app/shared/stores/current-resource/current-resource.selectors.ts index e066052af..bb6ccae03 100644 --- a/src/app/shared/stores/current-resource/current-resource.selectors.ts +++ b/src/app/shared/stores/current-resource/current-resource.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { BaseNodeModel, CurrentResource } from '@osf/shared/models'; +import { BaseNodeModel, CurrentResource, NodeShortInfoModel } from '@osf/shared/models'; import { CurrentResourceStateModel } from './current-resource.model'; import { CurrentResourceState } from './current-resource.state'; @@ -17,7 +17,7 @@ export class CurrentResourceSelectors { } @Selector([CurrentResourceState]) - static getResourceChildren(state: CurrentResourceStateModel): BaseNodeModel[] { + static getResourceWithChildren(state: CurrentResourceStateModel): NodeShortInfoModel[] { return state.resourceChildren.data; } @@ -27,7 +27,7 @@ export class CurrentResourceSelectors { } @Selector([CurrentResourceState]) - static isResourceChildrenLoading(state: CurrentResourceStateModel): boolean { + static isResourceWithChildrenLoading(state: CurrentResourceStateModel): boolean { return state.resourceChildren.isLoading; } } diff --git a/src/app/shared/stores/current-resource/current-resource.state.ts b/src/app/shared/stores/current-resource/current-resource.state.ts index 3dd1c7e7b..b635d9b86 100644 --- a/src/app/shared/stores/current-resource/current-resource.state.ts +++ b/src/app/shared/stores/current-resource/current-resource.state.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers'; import { ResourceGuidService } from '@osf/shared/services'; -import { GetResource, GetResourceChildren, GetResourceDetails } from './current-resource.actions'; +import { GetResource, GetResourceDetails, GetResourceWithChildren } from './current-resource.actions'; import { CURRENT_RESOURCE_DEFAULTS, CurrentResourceStateModel } from './current-resource.model'; @State({ @@ -78,8 +78,8 @@ export class CurrentResourceState { ); } - @Action(GetResourceChildren) - getResourceChildren(ctx: StateContext, action: GetResourceChildren) { + @Action(GetResourceWithChildren) + getResourceWithChildren(ctx: StateContext, action: GetResourceWithChildren) { const state = ctx.getState(); ctx.patchState({ @@ -90,7 +90,7 @@ export class CurrentResourceState { }, }); - return this.resourceService.getResourceChildren(action.resourceId, action.resourceType).pipe( + return this.resourceService.getResourceWithChildren(action.resourceId, action.resourceType).pipe( tap((children) => { ctx.patchState({ resourceChildren: { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 82a9fc527..68955053d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -399,6 +399,7 @@ "typeLinkName": "Type link name", "whichComponentLink": "Which components would you like to associate with this link?", "anyonePrivateLink": "Anyone with the private link can view—but not edit—the components associated with the link.", + "parentsNeedToBeChecked": "Parents need to be checked", "accessRequests": "Access Requests", "accessRequestsText": "Allow users to request access to this project", "wiki": "Wiki",