diff --git a/.env b/.env new file mode 100644 index 0000000..624c7b1 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PROJECT_UPDATE_URL_BASE=https://ecosystem.khronos.org/update/ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 491bd5d..6eafe68 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -26,9 +26,3 @@ jobs: run: yarn - name: Build run: yarn build - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 - if: ${{ github.ref == 'refs/heads/main' }} - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./build diff --git a/package.json b/package.json index 1ac877d..dd9711a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "project-explorer", "version": "0.1.0", "private": true, - "homepage": "https://github.khronos.org/glTF-Project-Explorer", + "homepage": "https://ecosystem.khronos.org/wp-content/plugins/glTF-project-explorer/", "dependencies": { "@types/dompurify": "^2.3.3", "@types/flexsearch": "^0.7.2", @@ -18,7 +18,6 @@ "marked": "^4.0.10", "react": "^17.0.2", "react-dom": "^17.0.2", - "react-github-fork-ribbon": "^0.6.0", "react-redux": "^7.2.6", "redux": "^4.1.2", "redux-devtools-extension": "^2.13.9", diff --git a/public/data/glTF-projects-data.json b/public/data/glTF-projects-data.json index 7843f0e..28ac09a 100644 --- a/public/data/glTF-projects-data.json +++ b/public/data/glTF-projects-data.json @@ -1372,8 +1372,7 @@ "link" : "https://github.com/BabylonJS/Babylon.js/tree/master/packages/dev/loaders/src/glTF", "task" : [ "import", "view" ], "type" : [ "engine" ], - "inputs" : [ "glTF 2.0" ], - "tags" : [ "Staff Picks" ] + "inputs" : [ "glTF 2.0" ] }, { "name" : "OSG.JS glTF loader", "description" : "WebGL engine", @@ -1541,7 +1540,7 @@ "name" : "Microsoft.glTF.CPP", "description" : "A C++ library for serializing and deserializing gltf/glb files.", "link" : "https://github.com/microsoft/glTF-SDK", - "language" : [ "2.0" ], + "language" : [ "C++" ], "inputs" : [ "glTF 2.0" ] }, { "name" : "Qt 3D", @@ -2108,7 +2107,7 @@ "link" : "https://prototechsolutions.com/3d-products/inventor/gltf-exporter/", "task" : [ "export" ], "type" : [ "plugin", "sdk" ], - "language" : [ "c#" ], + "language" : [ "C#" ], "outputs" : [ "glTF 2.0" ] }, { "name" : "ProtoTech glTF Exporter For Fusion 360", @@ -2116,7 +2115,7 @@ "link" : "https://prototechsolutions.com/3d-products/fusion-360/gltf-exporter/", "task" : [ "export" ], "type" : [ "plugin", "sdk" ], - "language" : [ "c#" ], + "language" : [ "C#" ], "outputs" : [ "glTF 2.0" ] }, { "name" : "ProtoTech glTF Exporter For Revit", @@ -2124,7 +2123,7 @@ "link" : "https://prototechsolutions.com/3d-products/revit/gltf-exporter/", "task" : [ "export" ], "type" : [ "plugin", "sdk" ], - "language" : [ "c#" ], + "language" : [ "C#" ], "outputs" : [ "glTF 2.0" ] }, { "name" : "ProtoTech glTF Exporter For SolidEdge", @@ -2132,7 +2131,7 @@ "link" : "https://prototechsolutions.com/3d-products/solidedge/gltf-exporter/", "task" : [ "export" ], "type" : [ "plugin", "sdk" ], - "language" : [ "c#" ], + "language" : [ "C#" ], "outputs" : [ "glTF 2.0" ] }, { "name" : "ProtoTech glTF Exporter For SolidWorks", @@ -2140,7 +2139,7 @@ "link" : "https://prototechsolutions.com/3d-products/solidworks/gltf-exporter/", "task" : [ "export" ], "type" : [ "plugin", "sdk" ], - "language" : [ "c#" ], + "language" : [ "C#" ], "outputs" : [ "glTF 2.0" ] }, { "name" : "F3D", @@ -2148,7 +2147,7 @@ "link" : "https://f3d-app.github.io/f3d/", "task" : [ "view" ], "type" : [ "application" ], - "language" : [ "c++" ], + "language" : [ "C++" ], "inputs" : [ "glTF 2.0" ] }, { "name": "Cesium for Unreal", diff --git a/public/data/glTF-projects-metadata.json b/public/data/glTF-projects-metadata.json new file mode 100644 index 0000000..ca281c1 --- /dev/null +++ b/public/data/glTF-projects-metadata.json @@ -0,0 +1,45 @@ +{ + "name": { + "type": "string", + "isArray": false + }, + "link": { + "type": "url", + "isArray": false + }, + "description": { + "type": "markdown", + "isArray": false + }, + "tags": { + "task": { + "type": "string", + "isArray": true + }, + "type": { + "type": "string", + "isArray": true + }, + "license": { + "type": "string", + "isArray": true + }, + "language": { + "type": "string", + "isArray": true + }, + "inputs": { + "type": "string", + "isArray": true + }, + "outputs": { + "type": "string", + "isArray": true + }, + "tags": { + "type": "string", + "isArray": true + } + }, + "filterTags": ["tags", "task", "type", "language", "license"] +} diff --git a/public/github-mark-white.svg b/public/github-mark-white.svg new file mode 100644 index 0000000..d5e6491 --- /dev/null +++ b/public/github-mark-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c8875e2..7236a99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ import ProjectList from "./components/ProjectList"; import FilterBar from "./components/FilterBar"; import SearchBar from "./components/SearchBar"; -import GitHubForkRibbon from "react-github-fork-ribbon"; import { useWindowDimensions } from "./utils/WindowWidthHook"; const App: React.FC = () => { @@ -18,7 +17,20 @@ const App: React.FC = () => { src={`${process.env.PUBLIC_URL}/glTF_RGB_June16.svg`} alt="glTF" /> -

