Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,19 @@ const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = {
$schema: 'http://json-schema.org/draft-04/schema',
'@context': {} as never,
required: [],
properties: {},
properties: {
'@context': {
properties: {
field1: { enum: ['https://schema.metadatacenter.org/properties/test-field-uuid'] },
},
},
field1: {
'@type': 'https://schema.metadatacenter.org/core/TemplateField',
_valueConstraints: {
literals: [{ label: 'Option A' }, { label: 'Option B' }],
},
},
},
_ui: {
order: ['field1'],
propertyLabels: { field1: 'Field One' },
Expand Down Expand Up @@ -274,6 +286,8 @@ describe('CollectionsDiscoverComponent', () => {
expect(setExtraFilters.filters).toHaveLength(1);
expect(setExtraFilters.filters[0].key).toBe('field1');
expect(setExtraFilters.filters[0].label).toBe('Field One');
expect(setExtraFilters.filters[0].cedarPropertyIri).toBe('test-field-uuid');
expect(setExtraFilters.filters[0].options).toHaveLength(2);
});

it('should render GlobalSearchComponent when filters are initialized', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/app/features/metadata/models/cedar-metadata-template.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export interface CedarMetadataDataTemplateJsonApi {
};
}

export const CEDAR_TEMPLATE_FIELD_TYPE = 'https://schema.metadatacenter.org/core/TemplateField';
export const CEDAR_PROPERTIES_BASE_IRI = 'https://schema.metadatacenter.org/properties/';

export interface CedarTemplateField {
'@type': string;
_valueConstraints?: {
literals?: { label: string }[];
multipleChoice?: boolean;
requiredValue?: boolean;
};
}

export interface CedarTemplateContextSchema {
properties: Record<string, { enum?: string[] }>;
}

export interface CedarTemplate {
'@id': string;
'@type': string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@
(onLazyLoad)="loadMoreItems($event)"
>
<ng-template #item let-item>
<p class="text-base">{{ item.label }} ({{ item.cardSearchResultCount }})</p>
<p class="text-base">
{{ item.label }}
@if (item.cardSearchResultCount !== null) {
({{ item.cardSearchResultCount }})
}
</p>
</ng-template>
</p-multiSelect>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,40 @@ describe('SearchFiltersComponent', () => {
expect(visibleFilters.length).toBe(3);
});

it('should show CEDAR filters that have options but no resultCount', () => {
const cedarFilter: DiscoverableFilter = {
key: 'School Type',
label: 'School Type',
operator: FilterOperatorOption.AnyOf,
cedarPropertyIri: 'uuid-school-type',
options: [
{ label: 'High School', value: 'High School', cardSearchResultCount: null },
{ label: 'Middle School', value: 'Middle School', cardSearchResultCount: null },
],
};

fixture.componentRef.setInput('filters', [cedarFilter]);
fixture.detectChanges();

expect(component.visibleFilters()).toHaveLength(1);
expect(component.visibleFilters()[0].key).toBe('School Type');
});

it('should still hide a filter with resultCount 0 and no options', () => {
const zeroCountFilter: DiscoverableFilter = {
key: 'emptyFilter',
label: 'Empty',
operator: FilterOperatorOption.AnyOf,
resultCount: 0,
options: [],
};

fixture.componentRef.setInput('filters', [zeroCountFilter]);
fixture.detectChanges();

expect(component.visibleFilters()).toHaveLength(0);
});

it('should compute splitFilters correctly', () => {
fixture.componentRef.setInput('filters', mockFilters);
fixture.detectChanges();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class SearchFiltersComponent {
return this.filters().filter((filter) => {
if (!filter || !filter.key) return false;

return Boolean((filter.resultCount && filter.resultCount > 0) || (filter.options && filter.options.length > 0));
return filter.resultCount === undefined || filter.resultCount > 0 || (filter.options?.length ?? 0) > 0;
});
});

Expand Down
144 changes: 144 additions & 0 deletions src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { CEDAR_TEMPLATE_FIELD_TYPE, CedarTemplate } from '@osf/features/metadata/models';
import { FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model';

import { CedarTemplateFilterMapper } from './cedar-template-filter.mapper';

const CEDAR_BASE = 'https://schema.metadatacenter.org/properties/';

function makeTemplate(overrides: Partial<CedarTemplate> = {}): CedarTemplate {
return {
'@id': 'https://repo.metadatacenter.org/templates/test',
'@type': 'https://schema.metadatacenter.org/core/Template',
type: 'object',
title: 'Test',
description: '',
$schema: 'http://json-schema.org/draft-04/schema',
'@context': {} as never,
required: [],
properties: {
'@context': {
properties: {
'School Type': { enum: [`${CEDAR_BASE}uuid-school-type`] },
'Study Design': { enum: [`${CEDAR_BASE}uuid-study-design`] },
About: { enum: [`${CEDAR_BASE}uuid-about`] },
},
},
'School Type': {
'@type': CEDAR_TEMPLATE_FIELD_TYPE,
_valueConstraints: {
literals: [{ label: 'High School' }, { label: 'Middle School' }],
},
},
'Study Design': {
'@type': CEDAR_TEMPLATE_FIELD_TYPE,
_valueConstraints: {
literals: [{ label: 'Intervention' }, { label: 'Correlational' }],
},
},
About: {
'@type': 'https://schema.metadatacenter.org/core/StaticTemplateField',
_ui: { inputType: 'richtext' },
},
},
_ui: {
order: ['School Type', 'Study Design', 'About'],
propertyLabels: { 'School Type': 'School Type', 'Study Design': 'Study Design', About: 'About' },
propertyDescriptions: {},
},
...overrides,
};
}

describe('CedarTemplateFilterMapper', () => {
describe('fromTemplate', () => {
it('should only include TemplateField entries with literals', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());

expect(filters).toHaveLength(2);
expect(filters.map((f) => f.key)).toEqual(['School Type', 'Study Design']);
});

it('should skip StaticTemplateField entries', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());

expect(filters.some((f) => f.key === 'About')).toBe(false);
});

it('should pre-populate options from _valueConstraints.literals', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());
const schoolType = filters.find((f) => f.key === 'School Type')!;

expect(schoolType.options).toHaveLength(2);
expect(schoolType.options![0]).toEqual({
label: 'High School',
value: 'High School',
cardSearchResultCount: null,
});
expect(schoolType.options![1]).toEqual({
label: 'Middle School',
value: 'Middle School',
cardSearchResultCount: null,
});
});

it('should set cardSearchResultCount to null for all options', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());

filters.forEach((f) => {
f.options?.forEach((opt) => {
expect(opt.cardSearchResultCount).toBeNull();
});
});
});

it('should set cedarPropertyIri to the UUID from the context IRI', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());
const schoolType = filters.find((f) => f.key === 'School Type')!;
const studyDesign = filters.find((f) => f.key === 'Study Design')!;

expect(schoolType.cedarPropertyIri).toBe('uuid-school-type');
expect(studyDesign.cedarPropertyIri).toBe('uuid-study-design');
});

