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",