Project Explorer

+

Project Explorer

+ + Submit New Project + + + GitHub +
@@ -29,13 +41,6 @@ const App: React.FC = () => {
- - Fork us on GitHub - ); }; diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index dceb760..226d9a1 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -6,7 +6,6 @@ import { updateSelectedFilters } from "../store/filters/Actions"; import FilterBarOptions from "./FilterBarOptions"; import "./FilterBar.css"; import FilterBarSelected from "./FilterBarSelected"; -import { ProjectFilterProperties } from "../interfaces/IProjectInfo"; interface IFilterBarOwnProps { allowCollapse: boolean; @@ -44,7 +43,7 @@ const FilterBar: React.FC = (props) => { const handleFilterAddClick = useCallback( (filter: IFilter) => (_: React.MouseEvent) => { selectedFilters.add(filter); - const options = filterOptions.get(filter.propertyName); + const options = filterOptions.get(filter.tagName); if (options) { const index = options.indexOf(filter); options.splice(index, 1); @@ -57,7 +56,7 @@ const FilterBar: React.FC = (props) => { const handleFilterRemoveClick = useCallback( (filter: IFilter) => (_: React.MouseEvent) => { selectedFilters.delete(filter); - const options = filterOptions.get(filter.propertyName); + const options = filterOptions.get(filter.tagName); if (options) { options.push(filter); options.sort((f0, f1) => f0.value.localeCompare(f1.value)); @@ -70,7 +69,7 @@ const FilterBar: React.FC = (props) => { const handleFilterResetClick = useCallback( (_) => { for (const filter of selectedFilters) { - const options = filterOptions.get(filter.propertyName); + const options = filterOptions.get(filter.tagName); if (options) { options.push(filter); } @@ -106,7 +105,7 @@ const FilterBar: React.FC = (props) => { diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index d647ec4..906f38d 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -1,4 +1,4 @@ -import { IProjectInfo, ProjectProperties } from "../interfaces/IProjectInfo"; +import { IProjectInfo } from "../interfaces/IProjectInfo"; import ProjectDetailList from "./ProjectDetailList"; import ProjectCardHeader from "./ProjectCardHeader"; import "./ProjectCard.css"; @@ -13,7 +13,7 @@ interface IProjectCardProps { const ProjectCard: React.FC = (props) => { const { project } = props; return ( -
+
{project.description && ( @@ -23,13 +23,13 @@ const ProjectCard: React.FC = (props) => { /> )}
- {Object.keys(project.properties).map((propName) => { - const items = project.properties[propName]; + {Object.keys(project.tags).map((propName) => { + const items = project.tags[propName]; return ( shouldShowSection(items) && ( ) diff --git a/src/components/ProjectCardHeader.tsx b/src/components/ProjectCardHeader.tsx index af868cc..8ff2381 100644 --- a/src/components/ProjectCardHeader.tsx +++ b/src/components/ProjectCardHeader.tsx @@ -7,10 +7,22 @@ interface IProjectCardHeader { const ProjectCardHeader: React.FC = (props) => { const { project } = props; + const updateUrlBase = process.env.PROJECT_UPDATE_URL_BASE; + return ( -

- {project.link ? {project.name} : project.name} -

+
+

+ {project.link ? {project.name} : project.name} +

+ {project.key && project.key.trim() !== "" && updateUrlBase && ( + + Update Project + + )} +
); }; diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx index cad3e45..4341bb2 100644 --- a/src/components/ProjectList.tsx +++ b/src/components/ProjectList.tsx @@ -9,7 +9,6 @@ interface IProjectListProps { const ProjectList: React.FC = (props) => { const { projects } = props; - return ( <> {projects && projects.map((p) => )} diff --git a/src/index.css b/src/index.css index 428442d..b52de4d 100644 --- a/src/index.css +++ b/src/index.css @@ -51,13 +51,13 @@ a:focus { color: theme("colors.link-focus"); } -a[href*="http"] { +a.external[href*="http"] { background: url(./external-link-icon.svg) no-repeat 100% 0; background-size: 1rem 1rem; padding-right: 1.25rem; } -h1 a[href*="http"] { +h1 a.external[href*="http"] { background-size: 1.5rem 1.5rem; padding-right: 2rem; } diff --git a/src/interfaces/IAppState.ts b/src/interfaces/IAppState.ts index a0fadd4..e68d5d0 100644 --- a/src/interfaces/IAppState.ts +++ b/src/interfaces/IAppState.ts @@ -1,6 +1,7 @@ import { Document } from "flexsearch"; import { IFilter } from "./IFilter"; import { IProjectInfo } from "./IProjectInfo"; +import { IProjectsMetadata } from "./IProjectsMetadata"; /** * This is the document layout used for indexing the Projects. @@ -24,12 +25,14 @@ export interface IProjectsState { isFetchingProjects: boolean; values: IProjectInfo[]; searchIndex?: Document; + isFetchingProjectsMetadata: boolean; + projectsMetadata?: IProjectsMetadata } /** * The part of the state that summarizes the filter options and selection. * - * @property filterOptions A record that maps ProjectProperties keys to the + * @property filterOptions A record that maps projectsMetadata tags to the * list of filters that are available for this project property. These are * one filter object for each possible value of this property. * @property titleSubstring The string that was entered in the free-text search box diff --git a/src/interfaces/IFilter.ts b/src/interfaces/IFilter.ts index ec48327..50c9eb8 100644 --- a/src/interfaces/IFilter.ts +++ b/src/interfaces/IFilter.ts @@ -1,13 +1,11 @@ export interface IFilter { - propertyName: string; + tagName: string; value: string; - // selected: boolean; } -export function createNewFilter(propertyName: string, value: string): IFilter { +export function createNewFilter(tagName: string, value: string): IFilter { return { - propertyName, + tagName, value, - // selected: false }; } diff --git a/src/interfaces/IProjectInfo.ts b/src/interfaces/IProjectInfo.ts index 313450d..e8c2cb4 100644 --- a/src/interfaces/IProjectInfo.ts +++ b/src/interfaces/IProjectInfo.ts @@ -1,68 +1,23 @@ -// Aliases as these are the four possible types of filters. -type ProjectTask = string; -type ProjectType = string; -type ProjectLanguage = string; -type ProjectLicense = string; -type ProjectTag = string; - -/** - * TODO_GENERALIZATION: - * - * The IProjectInfo COULD already be an entity description generated - * using https://github.com/typeorm/typeorm, but - * - we don't know yet how persistence will be implemented - * - the entities have to be dynamic in terms of the "columns" - * But instances of the IProjectInfo could probably be mapped to - * actual TypeORM entities in the persistence layer, while - * the UI solely operates on the IProjectInfo. - * - * The ProjectProperties and ProjectFilterProperties are the - * point of configuration for different project types. For now - * they are hard-coded here. - */ - -/** - * The map of keys for the IProjectInfo#properties map, - * mapped to a human-readable form that is shown in the UI - */ -export const ProjectProperties = new Map([ - ["task", "Task"], - ["license", "License"], - ["type", "Type"], - ["language", "Language"], - ["inputs", "Inputs"], - ["outputs", "Outputs"], - ["tags", "Tags"], -]); - -/** - * A map defining the ProjectProperties by which the projects - * can be filtered, which is a subset of the ProjectProperties - */ -export const ProjectFilterProperties = new Map([ - ["tags", "Tags"], - ["task", "Task"], - ["type", "Type"], - ["language", "Language"], - ["license", "License"], -]); - export interface IProjectInfo { id: number; + key: string; name: string; description?: string; link?: string; + tags: Record; +} - properties: Record; - - // TODO_GENERALIZATION These are supposed to be removed. - // Right now, they are "migrated" and written into the - // "properties" record in the DataService.ts - task?: ProjectTask[]; - license?: ProjectLicense[]; - type?: ProjectType[]; - language?: ProjectLanguage[]; +export interface ILegacyProjectInfo { + id: number; + key: string; + name: string; + description?: string; + link?: string; + task?: string[]; + license?: string[]; + type?: string[]; + language?: string[]; inputs?: string[]; outputs?: string[]; - tags?: ProjectTag[]; + tags?: string[]; } diff --git a/src/interfaces/IProjectsMetadata.ts b/src/interfaces/IProjectsMetadata.ts new file mode 100644 index 0000000..141ab53 --- /dev/null +++ b/src/interfaces/IProjectsMetadata.ts @@ -0,0 +1,13 @@ + +export interface IValueType { + type: "string" | "url" | "date" | "markdown" | "number"; + isArray: boolean; +} + +export interface IProjectsMetadata { + name: IValueType; + description: IValueType; + link: IValueType; + tags: Record; + filterTags: string[] +} diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 23d16b7..7845e66 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,49 +1,87 @@ import { IProjectInfo } from "../interfaces/IProjectInfo"; +import { ILegacyProjectInfo } from "../interfaces/IProjectInfo"; +import { IProjectsMetadata } from "../interfaces/IProjectsMetadata"; // Despite the data being a static file, we don't pull it in using Webpack so // we can change to using a restful service in the future. -function fetchProjectsInternal(): Promise { - return fetch(`${process.env.PUBLIC_URL}/data/glTF-projects-data.json`) - .then((r) => r.json()) - .catch((error) => console.error(`Error fetching data. Reason: ${error}`)); +async function fetchProjectsInternal(): Promise { + try { + const response = await fetch( + `${process.env.PUBLIC_URL}/data/glTF-projects-data.json` + ); + return await response.json(); + } catch (error) { + console.error(`Error fetching data. Reason: ${error}`); + return []; + } } /** - * Converts the given project info into the new structure, where the - * properties are stored in the `properties` record. + * Converts the given legacy project info into the new structure, where the + * properties are stored in the `tags` record. * - * TODO_GENERALIZATION This is a preliminary state. In the future, - * the input JSON could be modified, or be represented by a structure - * like "ILegacyProjectInfo" that is converted into the IProjectInfo - * here. - * - * @param p The project info - * @returns The given project info + * @param p The legacy project info + * @returns The new project info */ -function migrateProjectInfoForGeneralization(p: IProjectInfo): IProjectInfo { - p.properties = {}; - p.properties["task"] = p.task ? p.task : []; - p.properties["license"] = p.license ? p.license : []; - p.properties["type"] = p.type ? p.type : []; - p.properties["language"] = p.language ? p.language : []; - p.properties["inputs"] = p.inputs ? p.inputs : []; - p.properties["outputs"] = p.outputs ? p.outputs : []; - p.properties["tags"] = p.tags ? p.tags : []; - return p; +function migrateProjectInfoForGeneralization( + p: ILegacyProjectInfo +): IProjectInfo { + const result: IProjectInfo = { + id: p.id, + key: p.key, + name: p.name, + description: p.description, + link: p.link, + tags: {}, + }; + if (p.task) { + result.tags["task"] = p.task; + } + if (p.license) { + result.tags["license"] = p.license; + } + if (p.type) { + result.tags["type"] = p.type; + } + if (p.language) { + result.tags["language"] = p.language; + } + if (p.inputs) { + result.tags["inputs"] = p.inputs; + } + if (p.outputs) { + result.tags["outputs"] = p.outputs; + } + if (p.tags) { + result.tags["tags"] = p.tags; + } + return result; } -export function fetchProjects(): Promise { - return new Promise(async (resolve) => { - const projects = await fetchProjectsInternal(); +export async function fetchProjects(): Promise { + const legacyProjects = await fetchProjectsInternal(); - // This work gives us a stable key. Eventually when this is database - // backed the ID will be provided by the DB and this can be removed. - let id = 0; - let resultProjects = projects.map((p) => { - p.id = id++; - return p; - }); - resultProjects = resultProjects.map(migrateProjectInfoForGeneralization); - resolve(resultProjects); + // This work gives us a stable key. Eventually when this is database + // backed the ID will be provided by the DB and this can be removed. + let id = 0; + let legacyProjectsWithId = legacyProjects.map((p) => { + p.id = id++; + return p; }); + const resultProjects = legacyProjectsWithId.map( + migrateProjectInfoForGeneralization + ); + return resultProjects; +} + +export async function fetchProjectsMetadata(): Promise { + try { + const response = await fetch( + `${process.env.PUBLIC_URL}/data/glTF-projects-metadata.json` + ); + return response.json(); + } catch (error) { + console.error(`Error fetching data. Reason: ${error}`); + return undefined; + } } diff --git a/src/store/Sagas.ts b/src/store/Sagas.ts index 1a33b99..8c35560 100644 --- a/src/store/Sagas.ts +++ b/src/store/Sagas.ts @@ -1,16 +1,18 @@ import { fork, put } from "redux-saga/effects"; -import { updateProjects as watchForProjectUpdates } from "./projects/Sagas"; +import { updateProjects, updateProjectsMetadata } from "./projects/Sagas"; import { ProjectsActionTypes } from "./projects/Types"; import { watchForFilterRecalculate } from "./filters/Sagas"; import { watchForResultUpdates } from "./results/Sagas"; function* startup() { + yield put({ type: ProjectsActionTypes.PROJECTS_METADATA_REQUESTED }); yield put({ type: ProjectsActionTypes.PROJECTS_REQUESTED }); } export default function* root() { yield fork(startup); - yield fork(watchForProjectUpdates); + yield fork(updateProjectsMetadata); + yield fork(updateProjects); yield fork(watchForFilterRecalculate); yield fork(watchForResultUpdates); } diff --git a/src/store/filters/Interfaces.ts b/src/store/filters/Interfaces.ts index 6f2d2e3..ca21e9b 100644 --- a/src/store/filters/Interfaces.ts +++ b/src/store/filters/Interfaces.ts @@ -11,9 +11,8 @@ export type FiltersActions = * only called ONCE (?). * * The `filterOptions` are in fact the options for the filters. It is a - * map that maps ProjectProperties keys (i.e. the keys of the - * IProjectInfo#properties map) to the filters that have been created - * for the respective property for ALL available projects. + * map that maps projectsMetadata tags to the filters that have been + * created for the respective tag for ALL available projects. * * @property type The type * @property filterOptions The filter options diff --git a/src/store/filters/Sagas.ts b/src/store/filters/Sagas.ts index a7eb455..51f9df4 100644 --- a/src/store/filters/Sagas.ts +++ b/src/store/filters/Sagas.ts @@ -1,36 +1,31 @@ import { takeEvery, select, put } from "redux-saga/effects"; import { createNewFilter, IFilter } from "../../interfaces/IFilter"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; -import { ProjectFilterProperties } from "../../interfaces/IProjectInfo"; import { ProjectsActionTypes } from "../projects/Types"; import * as projectSelectors from "../projects/Selectors"; import * as actions from "./Actions"; const DEFAULT_FULL_TEXT_TITLE_VALUE = ""; -function calculateFiltersForProperty( - projects: IProjectInfo[], - propertyName: string -) { +function calculateFiltersForTag(projects: IProjectInfo[], tagName: string) { const tasks = [ - ...new Set( - projects.flatMap((p) => p.properties[propertyName]).filter((x) => x) - ), + ...new Set(projects.flatMap((p) => p.tags[tagName]).filter((x) => x)), ] as string[]; - return tasks.map((t) => createNewFilter(propertyName, t)); + return tasks.map((t) => createNewFilter(tagName, t)); } function* calculateFilters(): any { const projects = yield select(projectSelectors.getProjects); + const projectsMetadata = yield select(projectSelectors.getProjectsMetadata); const filterOptions = new Map(); - const filterProperties = Array.from(ProjectFilterProperties.keys()); - for (const propertyName of filterProperties) { - const filters = calculateFiltersForProperty(projects, propertyName); + const filterTags = projectsMetadata.filterTags; + for (const tagName of filterTags) { + const filters = calculateFiltersForTag(projects, tagName); const sortedFilters = filters.sort((f0, f1) => f0.value.localeCompare(f1.value) ); - filterOptions.set(propertyName, sortedFilters); + filterOptions.set(tagName, sortedFilters); } yield put( actions.updateFilters(filterOptions, DEFAULT_FULL_TEXT_TITLE_VALUE) diff --git a/src/store/projects/Actions.ts b/src/store/projects/Actions.ts index 663f4d7..af65760 100644 --- a/src/store/projects/Actions.ts +++ b/src/store/projects/Actions.ts @@ -2,7 +2,8 @@ import { Document } from "flexsearch"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; import { IProjectSearchDoc } from "../../interfaces/IAppState"; import { ProjectsActionTypes } from "./Types"; -import { ISuccessfulProjectsAction, IFailedProjectsAction } from "./Interfaces"; +import { ISuccessfulProjectsAction, IFailedProjectsAction, ISuccessfulProjectsMetadataAction, IFailedProjectsMetadataAction } from "./Interfaces"; +import { IProjectsMetadata } from "../../interfaces/IProjectsMetadata"; export function successfulProjects( projects: IProjectInfo[], @@ -21,3 +22,19 @@ export function failedProjects(error: Error): IFailedProjectsAction { error: error, }; } + +export function successfulProjectsMetadata( + projectsMetadata: IProjectsMetadata +): ISuccessfulProjectsMetadataAction { + return { + type: ProjectsActionTypes.PROJECTS_METADATA_SUCCESSFUL, + projectsMetadata: projectsMetadata, + }; +} + +export function failedProjectsMetadata(error: Error): IFailedProjectsMetadataAction { + return { + type: ProjectsActionTypes.PROJECTS_METADATA_FAILED, + error: error, + }; +} diff --git a/src/store/projects/Interfaces.ts b/src/store/projects/Interfaces.ts index 2cd7aaa..91bd826 100644 --- a/src/store/projects/Interfaces.ts +++ b/src/store/projects/Interfaces.ts @@ -2,23 +2,42 @@ import { Document } from "flexsearch"; import { IProjectSearchDoc } from "../../interfaces/IAppState"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; import { ProjectsActionTypes } from "./Types"; +import { IProjectsMetadata } from "../../interfaces/IProjectsMetadata"; export type ProjectsActions = | IRequestProjectsAction | ISuccessfulProjectsAction - | IFailedProjectsAction; + | IFailedProjectsAction + | IRequestProjectsMetadataAction + | ISuccessfulProjectsMetadataAction + | IFailedProjectsMetadataAction; -interface IRequestProjectsAction { - readonly type: ProjectsActionTypes.PROJECTS_REQUESTED; -} + interface IRequestProjectsAction { + readonly type: ProjectsActionTypes.PROJECTS_REQUESTED; + } + + export interface ISuccessfulProjectsAction { + readonly type: ProjectsActionTypes.PROJECTS_SUCCESSFUL; + readonly projects: IProjectInfo[]; + readonly searchIndex: Document; + } + + export interface IFailedProjectsAction { + readonly type: ProjectsActionTypes.PROJECTS_FAILED; + readonly error: Error; + } -export interface ISuccessfulProjectsAction { - readonly type: ProjectsActionTypes.PROJECTS_SUCCESSFUL; - readonly projects: IProjectInfo[]; - readonly searchIndex: Document; -} - -export interface IFailedProjectsAction { - readonly type: ProjectsActionTypes.PROJECTS_FAILED; - readonly error: Error; -} + interface IRequestProjectsMetadataAction { + readonly type: ProjectsActionTypes.PROJECTS_METADATA_REQUESTED; + } + + export interface ISuccessfulProjectsMetadataAction { + readonly type: ProjectsActionTypes.PROJECTS_METADATA_SUCCESSFUL; + readonly projectsMetadata: IProjectsMetadata; + } + + export interface IFailedProjectsMetadataAction { + readonly type: ProjectsActionTypes.PROJECTS_METADATA_FAILED; + readonly error: Error; + } + \ No newline at end of file diff --git a/src/store/projects/Reducers.ts b/src/store/projects/Reducers.ts index 5f594dd..ed731e1 100644 --- a/src/store/projects/Reducers.ts +++ b/src/store/projects/Reducers.ts @@ -6,6 +6,7 @@ export function projects( state: IProjectsState = { isFetchingProjects: false, values: [], + isFetchingProjectsMetadata: false, }, action: ProjectsActions ) { @@ -21,6 +22,17 @@ export function projects( }; case ProjectsActionTypes.PROJECTS_FAILED: return { ...state, isFetchingProjects: false }; + case ProjectsActionTypes.PROJECTS_METADATA_REQUESTED: + return { ...state, isFetchingProjectsMetadata: true }; + case ProjectsActionTypes.PROJECTS_METADATA_SUCCESSFUL: + return { + ...state, + projectsMetadata: action.projectsMetadata, + isFetchingProjectsMetadata: false, + }; + case ProjectsActionTypes.PROJECTS_METADATA_FAILED: + return { ...state, isFetchingProjectsMetadata: false }; + default: return state; } diff --git a/src/store/projects/Sagas.ts b/src/store/projects/Sagas.ts index fd35951..39354cc 100644 --- a/src/store/projects/Sagas.ts +++ b/src/store/projects/Sagas.ts @@ -2,9 +2,11 @@ import * as actions from "./Actions"; import { call, put, takeEvery } from "redux-saga/effects"; import { Document } from "flexsearch"; import { fetchProjects } from "../../services/DataService"; +import { fetchProjectsMetadata } from "../../services/DataService"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; import { IProjectSearchDoc } from "../../interfaces/IAppState"; import { ProjectsActionTypes } from "./Types"; +import { IProjectsMetadata } from "../../interfaces/IProjectsMetadata"; function* retrieveProjects() { try { @@ -38,3 +40,16 @@ function* retrieveProjects() { export function* updateProjects() { yield takeEvery(ProjectsActionTypes.PROJECTS_REQUESTED, retrieveProjects); } + +function* retrieveProjectsMetadata() { + try { + const projectsMetadata: IProjectsMetadata = yield call(fetchProjectsMetadata); + yield put(actions.successfulProjectsMetadata(projectsMetadata)); + } catch (err) { + yield put(actions.failedProjectsMetadata(err as Error)); + } +} + +export function* updateProjectsMetadata() { + yield takeEvery(ProjectsActionTypes.PROJECTS_METADATA_REQUESTED, retrieveProjectsMetadata); +} diff --git a/src/store/projects/Selectors.ts b/src/store/projects/Selectors.ts index c3ccbfc..c930bd3 100644 --- a/src/store/projects/Selectors.ts +++ b/src/store/projects/Selectors.ts @@ -1,5 +1,5 @@ import { IAppState } from "../../interfaces/IAppState"; export const getProjects = (state: IAppState) => state.projects.values; - export const getSearchIndex = (state: IAppState) => state.projects.searchIndex; +export const getProjectsMetadata = (state: IAppState) => state.projects.projectsMetadata; diff --git a/src/store/projects/Types.ts b/src/store/projects/Types.ts index c86f5f0..c7d3074 100644 --- a/src/store/projects/Types.ts +++ b/src/store/projects/Types.ts @@ -2,4 +2,9 @@ export enum ProjectsActionTypes { PROJECTS_REQUESTED = "PROJECTS_REQUESTED", PROJECTS_SUCCESSFUL = "PROJECTS_SUCCESSFUL", PROJECTS_FAILED = "PROJECTS_FAILED", + + PROJECTS_METADATA_REQUESTED = "PROJECTS_METADATA_REQUESTED", + PROJECTS_METADATA_SUCCESSFUL = "PROJECTS_METADATA_SUCCESSFUL", + PROJECTS_METADATA_FAILED = "PROJECTS_METADATA_FAILED", + } diff --git a/src/store/results/Sagas.ts b/src/store/results/Sagas.ts index 516e863..f1ca5fa 100644 --- a/src/store/results/Sagas.ts +++ b/src/store/results/Sagas.ts @@ -6,12 +6,12 @@ import { Document } from "flexsearch"; import { FilterActionTypes } from "../filters/Types"; import { IFilter } from "../../interfaces/IFilter"; import { IProjectInfo } from "../../interfaces/IProjectInfo"; -import { ProjectFilterProperties } from "../../interfaces/IProjectInfo"; +import { IProjectsMetadata } from "../../interfaces/IProjectsMetadata"; import { IProjectSearchDoc } from "../../interfaces/IAppState"; // Tags in these groups will be pulled to the top of the list. // Priority is given to tags with lower index values. -const tagPriority = ["Khronos Official", "Staff Picks"]; +const tagPriority = ["Khronos Official"]; const UNTAGGED_KEY = "UNTAGGED"; type ResultsBuckets = { [key: string]: IProjectInfo[] }; @@ -21,6 +21,7 @@ interface IGroupedFilters { } function applyTagFilters( + projectsMetadata: IProjectsMetadata, projects: IProjectInfo[], selectedFilters: Set ): IProjectInfo[] { @@ -28,14 +29,14 @@ function applyTagFilters( return projects; } - const filterPropertyNames = Array.from(ProjectFilterProperties.keys()); + const filterTagNames = projectsMetadata.filterTags; const groupedFilters = Array.from(selectedFilters).reduce( (acc, curr) => { - if (!acc[curr.propertyName]) { - acc[curr.propertyName] = []; + if (!acc[curr.tagName]) { + acc[curr.tagName] = []; } - acc[curr.propertyName].push(curr); + acc[curr.tagName].push(curr); return acc; }, @@ -45,15 +46,22 @@ function applyTagFilters( return projects.filter((project) => { let match = false; - for (const propertyName of filterPropertyNames) { - if (!groupedFilters[propertyName]) continue; + for (const tagName of filterTagNames) { + if (!groupedFilters[tagName]) continue; - match = groupedFilters[propertyName].some((filter) => { - if (project.properties[propertyName]) { + match = groupedFilters[tagName].some((filter) => { + if (project.tags[tagName]) { // Within the dimension we do an OR. - return project.properties[propertyName]!.some( - (v) => v === filter.value - ); + + // TODO_V2: Here, the type will have to be checked. + // Right now, this assumes that the values are + // string arrays or single values + const tagValue = project.tags[tagName]; + if (Array.isArray(tagValue)) { + return tagValue.some((v: any) => v === filter.value); + } else { + return tagValue === filter.value; + } } return false; @@ -96,12 +104,13 @@ function sortResults(projects: IProjectInfo[]): IProjectInfo[] { buckets[UNTAGGED_KEY] = []; for (const project of projects) { - if (!project.tags) { + const tags = project.tags["tags"]; + if (!tags) { buckets[UNTAGGED_KEY].push(project); continue; } - for (const tag of project.tags) { + for (const tag of tags) { if (buckets[tag]) { buckets[tag].push(project); } else { @@ -110,25 +119,29 @@ function sortResults(projects: IProjectInfo[]): IProjectInfo[] { } } - return Object.entries(buckets).flatMap(([_, projects]) => { + const result = Object.entries(buckets).flatMap(([_, projects]) => { return projects.sort((a, b) => a.name.localeCompare(b.name)); }); + + return result; } function* applyFilters() { - const [projects, searchIndex, selectedFilters, query]: [ + const [projectsMetadata, projects, searchIndex, selectedFilters, query]: [ + IProjectsMetadata, IProjectInfo[], Document | undefined, Set, string ] = yield all([ + select(projectSelectors.getProjectsMetadata), select(projectSelectors.getProjects), select(projectSelectors.getSearchIndex), select(filterSelectors.getSelectedFilters), select(filterSelectors.getTitleSubstring), ]); - const filteredResults = applyTagFilters(projects, selectedFilters); + const filteredResults = applyTagFilters(projectsMetadata, projects, selectedFilters); const searchedResults = applyIndexedSearch( filteredResults, searchIndex, diff --git a/src/utils/FilterHelpers.ts b/src/utils/FilterHelpers.ts index 96703ba..ed00f22 100644 --- a/src/utils/FilterHelpers.ts +++ b/src/utils/FilterHelpers.ts @@ -1,13 +1,13 @@ import { IFilter } from "../interfaces/IFilter"; const NUM_FILTER_COLORS = 8; // As defined in tailwind.config.css -const filterPropertyNames: string[] = []; +const filterTagNames: string[] = []; export const determineClassName = (filter: IFilter) => { - let index = filterPropertyNames.indexOf(filter.propertyName); + let index = filterTagNames.indexOf(filter.tagName); if (index < 0) { - index = filterPropertyNames.length; - filterPropertyNames.push(filter.propertyName); + index = filterTagNames.length; + filterTagNames.push(filter.tagName); } index %= NUM_FILTER_COLORS; return `filter-${index}`; diff --git a/yarn.lock b/yarn.lock index 1fac294..57e0e22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8628,11 +8628,6 @@ react-error-overlay@^6.0.10: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.10.tgz#0fe26db4fa85d9dbb8624729580e90e7159a59a6" integrity sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA== -react-github-fork-ribbon@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/react-github-fork-ribbon/-/react-github-fork-ribbon-0.6.0.tgz#8b7454fec5bd5481af7fdf6d0fc8aa7653373301" - integrity sha512-lmobEGwg1k/JGMJMbiWNh3dd+5HUtePVqUu1LPr5L65MiK6mnn7ZgkGpFYXLijPxO9hD7x+f9qUrEzeazQdx2g== - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"