it('should set operator to AnyOf', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());

filters.forEach((f) => {
expect(f.operator).toBe(FilterOperatorOption.AnyOf);
});
});

it('should use propertyLabels for the filter label', () => {
const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate());

expect(filters[0].label).toBe('School Type');
expect(filters[1].label).toBe('Study Design');
});

it('should skip fields with no literals', () => {
const template = makeTemplate();
(template.properties['School Type'] as any)._valueConstraints = { literals: [] };

const filters = CedarTemplateFilterMapper.fromTemplate(template);

expect(filters.some((f) => f.key === 'School Type')).toBe(false);
});

it('should skip fields with an empty label', () => {
const template = makeTemplate();
template._ui.propertyLabels['School Type'] = ' ';

const filters = CedarTemplateFilterMapper.fromTemplate(template);

expect(filters.some((f) => f.key === 'School Type')).toBe(false);
});

it('should return an empty array when no filterable fields exist', () => {
const template = makeTemplate({
_ui: { order: ['About'], propertyLabels: { About: 'About' }, propertyDescriptions: {} },
});

expect(CedarTemplateFilterMapper.fromTemplate(template)).toEqual([]);
});
});
});
47 changes: 39 additions & 8 deletions src/app/shared/mappers/filters/cedar-template-filter.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
import { CedarTemplate } from '@osf/features/metadata/models';
import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model';
import {
CEDAR_PROPERTIES_BASE_IRI,
CEDAR_TEMPLATE_FIELD_TYPE,
CedarTemplate,
CedarTemplateContextSchema,
CedarTemplateField,
} from '@osf/features/metadata/models';
import {
DiscoverableFilter,
FilterOperatorOption,
FilterOption,
} from '@osf/shared/models/search/discoverable-filter.model';

export class CedarTemplateFilterMapper {
static fromTemplate(template: CedarTemplate): DiscoverableFilter[] {
const { order, propertyLabels } = template._ui;
const contextProperties = (template.properties['@context'] as CedarTemplateContextSchema)?.properties ?? {};

return order
.filter((key) => propertyLabels[key]?.trim())
.map((key) => ({
key,
label: propertyLabels[key],
operator: FilterOperatorOption.AnyOf,
}));
.filter((key) => {
const field = template.properties[key] as CedarTemplateField | undefined;
return (
propertyLabels[key]?.trim() &&
field?.['@type'] === CEDAR_TEMPLATE_FIELD_TYPE &&
(field._valueConstraints?.literals?.length ?? 0) > 0
);
})
.map((key) => {
const field = template.properties[key] as CedarTemplateField;
const iri = contextProperties[key]?.enum?.[0];
const cedarPropertyIri = iri?.replace(CEDAR_PROPERTIES_BASE_IRI, '');
const options: FilterOption[] = (field._valueConstraints!.literals ?? []).map((literal) => ({
label: literal.label,
value: literal.label,
cardSearchResultCount: null,
}));

return {
key,
label: propertyLabels[key],
operator: FilterOperatorOption.AnyOf,
options,
cedarPropertyIri,
};
});
}
}
3 changes: 2 additions & 1 deletion src/app/shared/models/search/discoverable-filter.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface DiscoverableFilter {
isLoaded?: boolean;
isPaginationLoading?: boolean;
isSearchLoading?: boolean;
cedarPropertyIri?: string;
}

export enum FilterOperatorOption {
Expand All @@ -22,5 +23,5 @@ export enum FilterOperatorOption {
export interface FilterOption {
label: string;
value: string;
cardSearchResultCount: number;
cardSearchResultCount: number | null;
}
Loading
Loading