mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-03-28 17:42:40 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ae9a6ba2 | ||
|
|
c24cc2165f | ||
|
|
f177a61d4f | ||
|
|
f13b9e8ea5 | ||
|
|
c04dd79778 | ||
|
|
00273a8c86 | ||
|
|
231ef804dc | ||
|
|
44dc22c792 | ||
|
|
830fc550bd | ||
|
|
6c7567a15c | ||
|
|
be9797cc77 | ||
|
|
a78d9c5906 | ||
|
|
8f4fe45d9e | ||
|
|
79157feed5 | ||
|
|
09888d5657 | ||
|
|
a0371167bc | ||
|
|
0dc2623ded | ||
|
|
7d81a83672 | ||
|
|
17150a53bc | ||
|
|
fbf1990045 | ||
|
|
c5881d7905 | ||
|
|
b83f7beb30 | ||
|
|
d2c5a850ef | ||
|
|
5b334db3c9 | ||
|
|
69aa7a7648 | ||
|
|
97e4313d93 | ||
|
|
3f7acd7e26 | ||
|
|
7a2b45f031 | ||
|
|
8ed64691c4 | ||
|
|
844971cdd9 | ||
|
|
cf376cdda7 | ||
|
|
1a6acce77f | ||
|
|
e9258e1a7f | ||
|
|
61b461661d | ||
|
|
a12a3852d2 | ||
|
|
0c94b33606 | ||
|
|
23f3fbfadf | ||
|
|
434e87b074 | ||
|
|
081fb7ce2e | ||
|
|
bd43ba8a6d | ||
|
|
bd2860e225 | ||
|
|
daeaf0a59d | ||
|
|
9cc7ea09d6 | ||
|
|
4b6f283bf3 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,4 +4,8 @@ node_modules
|
||||
*.vsix
|
||||
.DS_Store
|
||||
dist
|
||||
todo.md
|
||||
todo.md
|
||||
|
||||
e2e/storage
|
||||
e2e/extensions
|
||||
e2e/sample
|
||||
|
||||
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
@@ -29,20 +29,6 @@
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Extension Tests",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--extensionTestsPath=${workspaceFolder}/out/test"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/out/test/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: test-compile"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -10,8 +10,4 @@
|
||||
"typescript.tsc.autoDetect": "off",
|
||||
"eliostruyf.writingstyleguide.terms.isDisabled": true,
|
||||
"eliostruyf.writingstyleguide.biasFree.isDisabled": true,
|
||||
"exportall.config.folderListener": [
|
||||
"/src/pagesView/state/atom",
|
||||
"/src/pagesView/state/selectors"
|
||||
]
|
||||
}
|
||||
@@ -26,4 +26,6 @@ dist/*.html
|
||||
frontmatter.json
|
||||
.frontmatter
|
||||
webpack
|
||||
README.beta.md
|
||||
README.beta.md
|
||||
e2e
|
||||
storage
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Change Log
|
||||
|
||||
## [8.0.0] - 2022-07-11 - [Release notes](https://beta.frontmatter.codes/updates/v8.0.0)
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- [#291](https://github.com/estruyf/vscode-front-matter/issues/291): New taxonomy dashboard for managing tags, categories, and custom taxonomies
|
||||
|
||||
### 🎨 Enhancements
|
||||
|
||||
- Ignore the SEO `keywords` field for missing content type field
|
||||
- [#307](https://github.com/estruyf/vscode-front-matter/issues/307): New `list` field which allows to create a list of items
|
||||
- [#345](https://github.com/estruyf/vscode-front-matter/issues/345): Media dashboard UI improvements to visualize the content and public folders
|
||||
- [#349](https://github.com/estruyf/vscode-front-matter/issues/349): New `slug` field which allows you to manage the slug of your post from the Front Matter panel
|
||||
- [#350](https://github.com/estruyf/vscode-front-matter/issues/350): New `previewPath` property for the `frontMatter.content.pageFolders` setting. This allows you to specify a section prefix for all content created in that directory.
|
||||
- [#351](https://github.com/estruyf/vscode-front-matter/issues/351): New `template` property for content types which allows you to combine templates and content types for content creation
|
||||
- [#353](https://github.com/estruyf/vscode-front-matter/issues/353): Add the default content type on project initialization
|
||||
- [#366](https://github.com/estruyf/vscode-front-matter/issues/366): Better support for using block fields in another block field
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
- [#348](https://github.com/estruyf/vscode-front-matter/issues/348): Fix media dashboard breadcrumb when multiple page folders are in use
|
||||
- [#356](https://github.com/estruyf/vscode-front-matter/issues/356): Re-introduce the `labelField` to the `frontMatter.taxonomy.fieldGroups` setting
|
||||
- [#358](https://github.com/estruyf/vscode-front-matter/issues/358): Fix for relative path of the public folder
|
||||
- [#364](https://github.com/estruyf/vscode-front-matter/issues/364): Honour file ending rules in data files
|
||||
- [#365](https://github.com/estruyf/vscode-front-matter/issues/365): Show spinner on the initial load of the content dashboard
|
||||
|
||||
## [7.3.4] - 2022-06-13
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
- [#354](https://github.com/estruyf/vscode-front-matter/issues/354): Fix Windows file path parsing for inserting media files
|
||||
|
||||
## [7.3.3] - 2022-06-11
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
- Card render when taxonomy is not an array value
|
||||
- Double pages on contents dashboard
|
||||
|
||||
## [7.3.2] - 2022-06-01
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
@@ -163,14 +163,6 @@
|
||||
border: 1px solid rgba(0, 0, 0, .9);
|
||||
}
|
||||
|
||||
.article__tags__input input {
|
||||
border: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
}
|
||||
|
||||
.article__tags__input input:disabled {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.article__tags__input.freeform {
|
||||
position: relative;
|
||||
outline: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
@@ -182,17 +174,6 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.article__tags__input button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.article__tags ul {
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
|
||||
80
e2e/src/command.test.ts
Normal file
80
e2e/src/command.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { By, VSBrowser, EditorView, WebView, Workbench, Notification, StatusBar, NotificationType } from "vscode-extension-tester";
|
||||
import { expect } from "chai";
|
||||
import { sleep } from "./utils";
|
||||
import { join } from "path";
|
||||
|
||||
// https://github.com/microsoft/vscode-java-dependency/blob/4256fa6adcaff5ec24dbdbb8d9a516fad21431c5/test/ui/index.ts
|
||||
// https://github.com/microsoft/vscode-java-dependency/blob/4256fa6adcaff5ec24dbdbb8d9a516fad21431c5/test/ui/command.test.ts
|
||||
|
||||
describe("Initialization testing", function() {
|
||||
this.timeout(2 * 60 * 1000 /*ms*/);
|
||||
|
||||
let workbench: Workbench;
|
||||
let view: WebView;
|
||||
|
||||
before(async function() {
|
||||
await VSBrowser.instance.openResources(join(__dirname, '../sample'));
|
||||
await sleep(3000);
|
||||
workbench = new Workbench();
|
||||
|
||||
await workbench.executeCommand("frontMatter.dashboard");
|
||||
await sleep(3000);
|
||||
|
||||
await new EditorView().openEditor(`FrontMatter Dashboard`);
|
||||
|
||||
view = new WebView();
|
||||
await view.switchToFrame();
|
||||
});
|
||||
|
||||
it("1. Open welcome dashboard", async function() {
|
||||
const element = await view.findWebElement(By.css('h1'));
|
||||
|
||||
const title = await element.getText();
|
||||
|
||||
expect(title).has.string(`Front Matter`);
|
||||
});
|
||||
|
||||
it("2. Initialize project", async function() {
|
||||
const btn = await view.findWebElement(By.css('[data-test="welcome-init"] button'));
|
||||
expect(btn).to.exist;
|
||||
|
||||
await btn.click();
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
await VSBrowser.instance.driver.wait(() => {
|
||||
return notificationExists(workbench, 'Front Matter:');
|
||||
}, 2000) as Notification;
|
||||
|
||||
const notifications = await workbench.getNotifications();
|
||||
|
||||
let notification!: Notification;
|
||||
for (const not of notifications) {
|
||||
console.log(not);
|
||||
|
||||
// const message = await not.get;
|
||||
// console.log(message);
|
||||
// if (message.includes('Front Matter:')) {
|
||||
// notification = not;
|
||||
// }
|
||||
}
|
||||
|
||||
expect(await notification.getMessage()).has.string(`Project initialized successfully.`);
|
||||
});
|
||||
|
||||
it("3. Check if project file is created", async function() {});
|
||||
});
|
||||
|
||||
|
||||
async function notificationExists(workbench: Workbench, text: string): Promise<Notification | undefined> {
|
||||
const notifications = await (await (new StatusBar()).openNotificationsCenter()).getNotifications(NotificationType.Info);
|
||||
console.log(`Notifications:`, notifications.length);
|
||||
|
||||
for (const notification of notifications) {
|
||||
const message = await notification.getMessage();
|
||||
console.log(message)
|
||||
if (message.indexOf(text) >= 0) {
|
||||
return notification;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
e2e/src/runTests.ts
Normal file
33
e2e/src/runTests.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as path from 'path';
|
||||
import * as semver from "semver";
|
||||
import { ExTester, ReleaseQuality } from "vscode-extension-tester";
|
||||
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const vsCodeVersion: semver.SemVer = new semver.SemVer(`1.66.0`);
|
||||
const version = vsCodeVersion.version;
|
||||
|
||||
const storageFolder = path.join(__dirname, "..", "storage");
|
||||
const extFolder = path.join(__dirname, "..", "extensions");
|
||||
|
||||
try {
|
||||
const testPath = path.join(__dirname, "command.test.js");
|
||||
|
||||
const exTester = new ExTester(storageFolder, ReleaseQuality.Stable, extFolder);
|
||||
await exTester.downloadCode(version);
|
||||
await exTester.installVsix();
|
||||
// await exTester.installFromMarketplace("eliostruyf.vscode-front-matter");
|
||||
await exTester.downloadChromeDriver(version);
|
||||
// await exTester.setupRequirements({vscodeVersion: version});
|
||||
const result = await exTester.runTests(testPath, {
|
||||
vscodeVersion: version
|
||||
});
|
||||
|
||||
process.exit(result);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
1
e2e/src/utils/index.ts
Normal file
1
e2e/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './sleep';
|
||||
3
e2e/src/utils/sleep.ts
Normal file
3
e2e/src/utils/sleep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function sleep(time: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
12978
package-lock.json
generated
12978
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
54
package.json
54
package.json
@@ -3,7 +3,7 @@
|
||||
"displayName": "Front Matter",
|
||||
"description": "Front Matter is a CMS that runs within Visual Studio Code. It gives you the power and control of a full-blown CMS while also providing you the flexibility and speed of the static site generator of your choice like: Hugo, Jekyll, Hexo, NextJs, Gatsby, and many more...",
|
||||
"icon": "assets/frontmatter-teal-128x128.png",
|
||||
"version": "7.3.2",
|
||||
"version": "8.0.0",
|
||||
"preview": false,
|
||||
"publisher": "eliostruyf",
|
||||
"galleryBanner": {
|
||||
@@ -22,6 +22,9 @@
|
||||
"href": "https://www.buymeacoffee.com/zMeFRy9"
|
||||
}
|
||||
],
|
||||
"sponsor": {
|
||||
"url": "https://github.com/sponsors/estruyf"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.63.0"
|
||||
},
|
||||
@@ -183,6 +186,14 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Exclude sub-directories"
|
||||
},
|
||||
"previewPath": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
],
|
||||
"default": null,
|
||||
"description": "Defines a custom preview path for the folder."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -806,7 +817,9 @@
|
||||
"fields",
|
||||
"json",
|
||||
"block",
|
||||
"dataFile"
|
||||
"list",
|
||||
"dataFile",
|
||||
"slug"
|
||||
],
|
||||
"description": "Define the type of field"
|
||||
},
|
||||
@@ -943,6 +956,11 @@
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Specify the property name that will be used to show the value for the field"
|
||||
},
|
||||
"editable": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Specify if the field is editable"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -1065,6 +1083,11 @@
|
||||
],
|
||||
"default": null,
|
||||
"description": "Defines a custom preview path for the content type."
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "An optional template that can be used for creating new content."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -1168,6 +1191,10 @@
|
||||
"type": "string",
|
||||
"description": "The name of the field group"
|
||||
},
|
||||
"labelField": {
|
||||
"type": "string",
|
||||
"description": "The name of the field to be used as display value"
|
||||
},
|
||||
"fields": {
|
||||
"$ref": "#contenttypefield"
|
||||
}
|
||||
@@ -1504,6 +1531,15 @@
|
||||
"light": "/assets/icons/frontmatter-small-light.svg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "frontMatter.dashboard.taxonomy",
|
||||
"title": "Open taxonomy dashboard",
|
||||
"category": "Front matter",
|
||||
"icon": {
|
||||
"dark": "/assets/icons/frontmatter-small-dark.svg",
|
||||
"light": "/assets/icons/frontmatter-small-light.svg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "frontMatter.markup.orderedlist",
|
||||
"title": "Ordered list",
|
||||
@@ -1819,7 +1855,7 @@
|
||||
{
|
||||
"command": "frontMatter.dashboard",
|
||||
"group": "navigation@2",
|
||||
"when": "view == frontMatter.explorer"
|
||||
"when": "view == frontMatter.explorer || view == explorer"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1890,7 +1926,9 @@
|
||||
"prod:panel": "webpack --mode production --config ./webpack/panel.config.js",
|
||||
"test-compile": "tsc -p ./",
|
||||
"clean": "rimraf dist",
|
||||
"start:site": "cd ./docs && npm run dev"
|
||||
"start:site": "cd ./docs && npm run dev",
|
||||
"clean:test": "rm ./e2e/sample/frontmatter.json || exit 0 && rm -rf ./e2e/sample/.frontmatter || exit 0",
|
||||
"test": "tsc -p tsconfig.e2e.json && npm run clean:test && node ./e2e/out/runTests.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "^1.8.2",
|
||||
@@ -1903,6 +1941,7 @@
|
||||
"@sentry/react": "^6.13.3",
|
||||
"@sentry/tracing": "^6.13.3",
|
||||
"@tailwindcss/forms": "^0.3.3",
|
||||
"@types/chai": "^4.3.1",
|
||||
"@types/glob": "7.1.3",
|
||||
"@types/invariant": "^2.2.35",
|
||||
"@types/js-yaml": "3.12.1",
|
||||
@@ -1910,7 +1949,7 @@
|
||||
"@types/lodash.uniqby": "4.7.6",
|
||||
"@types/lodash.xor": "^4.5.6",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/mocha": "^5.2.6",
|
||||
"@types/mocha": "^5.2.7",
|
||||
"@types/mustache": "^4.1.2",
|
||||
"@types/node": "10.17.48",
|
||||
"@types/node-fetch": "^2.5.12",
|
||||
@@ -1925,6 +1964,7 @@
|
||||
"ajv": "^8.8.2",
|
||||
"array-move": "^4.0.0",
|
||||
"autoprefixer": "^10.3.2",
|
||||
"chai": "^4.3.6",
|
||||
"css-loader": "5.2.7",
|
||||
"date-fns": "2.23.0",
|
||||
"downshift": "6.0.6",
|
||||
@@ -1941,6 +1981,7 @@
|
||||
"lodash.xor": "^4.5.0",
|
||||
"mdast-util-from-markdown": "1.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mocha": "^10.0.0",
|
||||
"mustache": "^4.2.0",
|
||||
"node-json-db": "^1.3.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -1953,10 +1994,12 @@
|
||||
"react-dom": "17.0.1",
|
||||
"react-dropzone": "^11.3.4",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
"react-toastify": "^8.1.0",
|
||||
"recoil": "^0.4.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semver": "^7.3.7",
|
||||
"style-loader": "2.0.0",
|
||||
"tailwindcss": "^2.2.7",
|
||||
"tailwindcss-nested-groups": "^1.2.4",
|
||||
@@ -1968,6 +2011,7 @@
|
||||
"uniforms-bridge-json-schema": "^3.7.0",
|
||||
"uniforms-unstyled": "^3.7.0",
|
||||
"url-join-ts": "^1.0.5",
|
||||
"vscode-extension-tester": "^4.2.5",
|
||||
"wc-react": "github:estruyf/wc-react",
|
||||
"webpack": "^5.65.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
|
||||
@@ -168,13 +168,34 @@ export class Article {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the slug based on the article title
|
||||
* Generate the new slug
|
||||
*/
|
||||
public static async generateSlug() {
|
||||
Telemetry.send(TelemetryEvent.generateSlug);
|
||||
|
||||
public static generateSlug(title: string) {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = Settings.get(SETTING_SLUG_PREFIX) as string;
|
||||
const suffix = Settings.get(SETTING_SLUG_SUFFIX) as string;
|
||||
|
||||
const slug = SlugHelper.createSlug(title);
|
||||
|
||||
if (slug) {
|
||||
return {
|
||||
slug,
|
||||
slugWithPrefixAndSuffix: `${prefix}${slug}${suffix}`
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the slug based on the article title
|
||||
*/
|
||||
public static async updateSlug() {
|
||||
Telemetry.send(TelemetryEvent.generateSlug);
|
||||
|
||||
const updateFileName = Settings.get(SETTING_SLUG_UPDATE_FILE_NAME) as string;
|
||||
const filePrefix = Settings.get<string>(SETTING_TEMPLATES_PREFIX);
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
@@ -191,17 +212,16 @@ export class Article {
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
const titleField = "title";
|
||||
const articleTitle: string = article.data[titleField];
|
||||
const slugInfo = Article.generateSlug(articleTitle);
|
||||
|
||||
const slug = SlugHelper.createSlug(articleTitle);
|
||||
if (slug) {
|
||||
let slugFieldValue = `${prefix}${slug}${suffix}`;
|
||||
article.data["slug"] = slugFieldValue;
|
||||
if (slugInfo && slugInfo.slug && slugInfo.slugWithPrefixAndSuffix) {
|
||||
article.data["slug"] = slugInfo.slugWithPrefixAndSuffix;
|
||||
|
||||
if (contentType) {
|
||||
// Update the fields containing the slug placeholder
|
||||
let fieldsToUpdate: Field[] = contentType.fields.filter(f => f.default === "{{slug}}");
|
||||
for (const field of fieldsToUpdate) {
|
||||
article.data[field.name] = slug;
|
||||
article.data[field.name] = slugInfo.slug;
|
||||
}
|
||||
|
||||
// Update the fields containing a custom placeholder that depends on slug
|
||||
@@ -227,7 +247,7 @@ export class Article {
|
||||
const ext = extname(editor.document.fileName);
|
||||
const fileName = basename(editor.document.fileName);
|
||||
|
||||
let slugName = slug.startsWith("/") ? slug.substring(1) : slug;
|
||||
let slugName = slugInfo.slug.startsWith("/") ? slugInfo.slug.substring(1) : slugInfo.slug;
|
||||
slugName = slugName.endsWith("/") ? slugName.substring(0, slugName.length - 1) : slugName;
|
||||
|
||||
let newFileName = `${slugName}${ext}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ export class Content {
|
||||
const templatesEnabled = await Settings.get(SETTING_TEMPLATES_ENABLED);
|
||||
if (!templatesEnabled) {
|
||||
commands.executeCommand(COMMAND_NAME.createByContentType);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: QuickPickItem[] = [{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SETTING_DASHBOARD_OPENONSTART, CONTEXT } from '../constants';
|
||||
import { SETTING_DASHBOARD_OPENONSTART, CONTEXT, ExtensionState } from '../constants';
|
||||
import { join } from "path";
|
||||
import { commands, Uri, ViewColumn, Webview, WebviewPanel, window } from "vscode";
|
||||
import { Logger, Settings as SettingsHelper } from '../helpers';
|
||||
@@ -7,7 +7,7 @@ import { Extension } from '../helpers/Extension';
|
||||
import { WebviewHelper } from '@estruyf/vscode';
|
||||
import { DashboardData } from '../models/DashboardData';
|
||||
import { MediaLibrary } from '../helpers/MediaLibrary';
|
||||
import { DashboardListener, MediaListener, SettingsListener, TelemetryListener, DataListener, PagesListener, ExtensionListener, SnippetListener } from '../listeners/dashboard';
|
||||
import { DashboardListener, MediaListener, SettingsListener, TelemetryListener, DataListener, PagesListener, ExtensionListener, SnippetListener, TaxonomyListener } from '../listeners/dashboard';
|
||||
import { MediaListener as PanelMediaListener } from '../listeners/panel'
|
||||
import { ModeListener } from '../listeners/general';
|
||||
|
||||
@@ -74,6 +74,7 @@ export class Dashboard {
|
||||
public static reload() {
|
||||
if (Dashboard.isOpen) {
|
||||
Dashboard.webview?.dispose();
|
||||
Extension.getInstance().setState(ExtensionState.Dashboard.Pages.Cache, undefined, "workspace")
|
||||
|
||||
setTimeout(() => {
|
||||
Dashboard.open();
|
||||
@@ -145,6 +146,7 @@ export class Dashboard {
|
||||
TelemetryListener.process(msg);
|
||||
SnippetListener.process(msg);
|
||||
ModeListener.process(msg);
|
||||
TaxonomyListener.process(msg);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ ${folderData.join("\n")}
|
||||
let projectStart = folder.path.split(projectName).pop();
|
||||
projectStart = projectStart || "";
|
||||
projectStart = projectStart?.replace(/\\/g, '/');
|
||||
projectStart = projectStart?.startsWith('/') ? projectStart.substr(1) : projectStart;
|
||||
projectStart = projectStart?.startsWith('/') ? projectStart.substring(1) : projectStart;
|
||||
|
||||
const mdFiles = await workspace.findFiles(join(projectStart, folder.excludeSubdir ? '/' : '**/', '*.md'));
|
||||
const mdxFiles = await workspace.findFiles(join(projectStart, folder.excludeSubdir ? '/' : '**/', '*.mdx'));
|
||||
|
||||
@@ -27,7 +27,7 @@ export class Folders {
|
||||
*/
|
||||
public static async addMediaFolder(data?: {selectedFolder?: string}) {
|
||||
let wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
let staticFolder = Folders.getStaticFolderRelativePath();
|
||||
|
||||
let startPath = "";
|
||||
|
||||
@@ -153,6 +153,25 @@ export class Folders {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the static folder its relative path
|
||||
* @returns
|
||||
*/
|
||||
public static getStaticFolderRelativePath(): string | undefined {
|
||||
let staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
|
||||
if (staticFolder && staticFolder.includes(WORKSPACE_PLACEHOLDER)) {
|
||||
staticFolder = Folders.getAbsFilePath(staticFolder);
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
if (wsFolder) {
|
||||
const relativePath = relative(parseWinPath(wsFolder.fsPath), parseWinPath(staticFolder));
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
return staticFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the folder path
|
||||
* @param folder
|
||||
@@ -231,7 +250,7 @@ export class Folders {
|
||||
|
||||
if (projectStart) {
|
||||
projectStart = projectStart.replace(/\\/g, '/');
|
||||
projectStart = projectStart.startsWith('/') ? projectStart.substr(1) : projectStart;
|
||||
projectStart = projectStart.startsWith('/') ? projectStart.substring(1) : projectStart;
|
||||
|
||||
let files: Uri[] = [];
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { SETTING_PREVIEW_HOST, SETTING_PREVIEW_PATHNAME, CONTEXT, TelemetryEvent
|
||||
import { ArticleHelper } from './../helpers/ArticleHelper';
|
||||
import { join } from "path";
|
||||
import { commands, env, Uri, ViewColumn, window } from "vscode";
|
||||
import { Extension, Settings } from '../helpers';
|
||||
import { PreviewSettings } from '../models';
|
||||
import { Extension, parseWinPath, Settings } from '../helpers';
|
||||
import { ContentFolder, PreviewSettings } from '../models';
|
||||
import { format } from 'date-fns';
|
||||
import { DateHelper } from '../helpers/DateHelper';
|
||||
import { Article } from '.';
|
||||
import { urlJoin } from 'url-join-ts';
|
||||
import { WebviewHelper } from '@estruyf/vscode';
|
||||
import { Folders } from './Folders';
|
||||
|
||||
|
||||
export class Preview {
|
||||
@@ -37,6 +38,28 @@ export class Preview {
|
||||
let slug = article?.data ? article.data.slug : "";
|
||||
|
||||
let pathname = settings.pathname;
|
||||
|
||||
// Check if there is a pathname defined on content folder level
|
||||
const folders = Folders.get();
|
||||
if (folders.length > 0) {
|
||||
const foldersWithPath = folders.filter(folder => folder.previewPath);
|
||||
const filePath = parseWinPath(editor?.document.uri.fsPath);
|
||||
|
||||
let selectedFolder: ContentFolder | null = null;
|
||||
for (const folder of foldersWithPath) {
|
||||
if (filePath.startsWith(folder.path)) {
|
||||
if (!selectedFolder || selectedFolder.path.length < folder.path.length) {
|
||||
selectedFolder = folder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFolder) {
|
||||
pathname = selectedFolder.previewPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there is a pathname defined on content type level
|
||||
if (article?.data) {
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
if (contentType && contentType.previewPath) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_CONTENT_TYPE } from './../constants/ContentType';
|
||||
import { Telemetry } from './../helpers/Telemetry';
|
||||
import { workspace, Uri } from "vscode";
|
||||
import { join } from "path";
|
||||
@@ -6,7 +7,7 @@ import { Notifications } from "../helpers/Notifications";
|
||||
import { Template } from "./Template";
|
||||
import { Folders } from "./Folders";
|
||||
import { FrameworkDetector, Logger, Settings } from "../helpers";
|
||||
import { SETTING_CONTENT_DEFAULT_FILETYPE, TelemetryEvent } from "../constants";
|
||||
import { SETTING_CONTENT_DEFAULT_FILETYPE, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants";
|
||||
import { SettingsListener } from '../listeners/dashboard';
|
||||
|
||||
export class Project {
|
||||
@@ -35,6 +36,9 @@ categories: []
|
||||
try {
|
||||
Settings.createTeamSettings();
|
||||
|
||||
// Add the default content type
|
||||
Settings.update(SETTING_TAXONOMY_CONTENT_TYPES, [DEFAULT_CONTENT_TYPE], true);
|
||||
|
||||
if (sampleTemplate !== undefined) {
|
||||
await Project.createSampleTemplate();
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TaxonomyHelper } from './../helpers/TaxonomyHelper';
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import { TaxonomyType } from "../models";
|
||||
import { SETTING_TAXONOMY_TAGS, SETTING_TAXONOMY_CATEGORIES, EXTENSION_NAME } from '../constants';
|
||||
import { ArticleHelper, Settings as SettingsHelper, FilesHelper } from '../helpers';
|
||||
import { FrontMatterParser } from '../parsers';
|
||||
import { DumpOptions } from 'js-yaml';
|
||||
import { Notifications } from '../helpers/Notifications';
|
||||
|
||||
export class Settings {
|
||||
@@ -76,7 +75,7 @@ export class Settings {
|
||||
*/
|
||||
public static async export() {
|
||||
// Retrieve all the Markdown files
|
||||
const allMdFiles = await FilesHelper.getMdFiles();
|
||||
const allMdFiles = await FilesHelper.getAllFiles();
|
||||
if (!allMdFiles) {
|
||||
return;
|
||||
}
|
||||
@@ -157,6 +156,7 @@ export class Settings {
|
||||
canPickMany: false,
|
||||
ignoreFocusOut: true
|
||||
});
|
||||
|
||||
if (!taxType) {
|
||||
return;
|
||||
}
|
||||
@@ -196,76 +196,10 @@ export class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve all the markdown files
|
||||
const allMdFiles = await FilesHelper.getMdFiles();
|
||||
if (!allMdFiles) {
|
||||
return;
|
||||
if (newOptionValue) {
|
||||
TaxonomyHelper.process("edit", type, selectedOption, newOptionValue);
|
||||
} else {
|
||||
TaxonomyHelper.process("delete", type, selectedOption, undefined);
|
||||
}
|
||||
|
||||
let progressText = `${EXTENSION_NAME}: Remapping "${selectedOption}" ${type === TaxonomyType.Tag ? "tag" : "category"} to "${newOptionValue}".`;
|
||||
if (!newOptionValue) {
|
||||
progressText = `${EXTENSION_NAME}: Deleting "${selectedOption}" ${type === TaxonomyType.Tag ? "tag" : "category"}.`;
|
||||
}
|
||||
vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: progressText,
|
||||
cancellable: false
|
||||
}, async (progress) => {
|
||||
// Set the initial progress
|
||||
const progressNr = allMdFiles.length/100;
|
||||
progress.report({ increment: 0});
|
||||
|
||||
const matterProp: string = type === TaxonomyType.Tag ? "tags" : "categories";
|
||||
|
||||
let i = 0;
|
||||
for (const file of allMdFiles) {
|
||||
progress.report({ increment: (++i/progressNr) });
|
||||
const mdFile = fs.readFileSync(file.path, { encoding: "utf8" });
|
||||
if (mdFile) {
|
||||
try {
|
||||
const article = FrontMatterParser.fromFile(mdFile);
|
||||
if (article && article.data) {
|
||||
const { data } = article;
|
||||
let taxonomies: string[] = data[matterProp];
|
||||
if (taxonomies && taxonomies.length > 0) {
|
||||
const idx = taxonomies.findIndex(o => o === selectedOption);
|
||||
if (idx !== -1) {
|
||||
if (newOptionValue) {
|
||||
taxonomies[idx] = newOptionValue;
|
||||
} else {
|
||||
taxonomies = taxonomies.filter(o => o !== selectedOption);
|
||||
}
|
||||
data[matterProp] = [...new Set(taxonomies)].sort();
|
||||
const spaces = vscode.window.activeTextEditor?.options?.tabSize;
|
||||
// Update the file
|
||||
fs.writeFileSync(file.path, FrontMatterParser.toFile(article.content, article.data, mdFile, {
|
||||
indent: spaces || 2
|
||||
} as DumpOptions as any), { encoding: "utf8" });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue with the next file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the settings
|
||||
const idx = options.findIndex(o => o === selectedOption);
|
||||
if (newOptionValue) {
|
||||
// Add or update the new option
|
||||
if (idx !== -1) {
|
||||
options[idx] = newOptionValue;
|
||||
} else {
|
||||
options.push(newOptionValue);
|
||||
}
|
||||
} else {
|
||||
// Remove the selected option
|
||||
options = options.filter(o => o !== selectedOption);
|
||||
}
|
||||
await SettingsHelper.updateTaxonomy(type, options);
|
||||
|
||||
Notifications.info(`${newOptionValue ? "Remapping" : "Deleation"} of the ${selectedOption} ${type === TaxonomyType.Tag ? "tag" : "category"} completed.`);
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/components/icons/MergeIcon.tsx
Normal file
13
src/components/icons/MergeIcon.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface IMergeIconProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const MergeIcon: React.FunctionComponent<IMergeIconProps> = ({className}: React.PropsWithChildren<IMergeIconProps>) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path xmlns="http://www.w3.org/2000/svg" d="M7.586 8.00366L4 8.00366C3.44772 8.00366 3 7.55595 3 7.00366C3 6.45138 3.44772 6.00366 4 6.00366L8 6.00366C8.26509 6.00366 8.51933 6.10892 8.70685 6.2963L13.414 11H18.5845L15.2931 7.71103C14.9025 7.32065 14.9023 6.68748 15.2926 6.29681C15.683 5.90615 16.3162 5.90592 16.7068 6.2963L21.7068 11.2926C21.8945 11.4802 22 11.7346 22 11.9998C22 12.2651 21.8947 12.5195 21.7071 12.7071L16.7071 17.7071C16.3166 18.0976 15.6834 18.0976 15.2929 17.7071C14.9024 17.3166 14.9024 16.6834 15.2929 16.2929L18.5858 13H13.4142L8.70711 17.7071C8.51957 17.8947 8.26522 18 8 18H4C3.44772 18 3 17.5523 3 17C3 16.4477 3.44772 16 4 16H7.58579L11.5855 12.0003L7.586 8.00366Z" fill="currentcolor"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export const COMMAND_NAME = {
|
||||
dashboardMedia: getCommandName("dashboard.media"),
|
||||
dashboardSnippets: getCommandName("dashboard.snippets"),
|
||||
dashboardData: getCommandName("dashboard.data"),
|
||||
dashboardTaxonomy: getCommandName("dashboard.taxonomy"),
|
||||
dashboardClose: getCommandName("dashboard.close"),
|
||||
promote: getCommandName("promoteSettings"),
|
||||
createFolder: getCommandName("createFolder"),
|
||||
|
||||
@@ -17,6 +17,9 @@ export const FEATURE_FLAG = {
|
||||
},
|
||||
data: {
|
||||
view: "dashboard.data.view",
|
||||
},
|
||||
taxonomy: {
|
||||
view: "dashboard.taxonomy.view"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export const TelemetryEvent = {
|
||||
openMediaDashboard: 'openMediaDashboard',
|
||||
openDataDashboard: 'openDataDashboard',
|
||||
openSnippetsDashboard: 'openSnippetsDashboard',
|
||||
openTaxonomyDashboard: 'openTaxonomyDashboard',
|
||||
closeDashboard: 'closeDashboard',
|
||||
|
||||
// Other actions
|
||||
@@ -41,4 +42,5 @@ export const TelemetryEvent = {
|
||||
webviewDataView: 'webviewDataView',
|
||||
webviewContentsView: 'webviewContentsView',
|
||||
webviewSnippetsView: 'webviewSnippetsView',
|
||||
webviewTaxonomyDashboard: 'webviewTaxonomyDashboard',
|
||||
};
|
||||
@@ -8,4 +8,7 @@ export enum DashboardCommand {
|
||||
mediaUpdate = "mediaUpdate",
|
||||
dataFileEntries = "dataFileEntries",
|
||||
searchReady = "searchReady",
|
||||
|
||||
// Taxonomy dashboard
|
||||
setTaxonomyData = "setTaxonomyData",
|
||||
}
|
||||
@@ -41,6 +41,16 @@ export enum DashboardMessage {
|
||||
addSnippet = 'addSnippet',
|
||||
updateSnippet = 'updateSnippet',
|
||||
|
||||
// Taxonomy dashboard
|
||||
getTaxonomyData = 'getTaxonomyData',
|
||||
editTaxonomy = "editTaxonomy",
|
||||
mergeTaxonomy = "mergeTaxonomy",
|
||||
deleteTaxonomy = "deleteTaxonomy",
|
||||
addToTaxonomy = "addToTaxonomy",
|
||||
createTaxonomy = "createTaxonomy",
|
||||
importTaxonomy = "importTaxonomy",
|
||||
moveTaxonomy = "moveTaxonomy",
|
||||
|
||||
// Other
|
||||
getTheme = 'getTheme',
|
||||
updateSetting = 'updateSetting',
|
||||
|
||||
90
src/dashboardWebView/components/App.tsx
Normal file
90
src/dashboardWebView/components/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import { Spinner } from './Spinner';
|
||||
import useMessages from '../hooks/useMessages';
|
||||
import useDarkMode from '../../hooks/useDarkMode';
|
||||
import { WelcomeScreen } from './WelcomeScreen';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { DashboardViewSelector, ModeAtom } from '../state';
|
||||
import { Contents } from './Contents/Contents';
|
||||
import { Media } from './Media/Media';
|
||||
import { DataView } from './DataView';
|
||||
import { Snippets } from './SnippetsView/Snippets';
|
||||
import { FEATURE_FLAG } from '../../constants';
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { TaxonomyView } from './TaxonomyView';
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { routePaths } from '..';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { UnknownView } from './UnknownView';
|
||||
|
||||
export interface IAppProps {
|
||||
showWelcome: boolean;
|
||||
}
|
||||
|
||||
export const App: React.FunctionComponent<IAppProps> = ({showWelcome}: React.PropsWithChildren<IAppProps>) => {
|
||||
const { loading, pages, settings } = useMessages();
|
||||
const view = useRecoilValue(DashboardViewSelector);
|
||||
const mode = useRecoilValue(ModeAtom);
|
||||
const navigate = useNavigate();
|
||||
useDarkMode();
|
||||
|
||||
const viewState: any = Messenger.getState() || {};
|
||||
|
||||
const isAllowed = (features: string[], flag: string) => {
|
||||
if (!features ||( features.length > 0 && !features.includes(flag))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowDataView = useMemo(() => {
|
||||
return isAllowed(mode?.features || [], FEATURE_FLAG.dashboard.data.view)
|
||||
}, [mode?.features]);
|
||||
|
||||
const allowTaxonomyView = useMemo(() => {
|
||||
return isAllowed(mode?.features || [], FEATURE_FLAG.dashboard.taxonomy.view)
|
||||
}, [mode?.features]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view && routePaths[view]) {
|
||||
navigate(routePaths[view]);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(routePaths[view]);
|
||||
}, [view]);
|
||||
|
||||
if (!settings) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (showWelcome || viewState.isWelcomeConfiguring) {
|
||||
return <WelcomeScreen settings={settings} />;
|
||||
}
|
||||
|
||||
if (!settings.initialized || settings.contentFolders?.length === 0) {
|
||||
return <WelcomeScreen settings={settings} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`h-full w-full`}>
|
||||
<Routes>
|
||||
<Route path={routePaths.welcome} element={<WelcomeScreen settings={settings} />} />
|
||||
<Route path={routePaths.contents} element={<Contents pages={pages} loading={loading} />} />
|
||||
<Route path={routePaths.media} element={<Media />} />
|
||||
<Route path={routePaths.snippets} element={<Snippets />} />
|
||||
|
||||
{
|
||||
allowDataView && <Route path={routePaths.data} element={<DataView />} />
|
||||
}
|
||||
|
||||
{
|
||||
allowTaxonomyView && <Route path={routePaths.taxonomy} element={<TaxonomyView pages={pages} />} />
|
||||
}
|
||||
|
||||
<Route path={`*`} element={<UnknownView />} />
|
||||
</Routes>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -12,10 +12,11 @@ export interface IChoiceButtonProps {
|
||||
onClick: () => void;
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
isTemplatesEnabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onClick, disabled, choices, title}: React.PropsWithChildren<IChoiceButtonProps>) => {
|
||||
export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onClick, disabled, choices, isTemplatesEnabled, title}: React.PropsWithChildren<IChoiceButtonProps>) => {
|
||||
return (
|
||||
<span className="relative z-50 inline-flex shadow-sm rounded-md">
|
||||
<button
|
||||
@@ -27,36 +28,40 @@ export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({onCli
|
||||
{title}
|
||||
</button>
|
||||
|
||||
<Menu as="span" className="-ml-px relative block">
|
||||
<Menu.Button
|
||||
className="h-full inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-700 hover:bg-teal-800 focus:outline-none disabled:bg-gray-500"
|
||||
disabled={disabled}>
|
||||
<span className="sr-only">Open options</span>
|
||||
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
{
|
||||
isTemplatesEnabled && (
|
||||
<Menu as="span" className="-ml-px relative block">
|
||||
<Menu.Button
|
||||
className="h-full inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-700 hover:bg-teal-800 focus:outline-none disabled:bg-gray-500"
|
||||
disabled={disabled}>
|
||||
<span className="sr-only">Open options</span>
|
||||
<ChevronDownIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
|
||||
<MenuItems widthClass={`w-56`}>
|
||||
<div className="py-1">
|
||||
{choices.map((choice, idx) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={(
|
||||
choice.icon ? (
|
||||
<div className="flex items-center">
|
||||
{choice.icon}
|
||||
<span>{choice.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
choice.title
|
||||
)
|
||||
)}
|
||||
value={null}
|
||||
onClick={choice.onClick}
|
||||
disabled={choice.disabled} />
|
||||
))}
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
<MenuItems widthClass={`w-56`}>
|
||||
<div className="py-1">
|
||||
{choices.map((choice, idx) => (
|
||||
<MenuItem
|
||||
key={idx}
|
||||
title={(
|
||||
choice.icon ? (
|
||||
<div className="flex items-center">
|
||||
{choice.icon}
|
||||
<span>{choice.title}</span>
|
||||
</div>
|
||||
) : (
|
||||
choice.title
|
||||
)
|
||||
)}
|
||||
value={null}
|
||||
onClick={choice.onClick}
|
||||
disabled={choice.disabled} />
|
||||
))}
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,20 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
|
||||
}
|
||||
|
||||
const tagField = settings.dashboardState.contents.tags;
|
||||
return pageData[tagField] || [];
|
||||
|
||||
if (tagField === "tags") {
|
||||
return pageData.fmTags;
|
||||
} else if (tagField === "categories") {
|
||||
return pageData.fmCategories;
|
||||
}
|
||||
|
||||
const tagsValue = pageData[tagField] || [];
|
||||
|
||||
if (Array.isArray(tagsValue)) {
|
||||
return tagsValue;
|
||||
}
|
||||
|
||||
return [tagsValue];
|
||||
}, [settings, pageData]);
|
||||
|
||||
if (view === DashboardViewType.Grid) {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Spinner } from './Spinner';
|
||||
import useMessages from '../hooks/useMessages';
|
||||
import useDarkMode from '../../hooks/useDarkMode';
|
||||
import { WelcomeScreen } from './WelcomeScreen';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { DashboardViewSelector, ModeAtom } from '../state';
|
||||
import { Contents } from './Contents/Contents';
|
||||
import { Media } from './Media/Media';
|
||||
import { NavigationType } from '../models';
|
||||
import { DataView } from './DataView';
|
||||
import { Snippets } from './SnippetsView/Snippets';
|
||||
import { FeatureFlag } from '../../components/features/FeatureFlag';
|
||||
import { FEATURE_FLAG } from '../../constants';
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
|
||||
export interface IDashboardProps {
|
||||
showWelcome: boolean;
|
||||
}
|
||||
|
||||
export const Dashboard: React.FunctionComponent<IDashboardProps> = ({showWelcome}: React.PropsWithChildren<IDashboardProps>) => {
|
||||
const { loading, pages, settings } = useMessages();
|
||||
const view = useRecoilValue(DashboardViewSelector);
|
||||
const mode = useRecoilValue(ModeAtom);
|
||||
useDarkMode();
|
||||
|
||||
const viewState: any = Messenger.getState() || {};
|
||||
|
||||
if (!settings) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (showWelcome || viewState.isWelcomeConfiguring) {
|
||||
return <WelcomeScreen settings={settings} />;
|
||||
}
|
||||
|
||||
if (!settings.initialized || settings.contentFolders?.length === 0) {
|
||||
return <WelcomeScreen settings={settings} />;
|
||||
}
|
||||
|
||||
if (view === NavigationType.Snippets) {
|
||||
return (
|
||||
<main className={`h-full w-full`}>
|
||||
<Snippets />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === NavigationType.Media) {
|
||||
return (
|
||||
<main className={`h-full w-full`}>
|
||||
<Media />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === NavigationType.Data) {
|
||||
return (
|
||||
<FeatureFlag features={mode?.features || []} flag={FEATURE_FLAG.dashboard.data.view}>
|
||||
<main className={`h-full w-full`}>
|
||||
<DataView />
|
||||
</main>
|
||||
</FeatureFlag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`h-full w-full`}>
|
||||
<Contents pages={pages} loading={loading} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import { ToastContainer, toast, Slide } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { DataType } from '../../../models/DataType';
|
||||
import { TelemetryEvent } from '../../../constants';
|
||||
import { NavigationItem } from '../Layout';
|
||||
|
||||
export interface IDataViewProps {}
|
||||
|
||||
@@ -150,14 +151,13 @@ export const DataView: React.FunctionComponent<IDataViewProps> = (props: React.P
|
||||
{
|
||||
(dataFiles && dataFiles.length > 0) && (
|
||||
dataFiles.map((dataFile, idx) => (
|
||||
<button
|
||||
<NavigationItem
|
||||
key={`${dataFile.id}-${idx}`}
|
||||
type='button'
|
||||
className={`px-4 py-2 flex items-center text-sm font-medium w-full text-left hover:bg-gray-200 dark:hover:bg-vulcan-400 hover:text-vulcan-500 dark:hover:text-whisper-500 ${selectedData?.id === dataFile.id ? 'bg-gray-300 dark:bg-vulcan-300 text-vulcan-500 dark:text-whisper-500' : 'text-gray-500 dark:text-whisper-900'}`}
|
||||
isSelected={selectedData?.id === dataFile.id}
|
||||
onClick={() => setSchema(dataFile)}>
|
||||
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
|
||||
<span>{dataFile.title}</span>
|
||||
</button>
|
||||
</NavigationItem>
|
||||
)
|
||||
))
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export const DataView: React.FunctionComponent<IDataViewProps> = (props: React.P
|
||||
selectedData ? (
|
||||
<>
|
||||
<div className={`w-1/3 py-6 px-4 flex-1 border-r border-gray-200 dark:border-vulcan-300 overflow-auto`}>
|
||||
<h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>Your {selectedData.title.toLowerCase()} data items</h2>
|
||||
<h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>Your {selectedData?.title?.toLowerCase() || ""} data items</h2>
|
||||
|
||||
<div className='py-4'>
|
||||
{
|
||||
@@ -245,4 +245,4 @@ export const DataView: React.FunctionComponent<IDataViewProps> = (props: React.P
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,14 +38,18 @@ export const Breadcrumb: React.FunctionComponent<IBreadcrumbProps> = (props: Rea
|
||||
}
|
||||
}
|
||||
|
||||
let valid = false;
|
||||
for (let i = 0; i < contentFolders.length; i++) {
|
||||
const folder = contentFolders[i];
|
||||
const contentFolder = parseWinPath(folder.path) as string;
|
||||
const relContentPath = folderPath.replace(contentFolder, '');
|
||||
return relContentPath.length > 1 && folderPath.startsWith(contentFolder);
|
||||
|
||||
if (!valid) {
|
||||
valid = relContentPath.length > 1 && folderPath.startsWith(contentFolder);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return valid;
|
||||
};
|
||||
|
||||
if (!selectedFolder) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { FolderSelector, TagSelector, CategorySelector, SortingAtom, FolderAtom, DEFAULT_FOLDER_STATE, TagAtom, CategoryAtom, DEFAULT_TAG_STATE, DEFAULT_CATEGORY_STATE } from '../../state';
|
||||
|
||||
import { DefaultValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const guardRecoilDefaultValue = (
|
||||
candidate: any
|
||||
@@ -34,7 +36,7 @@ export const ClearFilters: React.FunctionComponent<IClearFiltersProps> = (props:
|
||||
resetCategory();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (folder !== DEFAULT_FOLDER_STATE || tag !== DEFAULT_TAG_STATE || category !== DEFAULT_CATEGORY_STATE) {
|
||||
setShow(true);
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Navigation } from '../Navigation';
|
||||
import { Grouping } from '.';
|
||||
import { ViewSwitch } from './ViewSwitch';
|
||||
import { useRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { CategoryAtom, DashboardViewAtom, SortingAtom, TagAtom } from '../../state';
|
||||
import { CategoryAtom, SortingAtom, TagAtom } from '../../state';
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { ClearFilters } from './ClearFilters';
|
||||
import { MediaHeaderTop } from '../Media/MediaHeaderTop';
|
||||
@@ -19,6 +19,9 @@ import { MediaHeaderBottom } from '../Media/MediaHeaderBottom';
|
||||
import { Tabs } from './Tabs';
|
||||
import { CustomScript } from '../../../models';
|
||||
import { LightningBoltIcon, PlusIcon } from '@heroicons/react/outline';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { routePaths } from '../..';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface IHeaderProps {
|
||||
header?: React.ReactNode;
|
||||
@@ -34,8 +37,9 @@ export interface IHeaderProps {
|
||||
export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPages, folders, settings }: React.PropsWithChildren<IHeaderProps>) => {
|
||||
const [ crntTag, setCrntTag ] = useRecoilState(TagAtom);
|
||||
const [ crntCategory, setCrntCategory ] = useRecoilState(CategoryAtom);
|
||||
const [ view, setView ] = useRecoilState(DashboardViewAtom);
|
||||
const resetSorting = useResetRecoilState(SortingAtom)
|
||||
const resetSorting = useResetRecoilState(SortingAtom);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createContent = () => {
|
||||
Messenger.send(DashboardMessage.createContent);
|
||||
@@ -50,7 +54,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
|
||||
};
|
||||
|
||||
const updateView = (view: NavigationType) => {
|
||||
setView(view);
|
||||
navigate(routePaths[view]);
|
||||
resetSorting();
|
||||
}
|
||||
|
||||
@@ -68,6 +72,27 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
|
||||
onClick: () => runBulkScript(s)
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (location.search) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const taxonomy = searchParams.get("taxonomy");
|
||||
const value = searchParams.get("value");
|
||||
|
||||
if (taxonomy && value) {
|
||||
if (taxonomy === "tags") {
|
||||
setCrntTag(value);
|
||||
} else if (taxonomy === "categories") {
|
||||
setCrntCategory(value);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setCrntTag("");
|
||||
setCrntCategory("");
|
||||
}, [location.search]);
|
||||
|
||||
return (
|
||||
<div className={`w-full sticky top-0 z-40 bg-gray-100 dark:bg-vulcan-500`}>
|
||||
|
||||
@@ -76,7 +101,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
|
||||
</div>
|
||||
|
||||
{
|
||||
view === NavigationType.Contents && (
|
||||
location.pathname === routePaths.contents && (
|
||||
<>
|
||||
<div className={`px-4 mt-3 mb-2 flex items-center justify-between`}>
|
||||
<Searchbox />
|
||||
@@ -109,6 +134,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
|
||||
...customActions
|
||||
]}
|
||||
onClick={createContent}
|
||||
isTemplatesEnabled={settings?.dashboardState?.contents?.templatesEnabled || undefined}
|
||||
disabled={!settings?.initialized} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +167,7 @@ export const Header: React.FunctionComponent<IHeaderProps> = ({header, totalPage
|
||||
}
|
||||
|
||||
{
|
||||
view === NavigationType.Media && (
|
||||
location.pathname === routePaths.media && (
|
||||
<>
|
||||
<MediaHeaderTop />
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { NavigationType } from '../../models';
|
||||
import { DashboardViewAtom } from '../../state';
|
||||
|
||||
export interface ITabProps {
|
||||
navigationType: NavigationType;
|
||||
@@ -9,11 +8,11 @@ export interface ITabProps {
|
||||
}
|
||||
|
||||
export const Tab: React.FunctionComponent<ITabProps> = ({navigationType, onNavigate, children}: React.PropsWithChildren<ITabProps>) => {
|
||||
const view = useRecoilValue(DashboardViewAtom);
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`h-full flex items-center py-2 px-4 text-sm font-medium text-center border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 ${view === navigationType ? "border-vulcan-500 text-vulcan-500 dark:border-whisper-500 dark:text-whisper-500" : "text-gray-500 dark:text-gray-400"}`}
|
||||
className={`h-full flex items-center py-2 px-4 text-sm font-medium text-center border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 ${location.pathname === `/${navigationType}` ? "border-vulcan-500 text-vulcan-500 dark:border-whisper-500 dark:text-whisper-500" : "text-gray-500 dark:text-gray-400"}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="profile"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseIcon, PhotographIcon, ScissorsIcon } from '@heroicons/react/outline';
|
||||
import { DatabaseIcon, PhotographIcon, ScissorsIcon, TagIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FeatureFlag } from '../../../components/features/FeatureFlag';
|
||||
@@ -49,6 +49,15 @@ export const Tabs: React.FunctionComponent<ITabsProps> = ({ onNavigate }: React.
|
||||
</Tab>
|
||||
</li>
|
||||
</FeatureFlag>
|
||||
<FeatureFlag features={mode?.features || []} flag={FEATURE_FLAG.dashboard.taxonomy.view}>
|
||||
<li className="mr-2" role="presentation">
|
||||
<Tab
|
||||
navigationType={NavigationType.Taxonomy}
|
||||
onNavigate={onNavigate}>
|
||||
<TagIcon className={`h-6 w-auto mr-2`} /><span>Taxonomy</span>
|
||||
</Tab>
|
||||
</li>
|
||||
</FeatureFlag>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
28
src/dashboardWebView/components/Layout/NavigationBar.tsx
Normal file
28
src/dashboardWebView/components/Layout/NavigationBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface INavigationBarProps {
|
||||
title?: string;
|
||||
bottom?: JSX.Element;
|
||||
}
|
||||
|
||||
export const NavigationBar: React.FunctionComponent<INavigationBarProps> = ({title, bottom, children}: React.PropsWithChildren<INavigationBarProps>) => {
|
||||
return (
|
||||
<aside className={`w-2/12 px-4 py-6 h-full flex flex-col flex-grow border-r border-gray-200 dark:border-vulcan-300`}>
|
||||
{
|
||||
title && <h2 className={`text-lg text-gray-500 dark:text-whisper-900`}>{title}</h2>
|
||||
}
|
||||
|
||||
<nav className={`flex-1 py-4 -mx-4 h-full`}>
|
||||
<div className={`divide-y divide-gray-200 dark:divide-vulcan-300 border-t border-b border-gray-200 dark:border-vulcan-300`}>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{
|
||||
bottom && bottom
|
||||
}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
17
src/dashboardWebView/components/Layout/NavigationItem.tsx
Normal file
17
src/dashboardWebView/components/Layout/NavigationItem.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface INavigationItemProps {
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const NavigationItem: React.FunctionComponent<INavigationItemProps> = ({isSelected, onClick, children}: React.PropsWithChildren<INavigationItemProps>) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={`px-4 py-2 flex items-center text-sm font-medium w-full text-left hover:bg-gray-200 dark:hover:bg-vulcan-400 hover:text-vulcan-500 dark:hover:text-whisper-500 cursor-pointer ${isSelected ? 'bg-gray-300 dark:bg-vulcan-300 text-vulcan-500 dark:text-whisper-500' : 'text-gray-500 dark:text-whisper-900'}`}
|
||||
onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -5,11 +5,12 @@ import { Header } from '../Header';
|
||||
|
||||
export interface IPageLayoutProps {
|
||||
header?: React.ReactNode;
|
||||
folders?: string[] | undefined
|
||||
totalPages?: number | undefined
|
||||
folders?: string[] | undefined;
|
||||
totalPages?: number | undefined;
|
||||
contentClass?: string;
|
||||
}
|
||||
|
||||
export const PageLayout: React.FunctionComponent<IPageLayoutProps> = ({ header, folders, totalPages, children }: React.PropsWithChildren<IPageLayoutProps>) => {
|
||||
export const PageLayout: React.FunctionComponent<IPageLayoutProps> = ({ header, folders, totalPages, contentClass, children }: React.PropsWithChildren<IPageLayoutProps>) => {
|
||||
const settings = useRecoilValue(SettingsSelector);
|
||||
|
||||
return (
|
||||
@@ -20,7 +21,7 @@ export const PageLayout: React.FunctionComponent<IPageLayoutProps> = ({ header,
|
||||
totalPages={totalPages}
|
||||
settings={settings} />
|
||||
|
||||
<div className="w-full flex justify-between flex-col flex-grow max-w-7xl mx-auto pt-6 px-4">
|
||||
<div className={contentClass || "w-full flex justify-between flex-col flex-grow max-w-7xl mx-auto pt-6 px-4"}>
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
3
src/dashboardWebView/components/Layout/index.ts
Normal file
3
src/dashboardWebView/components/Layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './NavigationBar';
|
||||
export * from './NavigationItem';
|
||||
export * from './PageLayout';
|
||||
@@ -61,10 +61,14 @@ export const Item: React.FunctionComponent<IItemProps> = ({media}: React.PropsWi
|
||||
const getRelPath = () => {
|
||||
let relPath: string | undefined = "";
|
||||
if (settings?.wsFolder && media.fsPath) {
|
||||
relPath = media.fsPath.split(settings.wsFolder).pop();
|
||||
const wsFolderParsed = parseWinPath(settings.wsFolder);
|
||||
const mediaParsed = parseWinPath(media.fsPath);
|
||||
|
||||
relPath = mediaParsed.split(wsFolderParsed).pop();
|
||||
|
||||
if (settings.staticFolder && relPath) {
|
||||
relPath = relPath.split(settings.staticFolder).pop();
|
||||
const staticFolderParsed = parseWinPath(settings.staticFolder);
|
||||
relPath = relPath.split(staticFolderParsed).pop();
|
||||
}
|
||||
}
|
||||
return relPath;
|
||||
|
||||
@@ -17,7 +17,7 @@ import useMedia from '../../hooks/useMedia';
|
||||
import { TelemetryEvent } from '../../../constants';
|
||||
import { PageLayout } from '../Layout/PageLayout';
|
||||
import { parseWinPath } from '../../../helpers/parseWinPath';
|
||||
import { extname, join } from 'path';
|
||||
import { basename, extname, join } from 'path';
|
||||
|
||||
export interface IMediaProps {}
|
||||
|
||||
@@ -29,14 +29,27 @@ export const Media: React.FunctionComponent<IMediaProps> = (props: React.PropsWi
|
||||
const folders = useRecoilValue(MediaFoldersAtom);
|
||||
const loading = useRecoilValue(LoadingAtom);
|
||||
|
||||
|
||||
const allFolders = React.useMemo(() => {
|
||||
const contentFolders = React.useMemo(() => {
|
||||
// Check if content allows page bundle
|
||||
if (viewData && viewData.data && typeof viewData.data.pageBundle !== "undefined" && !viewData.data.pageBundle) {
|
||||
return folders.filter(f => parseWinPath(f).includes(join('/', settings?.staticFolder || '', '/')));
|
||||
return [];
|
||||
}
|
||||
|
||||
return folders;
|
||||
let groupedFolders = [];
|
||||
|
||||
for (const cFolder of (settings?.contentFolders || [])) {
|
||||
const foldersPath = parseWinPath(cFolder.path);
|
||||
groupedFolders.push({
|
||||
title: cFolder.title || basename(cFolder.path),
|
||||
folders: folders.filter(f => parseWinPath(f).startsWith(foldersPath))
|
||||
});
|
||||
}
|
||||
|
||||
return groupedFolders;
|
||||
}, [folders, viewData, settings?.contentFolders]);
|
||||
|
||||
const publicFolders = React.useMemo(() => {
|
||||
return folders.filter(f => parseWinPath(f).includes(join('/', settings?.staticFolder || '', '/')));
|
||||
}, [folders, viewData, settings?.staticFolder]);
|
||||
|
||||
const allMedia = React.useMemo(() => {
|
||||
@@ -126,11 +139,33 @@ export const Media: React.FunctionComponent<IMediaProps> = (props: React.PropsWi
|
||||
}
|
||||
|
||||
{
|
||||
allFolders && allFolders.length > 0 && (
|
||||
contentFolders && contentFolders.length > 0 && contentFolders.map(group => (
|
||||
group.folders && group.folders.length > 0 && (
|
||||
<div className={`mb-8`}>
|
||||
<h2 className='text-lg mb-8 first-letter:uppercase'>Content folder: <b>{group.title}</b></h2>
|
||||
|
||||
<List gap={0}>
|
||||
{
|
||||
group.folders.map((folder) => (
|
||||
<FolderItem key={folder} folder={folder} staticFolder={settings?.staticFolder} wsFolder={settings?.wsFolder} />
|
||||
))
|
||||
}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
{
|
||||
publicFolders && publicFolders.length > 0 && (
|
||||
<div className={`mb-8`}>
|
||||
{
|
||||
contentFolders && contentFolders.length > 0 && (<h2 className='text-lg mb-8'>Public folder{settings?.staticFolder && (<span>: <b>{settings?.staticFolder}</b></span>)}</h2>)
|
||||
}
|
||||
|
||||
<List gap={0}>
|
||||
{
|
||||
allFolders.map((folder) => (
|
||||
publicFolders.map((folder) => (
|
||||
<FolderItem key={folder} folder={folder} staticFolder={settings?.staticFolder} wsFolder={settings?.wsFolder} />
|
||||
))
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const Snippets: React.FunctionComponent<ISnippetsProps> = (props: React.P
|
||||
</FeatureFlag>
|
||||
)}>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col h-full">
|
||||
{
|
||||
viewData?.data?.filePath && (
|
||||
<div className={`text-xl text-center mb-6`}>
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ISponsorMsgProps {
|
||||
export const SponsorMsg: React.FunctionComponent<ISponsorMsgProps> = ({beta, isBacker, version}: React.PropsWithChildren<ISponsorMsgProps>) => {
|
||||
|
||||
return (
|
||||
<p className={`bg-gray-100 dark:bg-vulcan-500 w-full px-4 text-vulcan-50 dark:text-whisper-900 py-2 text-center space-x-8 flex items-center border-t border-gray-200 dark:border-vulcan-300 ${isBacker ? 'justify-center' : 'justify-between'}`}>
|
||||
<footer className={`bg-gray-100 dark:bg-vulcan-500 w-full px-4 text-vulcan-50 dark:text-whisper-900 py-2 text-center space-x-8 flex items-center border-t border-gray-200 dark:border-vulcan-300 ${isBacker ? 'justify-center' : 'justify-between'}`}>
|
||||
{
|
||||
isBacker ? (
|
||||
<span>Front Matter{version ? ` (v${version.installedVersion}${!!beta ? ` BETA` : ''})` : ''}</span>
|
||||
@@ -28,6 +28,6 @@ export const SponsorMsg: React.FunctionComponent<ISponsorMsgProps> = ({beta, isB
|
||||
</>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,7 @@ const Folder = ({ wsFolder, folder, folders, addFolder }: { wsFolder: string, fo
|
||||
|
||||
export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps> = ({settings}: React.PropsWithChildren<IStepsToGetStartedProps>) => {
|
||||
const [framework, setFramework] = useState<string | null>(null);
|
||||
const [taxImported, setTaxImported] = useState<boolean>(false);
|
||||
|
||||
const frameworks: Framework[] = FrameworkDetectors.map((detector: any) => detector.framework);
|
||||
|
||||
@@ -47,6 +48,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
|
||||
|
||||
const reload = () => {
|
||||
const crntState: any = Messenger.getState() || {};
|
||||
|
||||
Messenger.setState({
|
||||
...crntState,
|
||||
isWelcomeConfiguring: false
|
||||
@@ -55,14 +57,21 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
|
||||
Messenger.send(DashboardMessage.reload);
|
||||
};
|
||||
|
||||
const importTaxonomy = () => {
|
||||
Messenger.send(DashboardMessage.importTaxonomy);
|
||||
setTaxImported(true);
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: `welcome-init`,
|
||||
name: 'Initialize project',
|
||||
description: <>Initialize the project with a template folder and sample markdown file. The template folder can be used to define your own templates. <b>Start by clicking on this action</b>.</>,
|
||||
status: settings.initialized ? Status.Completed : Status.NotStarted,
|
||||
onClick: settings.initialized ? undefined : () => { Messenger.send(DashboardMessage.initializeProject); }
|
||||
},
|
||||
{
|
||||
id: `welcome-framework`,
|
||||
name: 'Framework presets',
|
||||
description: (
|
||||
<div>
|
||||
@@ -106,6 +115,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
|
||||
onClick: undefined
|
||||
},
|
||||
{
|
||||
id: `welcome-content-folders`,
|
||||
name: 'Register content folder(s)',
|
||||
description: (
|
||||
<>
|
||||
@@ -136,7 +146,15 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
|
||||
),
|
||||
status: settings.contentFolders && settings.contentFolders.length > 0 ? Status.Completed : Status.NotStarted
|
||||
},
|
||||
{
|
||||
id: `welcome-import`,
|
||||
name: 'Import all tags and categories (optional)',
|
||||
description: <>Now that Front Matter knows all the content folders. Would you like to import all tags and categories from the available content?</>,
|
||||
status: taxImported ? Status.Completed : Status.NotStarted,
|
||||
onClick: settings.contentFolders && settings.contentFolders.length > 0 ? importTaxonomy : undefined
|
||||
},
|
||||
{
|
||||
id: `welcome-show-dashboard`,
|
||||
name: 'Show the dashboard',
|
||||
description: <>Once all actions are completed, the dashboard can be loaded.</>,
|
||||
status: (settings.initialized && settings.contentFolders && settings.contentFolders.length > 0) ? Status.Active : Status.NotStarted,
|
||||
@@ -154,7 +172,7 @@ export const StepsToGetStarted: React.FunctionComponent<IStepsToGetStartedProps>
|
||||
<nav aria-label="Progress">
|
||||
<ol role="list">
|
||||
{steps.map((step, stepIdx) => (
|
||||
<li key={step.name} className={`${stepIdx !== steps.length - 1 ? 'pb-10' : ''} relative`}>
|
||||
<li key={step.id} className={`${stepIdx !== steps.length - 1 ? 'pb-10' : ''} relative`} data-test={step.id}>
|
||||
<Step name={step.name} description={step.description} status={step.status} showLine={stepIdx !== steps.length - 1} onClick={step.onClick} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
103
src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx
Normal file
103
src/dashboardWebView/components/TaxonomyView/TaxonomyActions.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { ArrowCircleUpIcon, ArrowUpIcon, PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { MergeIcon } from '../../../components/icons/MergeIcon';
|
||||
import { DashboardMessage } from '../../DashboardMessage';
|
||||
|
||||
export interface ITaxonomyActionsProps {
|
||||
field: string | null;
|
||||
value: string;
|
||||
unmapped?: boolean;
|
||||
}
|
||||
|
||||
export const TaxonomyActions: React.FunctionComponent<ITaxonomyActionsProps> = ({field, value, unmapped}: React.PropsWithChildren<ITaxonomyActionsProps>) => {
|
||||
|
||||
const onEdit = useCallback(() => {
|
||||
Messenger.send(DashboardMessage.editTaxonomy, {
|
||||
type: field,
|
||||
value
|
||||
});
|
||||
}, [field, value]);
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
Messenger.send(DashboardMessage.addToTaxonomy, {
|
||||
type: field,
|
||||
value
|
||||
});
|
||||
}, [field, value]);
|
||||
|
||||
const onMerge = useCallback(() => {
|
||||
Messenger.send(DashboardMessage.mergeTaxonomy, {
|
||||
type: field,
|
||||
value
|
||||
});
|
||||
}, [field, value]);
|
||||
|
||||
const onMove = useCallback(() => {
|
||||
Messenger.send(DashboardMessage.moveTaxonomy, {
|
||||
type: field,
|
||||
value
|
||||
});
|
||||
}, [field, value]);
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
Messenger.send(DashboardMessage.deleteTaxonomy, {
|
||||
type: field,
|
||||
value
|
||||
});
|
||||
}, [field, value]);
|
||||
|
||||
return (
|
||||
<div className={`space-x-2`}>
|
||||
{
|
||||
unmapped && (
|
||||
<button
|
||||
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 dark:hover:text-whisper-600'
|
||||
type={`button`}
|
||||
title={`Add ${value} to taxonomy settings`}
|
||||
onClick={onAdd}>
|
||||
<PlusIcon className={`w-4 h-4`} aria-hidden={true} />
|
||||
<span className='sr-only'>Add to settings</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 dark:hover:text-whisper-600'
|
||||
type={`button`}
|
||||
title={`Edit ${value}`}
|
||||
onClick={onEdit}>
|
||||
<PencilIcon className={`w-4 h-4`} aria-hidden={true} />
|
||||
<span className='sr-only'>Edit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 dark:hover:text-whisper-600'
|
||||
type={`button`}
|
||||
title={`Merge ${value}`}
|
||||
onClick={onMerge}>
|
||||
<MergeIcon className={`w-4 h-4`} aria-hidden={true} />
|
||||
<span className='sr-only'>Merge</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 dark:hover:text-whisper-600'
|
||||
type={`button`}
|
||||
title={`Move to another taxonomy type`}
|
||||
onClick={onMove}>
|
||||
<ArrowCircleUpIcon className={`w-4 h-4`} aria-hidden={true} />
|
||||
<span className='sr-only'>Move to another taxonomy type</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className='text-gray-500 hover:text-vulcan-600 dark:text-gray-400 dark:hover:text-whisper-600'
|
||||
type={`button`}
|
||||
title={`Delete ${value}`}
|
||||
onClick={onDelete}>
|
||||
<TrashIcon className={`w-4 h-4`} aria-hidden={true} />
|
||||
<span className='sr-only'>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Page } from '../../models';
|
||||
import { SettingsSelector } from '../../state';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { getTaxonomyField } from '../../../helpers/getTaxonomyField';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { routePaths } from '../..';
|
||||
|
||||
export interface ITaxonomyLookupProps {
|
||||
taxonomy: string | null;
|
||||
value: string;
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
export const TaxonomyLookup: React.FunctionComponent<ITaxonomyLookupProps> = ({ taxonomy, value, pages }: React.PropsWithChildren<ITaxonomyLookupProps>) => {
|
||||
const settings = useRecoilValue(SettingsSelector);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const total: number | undefined = useMemo(() => {
|
||||
if (!taxonomy || !value || !pages || !settings?.contentTypes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pages.filter(page => {
|
||||
if (taxonomy === "tags") {
|
||||
return (page.fmTags || []).includes(value);
|
||||
} else if (taxonomy === "categories") {
|
||||
return (page.fmCategories || []).includes(value);
|
||||
}
|
||||
|
||||
const contentType = settings.contentTypes.find(ct => ct.name === page.fmContentType);
|
||||
|
||||
if (!contentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fieldName = getTaxonomyField(taxonomy, contentType);
|
||||
|
||||
return fieldName && page[fieldName] ? page[fieldName].includes(value) : false;
|
||||
}).length;
|
||||
}, [taxonomy, value, pages, settings?.contentTypes]);
|
||||
|
||||
|
||||
const onNavigate = useCallback(() => {
|
||||
if (total) {
|
||||
navigate(`${routePaths.contents}?taxonomy=${taxonomy}&value=${value}`);
|
||||
}
|
||||
}, [total, navigate]);
|
||||
|
||||
if (taxonomy === "tags" || taxonomy === "categories") {
|
||||
return (
|
||||
<button
|
||||
className={total ? `text-teal-900 hover:text-teal-600 font-bold` : ``}
|
||||
title={total ? `Show contents with ${value} in ${taxonomy}` : ``}
|
||||
onClick={onNavigate}>
|
||||
{total || `-`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{total || `-`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
181
src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx
Normal file
181
src/dashboardWebView/components/TaxonomyView/TaxonomyManager.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { ExclamationIcon, PlusSmIcon, TagIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TaxonomyData } from '../../../models';
|
||||
import { DashboardMessage } from '../../DashboardMessage';
|
||||
import { Page } from '../../models';
|
||||
import { SettingsSelector } from '../../state';
|
||||
import { getTaxonomyField } from '../../../helpers/getTaxonomyField';
|
||||
import { TaxonomyActions } from './TaxonomyActions';
|
||||
import { TaxonomyLookup } from './TaxonomyLookup';
|
||||
|
||||
export interface ITaxonomyManagerProps {
|
||||
data: TaxonomyData | undefined;
|
||||
taxonomy: string | null;
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
export const TaxonomyManager: React.FunctionComponent<ITaxonomyManagerProps> = ({ data, taxonomy, pages }: React.PropsWithChildren<ITaxonomyManagerProps>) => {
|
||||
const settings = useRecoilValue(SettingsSelector);
|
||||
|
||||
const onCreate = () => {
|
||||
Messenger.send(DashboardMessage.createTaxonomy, {
|
||||
type: taxonomy
|
||||
});
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (data && taxonomy) {
|
||||
let crntItems: string[] = [];
|
||||
|
||||
if (taxonomy === "tags" || taxonomy === "categories") {
|
||||
crntItems = data[taxonomy];
|
||||
} else {
|
||||
crntItems = data.customTaxonomy.find(c => c.id === taxonomy)?.options || [];
|
||||
}
|
||||
|
||||
// Alphabetically sort the items
|
||||
crntItems = Object.assign([], crntItems).sort((a: string, b: string) => {
|
||||
if (a.toLowerCase() < b.toLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.toLowerCase() > b.toLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return crntItems;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [data, taxonomy]);
|
||||
|
||||
const unmappedItems = useMemo(() => {
|
||||
let unmapped: string[] = [];
|
||||
|
||||
if (!pages || !settings?.contentTypes || !taxonomy) {
|
||||
return unmapped;
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
let values: string[] = [];
|
||||
|
||||
if (taxonomy === "tags") {
|
||||
values = page.fmTags || [];
|
||||
} else if (taxonomy === "categories") {
|
||||
values = page.fmCategories || [];
|
||||
} else {
|
||||
const contentType = settings.contentTypes.find(ct => ct.name === page.fmContentType);
|
||||
|
||||
if (!contentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fieldName = getTaxonomyField(taxonomy, contentType);
|
||||
|
||||
if (fieldName && page[fieldName]) {
|
||||
values = page[fieldName];
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
if (!items.includes(value)) {
|
||||
unmapped.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(unmapped)];
|
||||
}, [items, taxonomy, pages, settings?.contentTypes]);
|
||||
|
||||
return (
|
||||
<div className={`py-6 px-4 flex flex-col h-full overflow-hidden`}>
|
||||
<div className={`flex w-full justify-between flex-shrink-0`}>
|
||||
<div>
|
||||
<h2 className={`text-lg text-gray-500 dark:text-whisper-900 first-letter:uppercase`}>{taxonomy}</h2>
|
||||
<p className={`mt-2 text-sm text-gray-500 dark:text-whisper-900 first-letter:uppercase`}>Create, edit, and manage the {taxonomy} of your site</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className={`inline-flex items-center px-3 py-1 border border-transparent text-xs leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-600 hover:bg-teal-700 focus:outline-none disabled:bg-gray-500`}
|
||||
title={`Create a new ${taxonomy} value`}
|
||||
onClick={onCreate}>
|
||||
<PlusSmIcon className={`mr-2 h-6 w-6`} />
|
||||
<span className={`text-sm`}>Create a new {taxonomy} value</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pb-6 -mr-4 pr-4 flex flex-col flex-grow overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-vulcan-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Count</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-whisper-900 uppercase">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-vulcan-300">
|
||||
{
|
||||
items && items.length > 0 ?
|
||||
items.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<TagIcon className="inline-block h-4 w-4 mr-2" />
|
||||
<span>{item}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<TaxonomyLookup
|
||||
taxonomy={taxonomy}
|
||||
value={item}
|
||||
pages={pages} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<TaxonomyActions
|
||||
field={taxonomy}
|
||||
value={item} />
|
||||
</td>
|
||||
</tr>
|
||||
)) : (
|
||||
!unmappedItems || unmappedItems.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200" colSpan={4}>No {taxonomy} found</td>
|
||||
</tr>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
unmappedItems && unmappedItems.length > 0 &&
|
||||
unmappedItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200" title='Missing in your settings'>
|
||||
<ExclamationIcon className="inline-block h-4 w-4 mr-2" />
|
||||
<span>{item}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
<TaxonomyLookup
|
||||
taxonomy={taxonomy}
|
||||
value={item}
|
||||
pages={pages} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<TaxonomyActions
|
||||
field={taxonomy}
|
||||
value={item}
|
||||
unmapped />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { ChevronRightIcon, DownloadIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TelemetryEvent } from '../../../constants';
|
||||
import { TaxonomyData } from '../../../models';
|
||||
import { DashboardMessage } from '../../DashboardMessage';
|
||||
import { Page } from '../../models';
|
||||
import { SettingsSelector } from '../../state';
|
||||
import { NavigationBar, NavigationItem } from '../Layout';
|
||||
import { PageLayout } from '../Layout/PageLayout';
|
||||
import { SponsorMsg } from '../SponsorMsg';
|
||||
import { TaxonomyManager } from './TaxonomyManager';
|
||||
|
||||
export interface ITaxonomyViewProps {
|
||||
pages: Page[];
|
||||
}
|
||||
|
||||
export const TaxonomyView: React.FunctionComponent<ITaxonomyViewProps> = ({ pages }: React.PropsWithChildren<ITaxonomyViewProps>) => {
|
||||
const settings = useRecoilValue(SettingsSelector);
|
||||
const [ taxonomySettings, setTaxonomySettings ] = useState<TaxonomyData>();
|
||||
const [ selectedTaxonomy, setSelectedTaxonomy ] = useState<string | null>(`tags`);
|
||||
|
||||
const onImport = () => {
|
||||
Messenger.send(DashboardMessage.importTaxonomy);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTaxonomySettings({
|
||||
tags: settings?.tags || [],
|
||||
categories: settings?.categories || [],
|
||||
customTaxonomy: settings?.customTaxonomy || [],
|
||||
});
|
||||
}, [settings?.tags, settings?.categories, settings?.customTaxonomy]);
|
||||
|
||||
useEffect(() => {
|
||||
Messenger.send(DashboardMessage.sendTelemetry, {
|
||||
event: TelemetryEvent.webviewTaxonomyDashboard
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
contentClass={`relative w-full flex-grow flex flex-col mx-auto overflow-hidden`}>
|
||||
|
||||
<div className={`h-full w-full flex`}>
|
||||
<NavigationBar
|
||||
title='Select the taxonomy'
|
||||
bottom={(
|
||||
<button
|
||||
className={`-mb-4 text-xs opacity-80 flex items-center text-gray-500 dark:text-whisper-900 hover:text-gray-700 dark:hover:text-whisper-500`}
|
||||
title="Import taxonomy"
|
||||
onClick={onImport}>
|
||||
<DownloadIcon className={`w-5 mr-2`} />
|
||||
<span>Import taxonomy</span>
|
||||
</button>
|
||||
)}>
|
||||
<NavigationItem
|
||||
isSelected={selectedTaxonomy === "tags"}
|
||||
onClick={() => setSelectedTaxonomy(`tags`)}>
|
||||
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
|
||||
<span>Tags</span>
|
||||
</NavigationItem>
|
||||
|
||||
<NavigationItem
|
||||
isSelected={selectedTaxonomy === "categories"}
|
||||
onClick={() => setSelectedTaxonomy(`categories`)}>
|
||||
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
|
||||
<span>Categories</span>
|
||||
</NavigationItem>
|
||||
|
||||
{
|
||||
taxonomySettings?.customTaxonomy && taxonomySettings.customTaxonomy.map((taxonomy, index) => (
|
||||
<NavigationItem
|
||||
key={`${taxonomy.id}-${index}`}
|
||||
isSelected={selectedTaxonomy === taxonomy.id}
|
||||
onClick={() => setSelectedTaxonomy(taxonomy.id)}>
|
||||
<ChevronRightIcon className='-ml-1 w-5 mr-2' />
|
||||
<span className={`first-letter:uppercase`}>{taxonomy.id}</span>
|
||||
</NavigationItem>
|
||||
))
|
||||
}
|
||||
</NavigationBar>
|
||||
|
||||
<div className={`w-10/12 h-full overflow-hidden`}>
|
||||
<TaxonomyManager
|
||||
data={taxonomySettings}
|
||||
taxonomy={selectedTaxonomy}
|
||||
pages={pages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SponsorMsg beta={settings?.beta} version={settings?.versionInfo} isBacker={settings?.isBacker} />
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
1
src/dashboardWebView/components/TaxonomyView/index.ts
Normal file
1
src/dashboardWebView/components/TaxonomyView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './TaxonomyView';
|
||||
18
src/dashboardWebView/components/UnknownView/UnknownView.tsx
Normal file
18
src/dashboardWebView/components/UnknownView/UnknownView.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StopIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface IUnknownViewProps {}
|
||||
|
||||
export const UnknownView: React.FunctionComponent<IUnknownViewProps> = (props: React.PropsWithChildren<IUnknownViewProps>) => {
|
||||
return (
|
||||
<div className={`w-full h-full flex items-center justify-center`}>
|
||||
<div className='flex flex-col items-center text-gray-500 dark:text-whisper-900'>
|
||||
<StopIcon className='w-32 h-32' />
|
||||
<p className='text-3xl mt-2'>View does not exist</p>
|
||||
<p className='text-xl mt-4'>
|
||||
You seem to have ended up on a view that doesn't exist. Please re-open the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/dashboardWebView/components/UnknownView/index.ts
Normal file
1
src/dashboardWebView/components/UnknownView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './UnknownView';
|
||||
@@ -33,6 +33,8 @@ export default function useMessages() {
|
||||
setView(NavigationType.Contents);
|
||||
} else if (message.data?.type === NavigationType.Data) {
|
||||
setView(NavigationType.Data);
|
||||
} else if (message.data?.type === NavigationType.Taxonomy) {
|
||||
setView(NavigationType.Taxonomy);
|
||||
} else if (message.data?.type === NavigationType.Snippets) {
|
||||
setView(NavigationType.Snippets);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { SortOption } from '../constants/SortOption';
|
||||
import { Tab } from '../constants/Tab';
|
||||
import { Page } from '../models/Page';
|
||||
import Fuse from 'fuse.js';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
import { CategorySelector, FolderSelector, SearchSelector, SettingsSelector, SortingAtom, TabInfoAtom, TabSelector, TagSelector } from '../state';
|
||||
import { SortOrder, SortType } from '../../models';
|
||||
@@ -46,45 +45,6 @@ export default function usePages(pages: Page[]) {
|
||||
});
|
||||
}
|
||||
|
||||
const draftTypes = Object.assign({}, tabInfo);
|
||||
draftTypes[Tab.All] = pagesToShow.length;
|
||||
|
||||
// Filter by draft status
|
||||
if (draftField && draftField.type === 'choice') {
|
||||
const draftChoices = settings?.draftField?.choices;
|
||||
for (const choice of (draftChoices || [])) {
|
||||
if (choice) {
|
||||
draftTypes[choice] = pagesToShow.filter(page => page.fmDraft === choice).length;
|
||||
}
|
||||
}
|
||||
|
||||
if (tab !== Tab.All) {
|
||||
pagesToShow = pagesToShow.filter(page => page.fmDraft === tab);
|
||||
} else {
|
||||
pagesToShow = searchedPages;
|
||||
}
|
||||
} else {
|
||||
// Draft field is a boolean field
|
||||
const draftFieldName = draftField?.name || "draft";
|
||||
|
||||
const drafts = pagesToShow.filter(page => page[draftFieldName] == true || page[draftFieldName] === "true");
|
||||
const published = pagesToShow.filter(page => page[draftFieldName] == false || page[draftFieldName] === "false" || typeof page[draftFieldName] === "undefined");
|
||||
|
||||
draftTypes[Tab.Draft] = draftField?.invert ? published.length : drafts.length;
|
||||
draftTypes[Tab.Published] = draftField?.invert ? drafts.length : published.length;
|
||||
|
||||
if (tab === Tab.Published) {
|
||||
pagesToShow = draftField?.invert ? drafts : published;
|
||||
} else if (tab === Tab.Draft) {
|
||||
pagesToShow = draftField?.invert ? published : drafts;
|
||||
} else {
|
||||
pagesToShow = searchedPages;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the tab information
|
||||
setTabInfo(draftTypes);
|
||||
|
||||
// Sort the pages
|
||||
let pagesSorted: Page[] = Object.assign([], pagesToShow);
|
||||
if (!search) {
|
||||
@@ -131,6 +91,47 @@ export default function usePages(pages: Page[]) {
|
||||
pagesSorted = pagesSorted.filter(page => page.fmCategories && page.fmCategories.includes(category));
|
||||
}
|
||||
|
||||
// Process the tab data
|
||||
const draftTypes = Object.assign({}, tabInfo);
|
||||
draftTypes[Tab.All] = pagesSorted.length;
|
||||
|
||||
// Filter by draft status
|
||||
if (draftField && draftField.type === 'choice') {
|
||||
const draftChoices = settings?.draftField?.choices;
|
||||
for (const choice of (draftChoices || [])) {
|
||||
if (choice) {
|
||||
draftTypes[choice] = pagesSorted.filter(page => page.fmDraft === choice).length;
|
||||
}
|
||||
}
|
||||
|
||||
if (tab !== Tab.All) {
|
||||
pagesSorted = pagesSorted.filter(page => page.fmDraft === tab);
|
||||
} else {
|
||||
pagesSorted = pagesSorted;
|
||||
}
|
||||
} else {
|
||||
// Draft field is a boolean field
|
||||
const draftFieldName = draftField?.name || "draft";
|
||||
|
||||
const drafts = pagesSorted.filter(page => page[draftFieldName] == true || page[draftFieldName] === "true");
|
||||
const published = pagesSorted.filter(page => page[draftFieldName] == false || page[draftFieldName] === "false" || typeof page[draftFieldName] === "undefined");
|
||||
|
||||
draftTypes[Tab.Draft] = draftField?.invert ? published.length : drafts.length;
|
||||
draftTypes[Tab.Published] = draftField?.invert ? drafts.length : published.length;
|
||||
|
||||
if (tab === Tab.Published) {
|
||||
pagesSorted = draftField?.invert ? drafts : published;
|
||||
} else if (tab === Tab.Draft) {
|
||||
pagesSorted = draftField?.invert ? published : drafts;
|
||||
} else {
|
||||
pagesSorted = pagesSorted;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the tab information
|
||||
setTabInfo(draftTypes);
|
||||
|
||||
// Set the pages
|
||||
setPageItems(pagesSorted);
|
||||
}, [ settings, tab, folder, search, tag, category, sorting, tabInfo ]);
|
||||
|
||||
@@ -165,7 +166,7 @@ export default function usePages(pages: Page[]) {
|
||||
} else {
|
||||
processPages(searchedPages);
|
||||
}
|
||||
}, [ settings?.draftField, pages, sorting, search, tab ]);
|
||||
}, [ settings?.draftField, pages, sorting, search, tab, tag, category, folder ]);
|
||||
|
||||
useEffect(() => {
|
||||
Messenger.listen(searchListener);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { App } from "./components/App";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import { SENTRY_LINK } from "../constants";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import './styles.css';
|
||||
import { Preview } from "./components/Preview";
|
||||
|
||||
@@ -14,6 +15,15 @@ declare const acquireVsCodeApi: <T = unknown>() => {
|
||||
postMessage: (msg: unknown) => void;
|
||||
};
|
||||
|
||||
export const routePaths: { [name: string]: string } = {
|
||||
welcome: "/welcome",
|
||||
contents: "/contents",
|
||||
media: "/media",
|
||||
snippets: "/snippets",
|
||||
data: "/data",
|
||||
taxonomy: "/taxonomy",
|
||||
};
|
||||
|
||||
const elm = document.querySelector("#app");
|
||||
if (elm) {
|
||||
const welcome = elm?.getAttribute("data-showWelcome");
|
||||
@@ -37,7 +47,15 @@ if (elm) {
|
||||
if (type === "preview") {
|
||||
render(<Preview url={url} />, elm);
|
||||
} else {
|
||||
render(<RecoilRoot><Dashboard showWelcome={!!welcome} /></RecoilRoot>, elm);
|
||||
render((
|
||||
<RecoilRoot>
|
||||
<MemoryRouter
|
||||
initialEntries={Object.keys(routePaths).map((key: string) => routePaths[key]) as string[]}
|
||||
initialIndex={1}>
|
||||
<App showWelcome={!!welcome} />
|
||||
</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
), elm);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum NavigationType {
|
||||
Media = "media",
|
||||
Data = "data",
|
||||
Snippets = "snippets",
|
||||
Taxonomy = "taxonomy",
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface Page {
|
||||
fmPreviewImage: string;
|
||||
fmTags: string[];
|
||||
fmCategories: string[];
|
||||
fmContentType: string;
|
||||
|
||||
title: string;
|
||||
slug: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DataType } from './../../models/DataType';
|
||||
import { VersionInfo } from '../../models/VersionInfo';
|
||||
import { ContentFolder } from '../../models/ContentFolder';
|
||||
import { ContentType, CustomScript, DraftField, Framework, Snippets, SortingSetting } from '../../models';
|
||||
import { ContentType, CustomScript, CustomTaxonomy, DraftField, Framework, Snippets, SortingSetting } from '../../models';
|
||||
import { SortingOption } from './SortingOption';
|
||||
import { DashboardViewType } from '.';
|
||||
import { DataFile } from '../../models/DataFile';
|
||||
@@ -13,6 +13,7 @@ export interface Settings {
|
||||
staticFolder: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
customTaxonomy: CustomTaxonomy[];
|
||||
openOnStart: boolean | null;
|
||||
versionInfo: VersionInfo;
|
||||
pageViewType: DashboardViewType | undefined;
|
||||
@@ -41,6 +42,7 @@ export interface ContentsViewState {
|
||||
sorting: SortingOption | null | undefined;
|
||||
defaultSorting: string | null | undefined;
|
||||
tags: string | null | undefined;
|
||||
templatesEnabled: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface MediaViewState extends ContentsViewState {
|
||||
|
||||
@@ -79,6 +79,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
Telemetry.send(TelemetryEvent.openDataDashboard);
|
||||
Dashboard.open({ type: NavigationType.Data });
|
||||
}));
|
||||
|
||||
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardTaxonomy, (data?: DashboardData) => {
|
||||
Telemetry.send(TelemetryEvent.openTaxonomyDashboard);
|
||||
Dashboard.open({ type: NavigationType.Taxonomy });
|
||||
}));
|
||||
|
||||
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboardClose, (data?: DashboardData) => {
|
||||
Telemetry.send(TelemetryEvent.closeDashboard);
|
||||
@@ -126,16 +131,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const setLastModifiedDate = vscode.commands.registerCommand(COMMAND_NAME.setLastModifiedDate, Article.setLastModifiedDate);
|
||||
|
||||
const generateSlug = vscode.commands.registerCommand(COMMAND_NAME.generateSlug, Article.generateSlug);
|
||||
|
||||
const createFromTemplate = vscode.commands.registerCommand(COMMAND_NAME.createFromTemplate, (folder: vscode.Uri) => {
|
||||
const folderPath = Folders.getFolderPath(folder);
|
||||
if (folderPath) {
|
||||
Template.create(folderPath);
|
||||
}
|
||||
});
|
||||
|
||||
let createTemplate = vscode.commands.registerCommand(COMMAND_NAME.createTemplate, Template.generate);
|
||||
const generateSlug = vscode.commands.registerCommand(COMMAND_NAME.generateSlug, Article.updateSlug);
|
||||
|
||||
subscriptions.push(
|
||||
vscode.commands.registerCommand(COMMAND_NAME.initTemplate, () => Project.createSampleTemplate(true))
|
||||
@@ -154,6 +150,20 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const createFolder = vscode.commands.registerCommand(COMMAND_NAME.createFolder, Folders.addMediaFolder);
|
||||
|
||||
/**
|
||||
* Template creation
|
||||
*/
|
||||
const createTemplate = vscode.commands.registerCommand(COMMAND_NAME.createTemplate, Template.generate);
|
||||
const createFromTemplate = vscode.commands.registerCommand(COMMAND_NAME.createFromTemplate, (folder: vscode.Uri) => {
|
||||
const folderPath = Folders.getFolderPath(folder);
|
||||
if (folderPath) {
|
||||
Template.create(folderPath);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Content creation
|
||||
*/
|
||||
const createByContentType = vscode.commands.registerCommand(COMMAND_NAME.createByContentType, ContentType.createContent);
|
||||
const createByTemplate = vscode.commands.registerCommand(COMMAND_NAME.createByTemplate, Folders.create);
|
||||
const createContent = vscode.commands.registerCommand(COMMAND_NAME.createContent, Content.create);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ModeListener } from './../listeners/general/ModeListener';
|
||||
import { PagesListener } from './../listeners/dashboard';
|
||||
import { ArticleHelper, Settings } from ".";
|
||||
import { FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_FRAMEWORK_ID, SETTING_TAXONOMY_CONTENT_TYPES, TelemetryEvent } from "../constants";
|
||||
import { ContentType as IContentType, DraftField, Field } from '../models';
|
||||
import { FEATURE_FLAG, SETTING_CONTENT_DRAFT_FIELD, SETTING_DATE_FORMAT, SETTING_FRAMEWORK_ID, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_TAXONOMY_FIELD_GROUPS, TelemetryEvent } from "../constants";
|
||||
import { ContentType as IContentType, DraftField, Field, FieldGroup, FieldType } from '../models';
|
||||
import { Uri, commands, window } from 'vscode';
|
||||
import { Folders } from "../commands/Folders";
|
||||
import { Questions } from "./Questions";
|
||||
@@ -12,6 +12,7 @@ import { DEFAULT_CONTENT_TYPE_NAME } from "../constants/ContentType";
|
||||
import { Telemetry } from './Telemetry';
|
||||
import { processKnownPlaceholders } from './PlaceholderHelper';
|
||||
import { basename } from 'path';
|
||||
import { ParsedFrontMatter } from '../parsers';
|
||||
|
||||
export class ContentType {
|
||||
|
||||
@@ -271,6 +272,147 @@ export class ContentType {
|
||||
ArticleHelper.update(editor!, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the field value
|
||||
* @param data
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
public static getFieldValue(data: any, parents: string[]): string[] {
|
||||
let fieldValue = [];
|
||||
let crntPageData = data;
|
||||
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
const crntField = parents[i];
|
||||
|
||||
if (i === parents.length - 1) {
|
||||
fieldValue = crntPageData[crntField];
|
||||
} else {
|
||||
if (!crntPageData[crntField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crntPageData = crntPageData[crntField];
|
||||
}
|
||||
}
|
||||
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the field value
|
||||
* @param data
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
public static setFieldValue(data: any, parents: string[], value: any) {
|
||||
let crntPageData = data;
|
||||
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
const crntField = parents[i];
|
||||
|
||||
if (i === parents.length - 1) {
|
||||
crntPageData[crntField] = value;
|
||||
} else {
|
||||
if (!crntPageData[crntField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crntPageData = crntPageData[crntField];
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the field by its type
|
||||
* @param fields
|
||||
* @param type
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
public static findFieldByType(fields: Field[], type: FieldType, parents: string[] = []): string[] {
|
||||
for (const field of fields) {
|
||||
if (field.type === type) {
|
||||
parents = [...parents, field.name];
|
||||
return parents;
|
||||
} else if (field.type === "fields" && field.fields) {
|
||||
const subFields = this.findFieldByType(field.fields, type, parents);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the preview field in the fields
|
||||
* @param ctFields
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
public static findPreviewField(ctFields: Field[], parents: string[] = []): string[] {
|
||||
for (const field of ctFields) {
|
||||
if (field.isPreviewImage && field.type === "image") {
|
||||
parents = [...parents, field.name];
|
||||
return parents;
|
||||
} else if (field.type === "fields" && field.fields) {
|
||||
const subFields = this.findPreviewField(field.fields);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
} else if (field.type === "block") {
|
||||
const subFields = this.findPreviewInBlockField(field);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for the preview image in the block field
|
||||
* @param field
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
private static findPreviewInBlockField(field: Field) {
|
||||
const groups = field.fieldGroup && Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
|
||||
if (!groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
|
||||
if (!blocks) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let found = false;
|
||||
for (const group of groups) {
|
||||
const block = blocks.find(block => block.id === group);
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let newParents: string[] = [];
|
||||
if (!found) {
|
||||
newParents = this.findPreviewField(block?.fields, []);
|
||||
}
|
||||
|
||||
if (newParents.length > 0) {
|
||||
found = true;
|
||||
return newParents;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the fields from the data
|
||||
* @param data
|
||||
@@ -362,6 +504,13 @@ export class ContentType {
|
||||
return;
|
||||
}
|
||||
|
||||
let templatePath = contentType.template;
|
||||
let templateData: ParsedFrontMatter | null = null;
|
||||
if (templatePath) {
|
||||
templatePath = Folders.getAbsFilePath(templatePath);
|
||||
templateData = ArticleHelper.getFrontMatterByPath(templatePath);
|
||||
}
|
||||
|
||||
let newFilePath: string | undefined = ArticleHelper.createContent(contentType, folderPath, titleValue);
|
||||
if (!newFilePath) {
|
||||
return;
|
||||
@@ -377,7 +526,7 @@ export class ContentType {
|
||||
}
|
||||
}
|
||||
|
||||
let data: any = this.processFields(contentType, titleValue, {});
|
||||
let data: any = this.processFields(contentType, titleValue, templateData?.data || {});
|
||||
|
||||
data = ArticleHelper.updateDates(Object.assign({}, data));
|
||||
|
||||
@@ -385,7 +534,7 @@ export class ContentType {
|
||||
data['type'] = contentType.name;
|
||||
}
|
||||
|
||||
const content = ArticleHelper.stringifyFrontMatter(``, data);
|
||||
const content = ArticleHelper.stringifyFrontMatter(templateData?.content || ``, data);
|
||||
|
||||
writeFileSync(newFilePath, content, { encoding: "utf8" });
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { basename, join } from "path";
|
||||
import { workspace } from "vscode";
|
||||
import { Folders } from "../commands/Folders";
|
||||
import { Project } from "../commands/Project";
|
||||
import { Template } from "../commands/Template";
|
||||
import { CONTEXT, ExtensionState, SETTING_CONTENT_DRAFT_FIELD, SETTING_CONTENT_SORTING, SETTING_CONTENT_SORTING_DEFAULT, SETTING_CONTENT_STATIC_FOLDER, SETTING_DASHBOARD_OPENONSTART, SETTING_DATA_FILES, SETTING_DATA_FOLDERS, SETTING_DATA_TYPES, SETTING_FRAMEWORK_ID, SETTING_MEDIA_SORTING_DEFAULT, SETTING_CUSTOM_SCRIPTS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_CONTENT_SNIPPETS, SETTING_DATE_FORMAT, SETTING_DASHBOARD_CONTENT_TAGS, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants";
|
||||
import { CONTEXT, ExtensionState, SETTING_CONTENT_DRAFT_FIELD, SETTING_CONTENT_SORTING, SETTING_CONTENT_SORTING_DEFAULT, SETTING_DASHBOARD_OPENONSTART, SETTING_DATA_FILES, SETTING_DATA_FOLDERS, SETTING_DATA_TYPES, SETTING_FRAMEWORK_ID, SETTING_MEDIA_SORTING_DEFAULT, SETTING_CUSTOM_SCRIPTS, SETTING_TAXONOMY_CONTENT_TYPES, SETTING_CONTENT_SNIPPETS, SETTING_DATE_FORMAT, SETTING_DASHBOARD_CONTENT_TAGS, SETTING_MEDIA_SUPPORTED_MIMETYPES, SETTING_TAXONOMY_CUSTOM, SETTING_TEMPLATES_ENABLED } from "../constants";
|
||||
import { DashboardViewType, SortingOption, Settings as ISettings } from "../dashboardWebView/models";
|
||||
import { CustomScript, DraftField, Snippets, SortingSetting, TaxonomyType } from "../models";
|
||||
import { DataFile } from "../models/DataFile";
|
||||
@@ -24,10 +23,11 @@ export class DashboardSettings {
|
||||
return {
|
||||
beta: ext.isBetaVersion(),
|
||||
wsFolder: wsFolder ? wsFolder.fsPath : '',
|
||||
staticFolder: Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER),
|
||||
staticFolder: Folders.getStaticFolderRelativePath(),
|
||||
initialized: isInitialized,
|
||||
tags: Settings.getTaxonomy(TaxonomyType.Tag),
|
||||
categories: Settings.getTaxonomy(TaxonomyType.Category),
|
||||
customTaxonomy: Settings.get(SETTING_TAXONOMY_CUSTOM, true) || [],
|
||||
openOnStart: Settings.get(SETTING_DASHBOARD_OPENONSTART),
|
||||
versionInfo: ext.getVersion(),
|
||||
pageViewType: await ext.getState<DashboardViewType | undefined>(ExtensionState.PagesView, "workspace"),
|
||||
@@ -46,6 +46,7 @@ export class DashboardSettings {
|
||||
sorting: await ext.getState<SortingOption | undefined>(ExtensionState.Dashboard.Contents.Sorting, "workspace"),
|
||||
defaultSorting: Settings.get<string>(SETTING_CONTENT_SORTING_DEFAULT),
|
||||
tags: Settings.get<string>(SETTING_DASHBOARD_CONTENT_TAGS),
|
||||
templatesEnabled: Settings.get<boolean>(SETTING_TEMPLATES_ENABLED)
|
||||
},
|
||||
media: {
|
||||
sorting: await ext.getState<SortingOption | undefined>(ExtensionState.Dashboard.Media.Sorting, "workspace"),
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { Notifications } from './Notifications';
|
||||
import { Uri, workspace } from 'vscode';
|
||||
import { Folders } from '../commands/Folders';
|
||||
import { isValidFile } from './isValidFile';
|
||||
|
||||
export class FilesHelper {
|
||||
|
||||
/**
|
||||
* Retrieve all markdown files from the current project
|
||||
*/
|
||||
public static async getMdFiles(): Promise<Uri[] | null> {
|
||||
const mdFiles = await workspace.findFiles('**/*.md', "**/node_modules/**,**/archetypes/**");
|
||||
const markdownFiles = await workspace.findFiles('**/*.markdown', "**/node_modules/**,**/archetypes/**");
|
||||
const mdxFiles = await workspace.findFiles('**/*.mdx', "**/node_modules/**,**/archetypes/**");
|
||||
if (!mdFiles && !markdownFiles) {
|
||||
Notifications.info(`No MD files found.`);
|
||||
public static async getAllFiles(): Promise<Uri[] | null> {
|
||||
const folderInfo = await Folders.getInfo();
|
||||
const pages: Uri[] = [];
|
||||
|
||||
if (folderInfo) {
|
||||
for (const folder of folderInfo) {
|
||||
for (const file of folder.lastModified) {
|
||||
if (isValidFile(file.fileName)) {
|
||||
pages.push(Uri.file(file.filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
Notifications.warning(`No files found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const allMdFiles = [...mdFiles, ...markdownFiles, ...mdxFiles];
|
||||
return allMdFiles;
|
||||
return pages;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import { dirname, join } from "path";
|
||||
import { Field } from '../models';
|
||||
import { existsSync } from 'fs';
|
||||
import { Folders } from '../commands/Folders';
|
||||
import { Settings } from './SettingsHelper';
|
||||
import { SETTING_CONTENT_STATIC_FOLDER } from '../constants';
|
||||
import { parseWinPath } from './parseWinPath';
|
||||
|
||||
export class ImageHelper {
|
||||
@@ -51,7 +49,7 @@ export class ImageHelper {
|
||||
*/
|
||||
public static relToAbs(filePath: string, value: string) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
const staticFolder = Folders.getStaticFolderRelativePath();
|
||||
|
||||
const staticPath = join(parseWinPath(wsFolder?.fsPath || ""), staticFolder || "", value);
|
||||
const contentFolderPath = filePath ? join(dirname(filePath), value) : null;
|
||||
@@ -73,7 +71,7 @@ export class ImageHelper {
|
||||
*/
|
||||
public static absToRel(imgValue: string) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
const staticFolder = Folders.getStaticFolderRelativePath();
|
||||
|
||||
let relPath = imgValue || "";
|
||||
if (imgValue) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { decodeBase64, Extension, MediaLibrary, Notifications, parseWinPath, Settings, Sorting } from ".";
|
||||
import { Dashboard } from "../commands/Dashboard";
|
||||
import { Folders } from "../commands/Folders";
|
||||
import { DEFAULT_CONTENT_TYPE, ExtensionState, HOME_PAGE_NAVIGATION_ID, SETTING_CONTENT_STATIC_FOLDER, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants";
|
||||
import { DEFAULT_CONTENT_TYPE, ExtensionState, HOME_PAGE_NAVIGATION_ID, SETTING_MEDIA_SUPPORTED_MIMETYPES } from "../constants";
|
||||
import { SortingOption } from "../dashboardWebView/models";
|
||||
import { MediaInfo, MediaPaths, SortOrder, SortType } from "../models";
|
||||
import { basename, extname, join, parse, dirname, relative } from "path";
|
||||
@@ -27,7 +27,7 @@ export class MediaHelpers {
|
||||
*/
|
||||
public static async getMedia(page: number = 0, requestedFolder: string = '', sort: SortingOption | null = null) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
const staticFolder = Folders.getStaticFolderRelativePath();
|
||||
const contentFolders = Folders.get();
|
||||
const viewData = Dashboard.viewData;
|
||||
let selectedFolder = requestedFolder;
|
||||
@@ -210,7 +210,7 @@ export class MediaHelpers {
|
||||
public static async saveFile({fileName, contents, folder}: { fileName: string; contents: string; folder: string | null }) {
|
||||
if (fileName && contents) {
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
const staticFolder = Folders.getStaticFolderRelativePath();
|
||||
const wsPath = wsFolder ? wsFolder.fsPath : "";
|
||||
let absFolderPath = join(wsPath, staticFolder || "");
|
||||
|
||||
@@ -271,10 +271,6 @@ export class MediaHelpers {
|
||||
*/
|
||||
public static async insertMediaToMarkdown(data: any) {
|
||||
if (data?.file && data?.relPath) {
|
||||
if (!data?.position) {
|
||||
await commands.executeCommand(`workbench.view.extension.frontmatter-explorer`);
|
||||
}
|
||||
|
||||
await EditorHelper.showFile(data.file);
|
||||
Dashboard.resetViewData();
|
||||
|
||||
|
||||
@@ -219,10 +219,22 @@ export class Settings {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the taxonomy settings
|
||||
*
|
||||
* @param type
|
||||
*/
|
||||
public static getCustomTaxonomy(type: string): string[] {
|
||||
const customTaxs = Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM, true);
|
||||
if (customTaxs && customTaxs.length > 0) {
|
||||
return customTaxs.find(t => t.id === type)?.options || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the taxonomy settings
|
||||
*
|
||||
* @param config
|
||||
* @param type
|
||||
* @param options
|
||||
*/
|
||||
@@ -259,6 +271,23 @@ export class Settings {
|
||||
await Settings.update(SETTING_TAXONOMY_CUSTOM, customTaxonomies, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the taxonomy settings
|
||||
*
|
||||
* @param type
|
||||
* @param options
|
||||
*/
|
||||
public static async updateCustomTaxonomyOptions(id: string, options: string[]) {
|
||||
const customTaxonomies = Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM, true) || [];
|
||||
let taxIdx = customTaxonomies?.findIndex(o => o.id === id);
|
||||
|
||||
if (taxIdx !== -1) {
|
||||
customTaxonomies[taxIdx].options = options;
|
||||
}
|
||||
|
||||
await Settings.update(SETTING_TAXONOMY_CUSTOM, customTaxonomies, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote settings from local to team level
|
||||
*/
|
||||
|
||||
430
src/helpers/TaxonomyHelper.ts
Normal file
430
src/helpers/TaxonomyHelper.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { getTaxonomyField } from './getTaxonomyField';
|
||||
import { EXTENSION_NAME, SETTING_TAXONOMY_CUSTOM } from "../constants";
|
||||
import { CustomTaxonomy, TaxonomyType, ContentType as IContentType } from "../models";
|
||||
import { FilesHelper } from "./FilesHelper";
|
||||
import { ProgressLocation, window } from "vscode";
|
||||
import { parseWinPath } from "./parseWinPath";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { FrontMatterParser } from "../parsers";
|
||||
import { DumpOptions } from "js-yaml";
|
||||
import { Settings } from "./SettingsHelper";
|
||||
import { Notifications } from "./Notifications";
|
||||
import { ArticleHelper } from './ArticleHelper';
|
||||
import { ContentType } from './ContentType';
|
||||
|
||||
|
||||
export class TaxonomyHelper {
|
||||
|
||||
/**
|
||||
* Rename an taxonomy value
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
public static async rename(data: { type: string, value: string }) {
|
||||
const { type, value } = data;
|
||||
|
||||
const answer = await window.showInputBox({
|
||||
title: `Rename the "${value}"`,
|
||||
value,
|
||||
validateInput: (text) => {
|
||||
if (text === value) {
|
||||
return "The new value must be different from the old one.";
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return "A new value must be provided.";
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
ignoreFocusOut: true
|
||||
});
|
||||
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process("edit", this.getTypeFromString(type), value, answer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a taxonomy value with another one
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
public static async merge(data: { type: string, value: string }) {
|
||||
const { type, value } = data;
|
||||
const taxonomyType = this.getTypeFromString(type);
|
||||
|
||||
let options = [];
|
||||
if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) {
|
||||
options = Settings.getTaxonomy(taxonomyType);
|
||||
} else {
|
||||
options = Settings.getCustomTaxonomy(taxonomyType);
|
||||
}
|
||||
|
||||
const answer = await window.showQuickPick(options.filter(o => o !== value), {
|
||||
title: `Merge the "${value}" with another ${type} value`,
|
||||
placeHolder: `Select the ${type} value to merge with`,
|
||||
ignoreFocusOut: true
|
||||
});
|
||||
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process("merge", taxonomyType, value, answer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a taxonomy value
|
||||
* @param data
|
||||
*/
|
||||
public static async delete(data: { type: string, value: string }) {
|
||||
const { type, value } = data;
|
||||
|
||||
const answer = await window.showQuickPick(["Yes", "No"], {
|
||||
title: `Delete the "${value}" ${type} value`,
|
||||
placeHolder: `Are you sure you want to delete the "${value}" ${type} value?`,
|
||||
ignoreFocusOut: true
|
||||
});
|
||||
|
||||
if (!answer || answer === "No") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process("delete", this.getTypeFromString(type), value, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the taxonomy value to the settings
|
||||
* @param data
|
||||
*/
|
||||
public static addTaxonomy(data: { type: string, value: string }) {
|
||||
const { type, value } = data;
|
||||
this.addToSettings(this.getTypeFromString(type), value, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new taxonomy value
|
||||
* @param data
|
||||
*/
|
||||
public static async createNew(data: { type: string }) {
|
||||
const { type } = data;
|
||||
|
||||
const taxonomyType = this.getTypeFromString(type);
|
||||
const options = this.getTaxonomyOptions(taxonomyType);
|
||||
|
||||
const newOption = await window.showInputBox({
|
||||
title: `Create a new ${taxonomyType} value`,
|
||||
placeHolder: `The value you want to add`,
|
||||
ignoreFocusOut: true,
|
||||
validateInput: (text) => {
|
||||
if (!text) {
|
||||
return "A value must be provided.";
|
||||
}
|
||||
|
||||
if (options.includes(text)) {
|
||||
return "The value already exists.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (!newOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addToSettings(taxonomyType, newOption, newOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the taxonomy changes
|
||||
* @param type
|
||||
* @param taxonomyType
|
||||
* @param oldValue
|
||||
* @param newValue
|
||||
* @returns
|
||||
*/
|
||||
public static async process(type: "edit" | "merge" | "delete", taxonomyType: TaxonomyType | string, oldValue: string, newValue?: string) {
|
||||
// Retrieve all the markdown files
|
||||
const allFiles = await FilesHelper.getAllFiles();
|
||||
if (!allFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
let taxonomyName: string;
|
||||
if (taxonomyType === TaxonomyType.Tag) {
|
||||
taxonomyName = "tags";
|
||||
} else if (taxonomyType === TaxonomyType.Category) {
|
||||
taxonomyName = "categories";
|
||||
} else {
|
||||
taxonomyName = taxonomyType;
|
||||
}
|
||||
|
||||
let progressText = ``;
|
||||
|
||||
if (type === "edit") {
|
||||
progressText = `${EXTENSION_NAME}: Renaming "${oldValue}" from ${taxonomyName} to "${newValue}".`;
|
||||
} else if (type === "merge") {
|
||||
progressText = `${EXTENSION_NAME}: Merging "${oldValue}" from "${taxonomyName}" to "${newValue}".`;
|
||||
} else if (type === "delete") {
|
||||
progressText = `${EXTENSION_NAME}: Deleting "${oldValue}" from "${taxonomyName}".`;
|
||||
}
|
||||
|
||||
window.withProgress({
|
||||
location: ProgressLocation.Notification,
|
||||
title: progressText,
|
||||
cancellable: false
|
||||
}, async (progress) => {
|
||||
// Set the initial progress
|
||||
const progressNr = allFiles.length/100;
|
||||
progress.report({ increment: 0});
|
||||
|
||||
let i = 0;
|
||||
for (const file of allFiles) {
|
||||
progress.report({ increment: (++i/progressNr) });
|
||||
|
||||
const mdFile = readFileSync(parseWinPath(file.fsPath), { encoding: "utf8" });
|
||||
|
||||
if (mdFile) {
|
||||
try {
|
||||
const article = FrontMatterParser.fromFile(mdFile);
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
|
||||
let fieldNames: string[] = this.getFieldsHierarchy(taxonomyType, contentType);
|
||||
|
||||
if (fieldNames.length > 0 && article && article.data) {
|
||||
const { data } = article;
|
||||
let taxonomies: string[] = ContentType.getFieldValue(data, fieldNames);
|
||||
|
||||
if (taxonomies && taxonomies.length > 0) {
|
||||
const idx = taxonomies.findIndex(o => o === oldValue);
|
||||
|
||||
if (idx !== -1) {
|
||||
if (newValue) {
|
||||
taxonomies[idx] = newValue;
|
||||
} else {
|
||||
taxonomies = taxonomies.filter(o => o !== oldValue);
|
||||
}
|
||||
|
||||
const newTaxValue = [...new Set(taxonomies)].sort();
|
||||
ContentType.setFieldValue(data, fieldNames, newTaxValue);
|
||||
|
||||
const spaces = window.activeTextEditor?.options?.tabSize;
|
||||
// Update the file
|
||||
writeFileSync(parseWinPath(file.fsPath), FrontMatterParser.toFile(article.content, article.data, mdFile, {
|
||||
indent: spaces || 2
|
||||
} as DumpOptions as any), { encoding: "utf8" });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue with the next file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.addToSettings(taxonomyType, oldValue, newValue);
|
||||
|
||||
if (type === "edit") {
|
||||
Notifications.info(`Edit completed.`);
|
||||
} else if (type === "merge") {
|
||||
Notifications.info(`Merge completed.`);
|
||||
} else if (type === "delete") {
|
||||
Notifications.info(`Deletion completed.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a taxonomy value to another taxonomy type
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
public static async move(data: { type: string, value: string }) {
|
||||
const { type, value } = data;
|
||||
|
||||
const customTaxs = Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM, true) || [];
|
||||
|
||||
let options = [
|
||||
"tags",
|
||||
"categories",
|
||||
...customTaxs.map(t => t.id)
|
||||
];
|
||||
|
||||
options = options.filter(o => o !== type);
|
||||
|
||||
const answer = await window.showQuickPick(options, {
|
||||
title: `Move the "${value}" to another type`,
|
||||
placeHolder: `Select the type to move to`,
|
||||
ignoreFocusOut: true
|
||||
});
|
||||
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldType = this.getTypeFromString(type);
|
||||
const newType = this.getTypeFromString(answer);
|
||||
|
||||
window.withProgress({
|
||||
location: ProgressLocation.Notification,
|
||||
title: `${EXTENSION_NAME}: Moving "${value}" from ${type} to "${answer}".`,
|
||||
cancellable: false
|
||||
}, async (progress) => {
|
||||
// Retrieve all the markdown files
|
||||
const allFiles = await FilesHelper.getAllFiles();
|
||||
if (!allFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the initial progress
|
||||
const progressNr = allFiles.length/100;
|
||||
progress.report({ increment: 0});
|
||||
|
||||
let i = 0;
|
||||
for (const file of allFiles) {
|
||||
progress.report({ increment: (++i/progressNr) });
|
||||
|
||||
const mdFile = readFileSync(parseWinPath(file.fsPath), { encoding: "utf8" });
|
||||
|
||||
if (mdFile) {
|
||||
try {
|
||||
const article = FrontMatterParser.fromFile(mdFile);
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
|
||||
let oldFieldNames: string[] = this.getFieldsHierarchy(oldType, contentType);
|
||||
let newFieldNames: string[] = this.getFieldsHierarchy(newType, contentType, true);
|
||||
|
||||
if (oldFieldNames.length > 0 && newFieldNames.length > 0 && article && article.data) {
|
||||
const { data } = article;
|
||||
let oldTaxonomies: string[] = ContentType.getFieldValue(data, oldFieldNames) || [];
|
||||
let newTaxonomies: string[] = ContentType.getFieldValue(data, newFieldNames) || [];
|
||||
|
||||
if (oldTaxonomies && oldTaxonomies.length > 0) {
|
||||
const idx = oldTaxonomies.findIndex(o => o === value);
|
||||
|
||||
if (idx !== -1) {
|
||||
newTaxonomies.push(value);
|
||||
|
||||
const newTaxonomiesValues = [...new Set(newTaxonomies)].sort();
|
||||
ContentType.setFieldValue(data, newFieldNames, newTaxonomiesValues);
|
||||
|
||||
const spaces = window.activeTextEditor?.options?.tabSize;
|
||||
// Update the file
|
||||
writeFileSync(parseWinPath(file.fsPath), FrontMatterParser.toFile(article.content, article.data, mdFile, {
|
||||
indent: spaces || 2
|
||||
} as DumpOptions as any), { encoding: "utf8" });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue with the next file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.addToSettings(newType, value, value);
|
||||
|
||||
await this.process("delete", oldType, value);
|
||||
|
||||
Notifications.info(`Move completed.`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieve the fields for the taxonomy field
|
||||
* @returns
|
||||
*/
|
||||
private static getFieldsHierarchy(taxonomyType: TaxonomyType | string, contentType: IContentType, fallback: boolean = false): string[] {
|
||||
let fieldNames: string[] = [];
|
||||
if (taxonomyType === TaxonomyType.Tag) {
|
||||
fieldNames = ContentType.findFieldByType(contentType.fields, "tags");
|
||||
} else if (taxonomyType === TaxonomyType.Category) {
|
||||
fieldNames = ContentType.findFieldByType(contentType.fields, "categories");
|
||||
} else {
|
||||
const taxFieldName = getTaxonomyField(taxonomyType, contentType);
|
||||
fieldNames = taxFieldName ? [taxFieldName] : [];
|
||||
}
|
||||
|
||||
if (fallback && fieldNames.length === 0) {
|
||||
let taxFieldName;
|
||||
if (taxonomyType === TaxonomyType.Tag) {
|
||||
taxFieldName = getTaxonomyField("tags", contentType);
|
||||
} else if (taxonomyType === TaxonomyType.Category) {
|
||||
taxFieldName = getTaxonomyField("categories", contentType);
|
||||
}
|
||||
|
||||
if (taxFieldName) {
|
||||
fieldNames = [taxFieldName];
|
||||
}
|
||||
}
|
||||
|
||||
return fieldNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the taxonomy value to the settings
|
||||
* @param taxonomyType
|
||||
* @param oldValue
|
||||
* @param newValue
|
||||
*/
|
||||
private static async addToSettings(taxonomyType: TaxonomyType | string, oldValue: string, newValue?: string) {
|
||||
// Update the settings
|
||||
let options = this.getTaxonomyOptions(taxonomyType);
|
||||
|
||||
const idx = options.findIndex(o => o === oldValue);
|
||||
if (newValue) {
|
||||
// Add or update the new option
|
||||
if (idx !== -1) {
|
||||
options[idx] = newValue;
|
||||
} else {
|
||||
options.push(newValue);
|
||||
}
|
||||
} else {
|
||||
// Remove the selected option
|
||||
options = options.filter(o => o !== oldValue);
|
||||
}
|
||||
|
||||
if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) {
|
||||
await Settings.updateTaxonomy(taxonomyType, options);
|
||||
} else {
|
||||
await Settings.updateCustomTaxonomyOptions(taxonomyType, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the taxonomy options
|
||||
* @param taxonomyType
|
||||
* @returns
|
||||
*/
|
||||
private static getTaxonomyOptions(taxonomyType: TaxonomyType | string) {
|
||||
let options = [];
|
||||
|
||||
if (taxonomyType === TaxonomyType.Tag || taxonomyType === TaxonomyType.Category) {
|
||||
options = Settings.getTaxonomy(taxonomyType);
|
||||
} else {
|
||||
options = Settings.getCustomTaxonomy(taxonomyType);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the taxonomy type based from the string
|
||||
* @param taxonomyType
|
||||
* @returns
|
||||
*/
|
||||
private static getTypeFromString(taxonomyType: string): TaxonomyType | string {
|
||||
if (taxonomyType === "tags") {
|
||||
return TaxonomyType.Tag;
|
||||
} else if (taxonomyType === "categories") {
|
||||
return TaxonomyType.Category;
|
||||
} else {
|
||||
return taxonomyType;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/helpers/getTaxonomyField.ts
Normal file
16
src/helpers/getTaxonomyField.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ContentType } from '../models';
|
||||
|
||||
|
||||
export const getTaxonomyField = (taxonomyType: string, contentType: ContentType): string | undefined => {
|
||||
let fieldName: string | undefined;
|
||||
|
||||
if (taxonomyType === "tags") {
|
||||
fieldName = contentType.fields.find(f => f.name === "tags")?.name || "tags";
|
||||
} else if (taxonomyType === "categories") {
|
||||
fieldName = contentType.fields.find(f => f.name === "categories")?.name || "categories";
|
||||
} else {
|
||||
fieldName = contentType.fields.find(f => f.type === "taxonomy" && f.taxonomyId === taxonomyType)?.name;
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
}
|
||||
@@ -23,9 +23,11 @@ export * from './SlugHelper';
|
||||
export * from './SnippetParser';
|
||||
export * from './Sorting';
|
||||
export * from './StringHelpers';
|
||||
export * from './TaxonomyHelper';
|
||||
export * from './Telemetry';
|
||||
export * from './decodeBase64Image';
|
||||
export * from './getNonce';
|
||||
export * from './getTaxonomyField';
|
||||
export * from './isValidFile';
|
||||
export * from './openFileInEditor';
|
||||
export * from './parseWinPath';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { workspace } from 'vscode';
|
||||
import { DataFile } from './../../models/DataFile';
|
||||
import { DashboardMessage } from "../../dashboardWebView/DashboardMessage";
|
||||
import { BaseListener } from "./BaseListener";
|
||||
import { DashboardCommand } from '../../dashboardWebView/DashboardCommand';
|
||||
import { Folders } from '../../commands/Folders';
|
||||
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { DataFileHelper } from '../../helpers';
|
||||
@@ -30,6 +31,10 @@ export class DataListener extends BaseListener {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the data update
|
||||
* @param msgData
|
||||
*/
|
||||
private static processDataUpdate(msgData: any) {
|
||||
const { file, fileType, entries } = msgData as { file: string, fileType: string, entries: any[] };
|
||||
|
||||
@@ -41,11 +46,17 @@ export class DataListener extends BaseListener {
|
||||
}
|
||||
}
|
||||
|
||||
const fileContent = readFileSync(absPath, 'utf8');
|
||||
// check if file content ends with newline
|
||||
const newFileContent = fileContent.endsWith('\n');
|
||||
const insertFinalNewLine = newFileContent || workspace.getConfiguration().get('files.insertFinalNewline');
|
||||
|
||||
if (fileType === 'yaml') {
|
||||
const yamlData = yaml.safeDump(entries);
|
||||
writeFileSync(absPath, yamlData, 'utf8');
|
||||
writeFileSync(absPath, insertFinalNewLine ? `${yamlData}\n` : yamlData, 'utf8');
|
||||
} else {
|
||||
writeFileSync(absPath, JSON.stringify(entries, null, 2));
|
||||
const jsonData = JSON.stringify(entries, null, 2);
|
||||
writeFileSync(absPath, insertFinalNewLine ? `${jsonData}\n` : jsonData, 'utf8');
|
||||
}
|
||||
|
||||
this.processDataFile(msgData);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DEFAULT_CONTENT_TYPE_NAME } from './../../constants/ContentType';
|
||||
import { isValidFile } from '../../helpers/isValidFile';
|
||||
import { existsSync, unlinkSync } from "fs";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { commands, FileSystemWatcher, RelativePattern, TextDocument, Uri, workspace } from "vscode";
|
||||
import { Dashboard } from "../../commands/Dashboard";
|
||||
import { Folders } from "../../commands/Folders";
|
||||
import { COMMAND_NAME, DefaultFields, ExtensionState, SETTING_CONTENT_STATIC_FOLDER, SETTING_SEO_DESCRIPTION_FIELD, SETTING_TAXONOMY_FIELD_GROUPS } from "../../constants";
|
||||
import { COMMAND_NAME, DefaultFields, ExtensionState, SETTING_SEO_DESCRIPTION_FIELD } from "../../constants";
|
||||
import { DashboardCommand } from "../../dashboardWebView/DashboardCommand";
|
||||
import { DashboardMessage } from "../../dashboardWebView/DashboardMessage";
|
||||
import { Page } from "../../dashboardWebView/models";
|
||||
@@ -13,7 +14,6 @@ import { ContentType } from "../../helpers/ContentType";
|
||||
import { DateHelper } from "../../helpers/DateHelper";
|
||||
import { Notifications } from "../../helpers/Notifications";
|
||||
import { BaseListener } from "./BaseListener";
|
||||
import { Field, FieldGroup, FieldType } from '../../models';
|
||||
import { DataListener } from '../panel';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
@@ -91,7 +91,7 @@ export class PagesListener extends BaseListener {
|
||||
// Recreate all the watchers
|
||||
for (const folder of folders) {
|
||||
const folderUri = Uri.parse(folder.path);
|
||||
let watcher = workspace.createFileSystemWatcher(new RelativePattern(folderUri, "*"), false, false, false);
|
||||
let watcher = workspace.createFileSystemWatcher(new RelativePattern(folderUri, "**/*"), false, false, false);
|
||||
watcher.onDidCreate(async (uri: Uri) => this.watcherExec(uri));
|
||||
watcher.onDidChange(async (uri: Uri) => this.watcherExec(uri));
|
||||
watcher.onDidDelete(async (uri: Uri) => this.watcherExec(uri));
|
||||
@@ -169,7 +169,7 @@ export class PagesListener extends BaseListener {
|
||||
try {
|
||||
const page = this.processPageContent(file.filePath, file.mtime, file.fileName, folder.title);
|
||||
|
||||
if (page) {
|
||||
if (page && !pages.find(p => p.fmFilePath === page.fmFilePath)) {
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ export class PagesListener extends BaseListener {
|
||||
const modifiedField = ArticleHelper.getModifiedDateField(article) || null;
|
||||
const modifiedFieldValue = modifiedField && article?.data[modifiedField] ? DateHelper.tryParse(article?.data[modifiedField])?.getTime() : undefined;
|
||||
|
||||
const staticFolder = Settings.get<string>(SETTING_CONTENT_STATIC_FOLDER);
|
||||
const staticFolder = Folders.getStaticFolderRelativePath();
|
||||
|
||||
const page: Page = {
|
||||
...article.data,
|
||||
@@ -282,6 +282,7 @@ export class PagesListener extends BaseListener {
|
||||
fmPreviewImage: "",
|
||||
fmTags: [],
|
||||
fmCategories: [],
|
||||
fmContentType: DEFAULT_CONTENT_TYPE_NAME,
|
||||
fmBody: article?.content || "",
|
||||
// Make sure these are always set
|
||||
title: article?.data.title,
|
||||
@@ -292,8 +293,11 @@ export class PagesListener extends BaseListener {
|
||||
};
|
||||
|
||||
const contentType = ArticleHelper.getContentType(article.data);
|
||||
if (contentType) {
|
||||
page.fmContentType = contentType.name;
|
||||
}
|
||||
|
||||
let previewFieldParents = this.findPreviewField(contentType.fields);
|
||||
let previewFieldParents = ContentType.findPreviewField(contentType.fields);
|
||||
if (previewFieldParents.length === 0) {
|
||||
const previewField = contentType.fields.find(field => field.type === "image" && field.name === "preview");
|
||||
if (previewField) {
|
||||
@@ -301,15 +305,11 @@ export class PagesListener extends BaseListener {
|
||||
}
|
||||
}
|
||||
|
||||
let tagParents = this.findFieldByType(contentType.fields, "tags");
|
||||
if (tagParents.length !== 0) {
|
||||
page.fmTags = this.getFieldValue(article.data, tagParents);
|
||||
}
|
||||
let tagParents = ContentType.findFieldByType(contentType.fields, "tags");
|
||||
page.fmTags = ContentType.getFieldValue(article.data, tagParents.length !== 0 ? tagParents : ["tags"]);
|
||||
|
||||
let categoryParents = this.findFieldByType(contentType.fields, "categories");
|
||||
if (categoryParents.length !== 0) {
|
||||
page.fmCategories = this.getFieldValue(article.data, categoryParents);
|
||||
}
|
||||
let categoryParents = ContentType.findFieldByType(contentType.fields, "categories");
|
||||
page.fmCategories = ContentType.getFieldValue(article.data, categoryParents.length !== 0 ? categoryParents : ["categories"]);
|
||||
|
||||
// Check if parent fields were retrieved, if not there was no image present
|
||||
if (previewFieldParents.length > 0) {
|
||||
@@ -375,119 +375,4 @@ export class PagesListener extends BaseListener {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the field value
|
||||
* @param data
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
private static getFieldValue(data: any, parents: string[]): string[] {
|
||||
let fieldValue = [];
|
||||
let crntPageData = data;
|
||||
|
||||
for (let i = 0; i < parents.length; i++) {
|
||||
const crntField = parents[i];
|
||||
|
||||
if (i === parents.length - 1) {
|
||||
fieldValue = crntPageData[crntField];
|
||||
} else {
|
||||
if (!crntPageData[crntField]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
crntPageData = crntPageData[crntField];
|
||||
}
|
||||
}
|
||||
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the field by its type
|
||||
* @param fields
|
||||
* @param type
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
private static findFieldByType(fields: Field[], type: FieldType, parents: string[] = []) {
|
||||
for (const field of fields) {
|
||||
if (field.type === type) {
|
||||
parents = [...parents, field.name];
|
||||
return parents;
|
||||
} else if (field.type === "fields" && field.fields) {
|
||||
const subFields = this.findPreviewField(field.fields);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the preview field in the fields
|
||||
* @param ctFields
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
private static findPreviewField(ctFields: Field[], parents: string[] = []): string[] {
|
||||
for (const field of ctFields) {
|
||||
if (field.isPreviewImage && field.type === "image") {
|
||||
parents = [...parents, field.name];
|
||||
return parents;
|
||||
} else if (field.type === "fields" && field.fields) {
|
||||
const subFields = this.findPreviewField(field.fields);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
} else if (field.type === "block") {
|
||||
const subFields = this.findPreviewInBlockField(field);
|
||||
if (subFields.length > 0) {
|
||||
return [...parents, field.name, ...subFields];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for the preview image in the block field
|
||||
* @param field
|
||||
* @param parents
|
||||
* @returns
|
||||
*/
|
||||
private static findPreviewInBlockField(field: Field) {
|
||||
const groups = field.fieldGroup && Array.isArray(field.fieldGroup) ? field.fieldGroup : [field.fieldGroup];
|
||||
if (!groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks = Settings.get<FieldGroup[]>(SETTING_TAXONOMY_FIELD_GROUPS);
|
||||
if (!blocks) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let found = false;
|
||||
for (const group of groups) {
|
||||
const block = blocks.find(block => block.id === group);
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let newParents: string[] = [];
|
||||
if (!found) {
|
||||
newParents = this.findPreviewField(block?.fields, []);
|
||||
}
|
||||
|
||||
if (newParents.length > 0) {
|
||||
found = true;
|
||||
return newParents;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
57
src/listeners/dashboard/TaxonomyListener.ts
Normal file
57
src/listeners/dashboard/TaxonomyListener.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { commands } from "vscode";
|
||||
import { COMMAND_NAME, SETTING_TAXONOMY_CATEGORIES, SETTING_TAXONOMY_CUSTOM, SETTING_TAXONOMY_TAGS } from "../../constants";
|
||||
import { DashboardCommand } from "../../dashboardWebView/DashboardCommand";
|
||||
import { DashboardMessage } from "../../dashboardWebView/DashboardMessage";
|
||||
import { Settings, TaxonomyHelper } from "../../helpers";
|
||||
import { CustomTaxonomy } from "../../models";
|
||||
import { BaseListener } from "./BaseListener";
|
||||
|
||||
|
||||
export class TaxonomyListener extends BaseListener {
|
||||
|
||||
/**
|
||||
* Process the messages for the dashboard views
|
||||
* @param msg
|
||||
*/
|
||||
public static process(msg: { command: DashboardMessage, data: any }) {
|
||||
super.process(msg);
|
||||
|
||||
switch(msg.command) {
|
||||
case DashboardMessage.getTaxonomyData:
|
||||
this.getData();
|
||||
break;
|
||||
case DashboardMessage.editTaxonomy:
|
||||
TaxonomyHelper.rename(msg.data);
|
||||
break;
|
||||
case DashboardMessage.mergeTaxonomy:
|
||||
TaxonomyHelper.merge(msg.data);
|
||||
break;
|
||||
case DashboardMessage.deleteTaxonomy:
|
||||
TaxonomyHelper.delete(msg.data);
|
||||
break;
|
||||
case DashboardMessage.moveTaxonomy:
|
||||
TaxonomyHelper.move(msg.data);
|
||||
break;
|
||||
case DashboardMessage.addToTaxonomy:
|
||||
TaxonomyHelper.addTaxonomy(msg.data);
|
||||
break;
|
||||
case DashboardMessage.createTaxonomy:
|
||||
TaxonomyHelper.createNew(msg.data);
|
||||
break;
|
||||
case DashboardMessage.importTaxonomy:
|
||||
commands.executeCommand(COMMAND_NAME.exportTaxonomy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async getData() {
|
||||
// Retrieve the tags, categories and custom taxonomy
|
||||
const taxonomyData = {
|
||||
tags: Settings.get<string[]>(SETTING_TAXONOMY_TAGS) || [],
|
||||
categories: Settings.get<string[]>(SETTING_TAXONOMY_CATEGORIES) || [],
|
||||
customTaxonomy: Settings.get<CustomTaxonomy[]>(SETTING_TAXONOMY_CUSTOM) || []
|
||||
};
|
||||
|
||||
this.sendMsg(DashboardCommand.setTaxonomyData, taxonomyData);
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './PagesListener';
|
||||
export * from './SettingsListener';
|
||||
export * from './SnippetListener';
|
||||
export * from './TelemetryListener';
|
||||
export * from './TaxonomyListener';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Article } from "../../commands";
|
||||
import { Command } from "../../panelWebView/Command";
|
||||
import { CommandToCode } from "../../panelWebView/CommandToCode";
|
||||
import { BaseListener } from "./BaseListener";
|
||||
|
||||
@@ -14,7 +15,10 @@ export class ArticleListener extends BaseListener {
|
||||
|
||||
switch(msg.command) {
|
||||
case CommandToCode.updateSlug:
|
||||
Article.generateSlug();
|
||||
Article.updateSlug();
|
||||
break;
|
||||
case CommandToCode.generateSlug:
|
||||
this.generateSlug(msg.data);
|
||||
break;
|
||||
case CommandToCode.updateLastMod:
|
||||
Article.setLastModifiedDate();
|
||||
@@ -24,4 +28,15 @@ export class ArticleListener extends BaseListener {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug
|
||||
* @param title
|
||||
*/
|
||||
private static generateSlug(title: string) {
|
||||
const slug = Article.generateSlug(title);
|
||||
if (slug) {
|
||||
this.sendMsg(Command.updatedSlug, slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,17 +107,20 @@ export class DataListener extends BaseListener {
|
||||
if (keys.length > 0 && contentTypes && wsFolder) {
|
||||
// Get the current content type
|
||||
const contentType = ArticleHelper.getContentType(updatedMetadata);
|
||||
let slugField;
|
||||
if (contentType) {
|
||||
ImageHelper.processImageFields(updatedMetadata, contentType.fields);
|
||||
|
||||
slugField = contentType.fields.find((f) => f.type === "slug");
|
||||
}
|
||||
}
|
||||
|
||||
// Check slug
|
||||
if (!slugField && !updatedMetadata[DefaultFields.Slug]) {
|
||||
const slug = Article.getSlug();
|
||||
|
||||
// Check slug
|
||||
if (!updatedMetadata[DefaultFields.Slug]) {
|
||||
const slug = Article.getSlug();
|
||||
|
||||
if (slug) {
|
||||
updatedMetadata[DefaultFields.Slug] = slug;
|
||||
if (slug) {
|
||||
updatedMetadata[DefaultFields.Slug] = slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface ContentFolder {
|
||||
path: string;
|
||||
|
||||
excludeSubdir?: boolean;
|
||||
previewPath?: string;
|
||||
}
|
||||
@@ -46,9 +46,10 @@ export interface ContentType {
|
||||
fileType?: "md" | "mdx" | string;
|
||||
previewPath?: string | null;
|
||||
pageBundle?: boolean;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file" | "dataFile";
|
||||
export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file" | "dataFile" | "list" | "slug";
|
||||
|
||||
export interface Field {
|
||||
title?: string;
|
||||
@@ -67,6 +68,7 @@ export interface Field {
|
||||
dataType?: string | string[];
|
||||
taxonomyLimit?: number;
|
||||
fileExtensions?: string[];
|
||||
editable?: boolean;
|
||||
|
||||
// Date fields
|
||||
isPublishDate?: boolean;
|
||||
|
||||
8
src/models/TaxonomyData.ts
Normal file
8
src/models/TaxonomyData.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CustomTaxonomy } from ".";
|
||||
|
||||
|
||||
export interface TaxonomyData {
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
customTaxonomy: CustomTaxonomy[];
|
||||
}
|
||||
@@ -15,5 +15,6 @@ export * from './Snippets';
|
||||
export * from './SortOrder';
|
||||
export * from './SortType';
|
||||
export * from './SortingSetting';
|
||||
export * from './TaxonomyData';
|
||||
export * from './TaxonomyType';
|
||||
export * from './VersionInfo';
|
||||
|
||||
@@ -10,4 +10,5 @@ export enum Command {
|
||||
sendMediaUrl = "sendMediaUrl",
|
||||
updatePlaceholder = "updatePlaceholder",
|
||||
dataFileEntries = "dataFileEntries",
|
||||
updatedSlug = "updatedSlug",
|
||||
}
|
||||
@@ -38,4 +38,5 @@ export enum CommandToCode {
|
||||
addMissingFields = "add-missing-fields",
|
||||
setContentType = "set-content-type",
|
||||
getDataEntries = "get-data-entries",
|
||||
generateSlug = "generate-slug",
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface IContentTypeValidatorProps {
|
||||
metadata: IMetadata
|
||||
}
|
||||
|
||||
const fieldsToIgnore = [`filePath`, `articleDetails`, `slug`];
|
||||
const fieldsToIgnore = [`filePath`, `articleDetails`, `slug`, `keywords`];
|
||||
|
||||
export const ContentTypeValidator: React.FunctionComponent<IContentTypeValidatorProps> = ({ fields, metadata}: React.PropsWithChildren<IContentTypeValidatorProps>) => {
|
||||
|
||||
|
||||
@@ -49,8 +49,26 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
}
|
||||
|
||||
let parentObj: any = data;
|
||||
|
||||
if (parents.length > 1) {
|
||||
// Get last parent
|
||||
const lastParent = parents[parents.length - 1];
|
||||
|
||||
// Check if the last parent is not the same as the field.
|
||||
// If it is, then we need to skip it.
|
||||
if (lastParent !== field.name) {
|
||||
if (!parentObj[lastParent]) {
|
||||
parentObj[lastParent] = {};
|
||||
}
|
||||
parentObj = parentObj[lastParent];
|
||||
}
|
||||
}
|
||||
|
||||
// Set the current field to the data object
|
||||
parentObj[crntField] = crntValue;
|
||||
|
||||
// Delete the field group to have it added at the end
|
||||
delete data["fieldGroup"];
|
||||
|
||||
if (selectedIndex !== null && selectedIndex !== undefined) {
|
||||
dataClone[selectedIndex] = {
|
||||
@@ -83,6 +101,10 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
onAdd();
|
||||
}, [value, selectedIndex, onSubmit]);
|
||||
|
||||
/**
|
||||
* On group change
|
||||
* @param group
|
||||
*/
|
||||
const onGroupChange = (group: FieldGroup | null | undefined) => {
|
||||
setSelectedGroup(group);
|
||||
|
||||
@@ -90,7 +112,7 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
// Clear the selected index
|
||||
onAdd();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store the current state + show the form
|
||||
@@ -123,7 +145,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
onAdd();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Add a new item to the list
|
||||
*/
|
||||
const onAdd = useCallback(() => {
|
||||
setSelectedIndex(null);
|
||||
setSelectedGroup(null);
|
||||
@@ -132,6 +156,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
updateSelectionState(undefined);
|
||||
}, [setSelectedIndex, setSelectedGroup, setSelectedBlockData]);
|
||||
|
||||
/**
|
||||
* Update an item from the list
|
||||
*/
|
||||
const onEdit = useCallback((index: number) => {
|
||||
updateSelectionState(index);
|
||||
setSelectedIndex(index);
|
||||
@@ -148,6 +175,9 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
}
|
||||
}, [setSelectedIndex, setSelectedBlockData, value]);
|
||||
|
||||
/**
|
||||
* On sort of an item
|
||||
*/
|
||||
const onSort = useCallback(({ oldIndex, newIndex }: SortEnd) => {
|
||||
if (!value || value.length === 0) {
|
||||
return null;
|
||||
@@ -193,12 +223,12 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (parentBlock === null) {
|
||||
if (parentBlock === null && field.type !== "block") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='json_data__field'>
|
||||
<div className='block_field'>
|
||||
|
||||
<VsLabel>
|
||||
<div className={`metadata_field__label`}>
|
||||
@@ -237,7 +267,7 @@ export const DataBlockField: React.FunctionComponent<IDataBlockFieldProps> = ({
|
||||
selectedIndex: selectedIndex === null ? undefined : selectedIndex
|
||||
},
|
||||
onFieldUpdate,
|
||||
selectedIndex === null ? null : `${field.name}-${selectedGroup?.id}-${selectedIndex}`
|
||||
`${field.name}-${selectedGroup?.id}-${selectedIndex || 0}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
135
src/panelWebView/components/Fields/ListField.tsx
Normal file
135
src/panelWebView/components/Fields/ListField.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { PencilIcon, TrashIcon, ViewListIcon } from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { VsLabel } from '../VscodeComponents';
|
||||
|
||||
export interface IListFieldProps {
|
||||
label: string;
|
||||
value: string[] | null;
|
||||
onChange: (value: string | string[]) => void;
|
||||
}
|
||||
|
||||
export const ListField: React.FunctionComponent<IListFieldProps> = ({ label, value, onChange }: React.PropsWithChildren<IListFieldProps>) => {
|
||||
const [ text, setText ] = React.useState<string | null>("");
|
||||
const [ list, setList ] = React.useState<string[] | null>(null);
|
||||
const [ itemToEdit, setItemToEdit ] = React.useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onTextChange = (txtValue: string) => {
|
||||
setText(txtValue);
|
||||
};
|
||||
|
||||
const onSaveForm = useCallback(() => {
|
||||
if (itemToEdit !== null) {
|
||||
if (list && text) {
|
||||
const newList = [...(list || [])];
|
||||
newList[itemToEdit] = text;
|
||||
setList(newList);
|
||||
onChange(newList);
|
||||
}
|
||||
} else {
|
||||
if (text) {
|
||||
const newList = list ? [ ...list, text ] : [ text ];
|
||||
setList(newList);
|
||||
onChange(newList);
|
||||
}
|
||||
}
|
||||
|
||||
onCancelForm();
|
||||
}, [list, text]);
|
||||
|
||||
const onEdit = useCallback((index: number) => {
|
||||
setItemToEdit(index);
|
||||
setText(list ? list[index] : "");
|
||||
inputRef.current?.focus();
|
||||
}, [list]);
|
||||
|
||||
const onCancelForm = useCallback(() => {
|
||||
setText("");
|
||||
setItemToEdit(null);
|
||||
}, []);
|
||||
|
||||
const onDelete = useCallback((index: number) => {
|
||||
if (list) {
|
||||
const newList = [...list];
|
||||
newList.splice(index, 1);
|
||||
setList(newList);
|
||||
onChange(newList);
|
||||
}
|
||||
}, [list]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
if (typeof value === "string") {
|
||||
setList([value]);
|
||||
} else {
|
||||
setList(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
return (
|
||||
<div className={`list_field`}>
|
||||
<VsLabel>
|
||||
<div className={`metadata_field__label`}>
|
||||
<ViewListIcon style={{ width: "16px", height: "16px" }} /> <span style={{ lineHeight: "16px"}}>{label}</span>
|
||||
</div>
|
||||
</VsLabel>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={`metadata_field__input`}
|
||||
value={text || ""}
|
||||
onChange={(e) => onTextChange(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSaveForm();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
border: "1px solid var(--vscode-inputValidation-infoBorder)"
|
||||
}} />
|
||||
|
||||
<div className={`list_field__form__buttons`}>
|
||||
<button
|
||||
className={`list_field__form__button__save`}
|
||||
title={`Save`}
|
||||
onClick={onSaveForm}
|
||||
disabled={!text}>
|
||||
{itemToEdit !== null ? `Update` : `Add`}
|
||||
</button>
|
||||
<button
|
||||
className={`list_field__form__button__cancel`}
|
||||
title={`Cancel`}
|
||||
onClick={onCancelForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className='list_field__list'>
|
||||
{
|
||||
list && list.length > 0 && list.map((item, index) => (
|
||||
<li className='list_field__list__item' key={index}>
|
||||
<div>
|
||||
{item}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button title='Edit record' className='list_field__list__button list_field__list__button_edit' onClick={() => onEdit(index)}>
|
||||
<PencilIcon className='list_field__list__button_icon' />
|
||||
<span className='sr-only'>Edit</span>
|
||||
</button>
|
||||
<button title='Delete record' className='list_field__list__button list_field__list__button_delete' onClick={() => onDelete(index)}>
|
||||
<TrashIcon className='list_field__list__button_icon' />
|
||||
<span className='sr-only'>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
src/panelWebView/components/Fields/SlugField.tsx
Normal file
82
src/panelWebView/components/Fields/SlugField.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Messenger } from '@estruyf/vscode/dist/client';
|
||||
import { EventData } from '@estruyf/vscode/dist/models';
|
||||
import {LinkIcon, RefreshIcon} from '@heroicons/react/outline';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Command } from '../../Command';
|
||||
import { CommandToCode } from '../../CommandToCode';
|
||||
import { VsLabel } from '../VscodeComponents';
|
||||
|
||||
export interface ISlugFieldProps {
|
||||
label: string;
|
||||
value: string | null;
|
||||
titleValue: string | null;
|
||||
editable?: boolean;
|
||||
onChange: (txtValue: string) => void;
|
||||
}
|
||||
|
||||
export const SlugField: React.FunctionComponent<ISlugFieldProps> = ({ label, editable, value, titleValue, onChange }: React.PropsWithChildren<ISlugFieldProps>) => {
|
||||
const [ text, setText ] = React.useState<string | null>(value);
|
||||
const [ slug, setSlug ] = React.useState<string | null>(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (text !== value) {
|
||||
setText(value);
|
||||
}
|
||||
}, [ value ]);
|
||||
|
||||
const onTextChange = (txtValue: string) => {
|
||||
setText(txtValue);
|
||||
onChange(txtValue);
|
||||
};
|
||||
|
||||
const updateSlug = () => {
|
||||
Messenger.send(CommandToCode.updateSlug);
|
||||
};
|
||||
|
||||
const messageListener = useCallback((message: MessageEvent<EventData<any>>) => {
|
||||
const {command, data} = message.data;
|
||||
if (command === Command.updatedSlug) {
|
||||
setSlug(data?.slugWithPrefixAndSuffix);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (titleValue) {
|
||||
Messenger.send(CommandToCode.generateSlug, titleValue);
|
||||
}
|
||||
}, [titleValue]);
|
||||
|
||||
useEffect(() => {
|
||||
Messenger.listen(messageListener);
|
||||
|
||||
return () => {
|
||||
Messenger.unlisten(messageListener);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`metadata_field`}>
|
||||
<VsLabel>
|
||||
<div className={`metadata_field__label`}>
|
||||
<LinkIcon style={{ width: "16px", height: "16px" }} /> <span style={{ lineHeight: "16px"}}>{label}</span>
|
||||
</div>
|
||||
</VsLabel>
|
||||
|
||||
<div className='metadata_field__slug'>
|
||||
<input
|
||||
className={`metadata_field__slug__input`}
|
||||
value={text || ""}
|
||||
disabled={editable !== undefined ? !editable : false}
|
||||
onChange={(e) => onTextChange(e.currentTarget.value)} />
|
||||
|
||||
<button
|
||||
title={slug !== text ? "Update available" : "Generate slug"}
|
||||
className={`metadata_field__slug__button ${slug !== text ? "metadata_field__slug__button_update" : ""}`}
|
||||
onClick={updateSlug}>
|
||||
<RefreshIcon aria-hidden={true} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,10 @@ import { DataFileField } from './DataFileField';
|
||||
import { DateTimeField } from './DateTimeField';
|
||||
import { DraftField } from './DraftField';
|
||||
import { FileField } from './FileField';
|
||||
import { ListField } from './ListField';
|
||||
import { NumberField } from './NumberField';
|
||||
import { PreviewImageField, PreviewImageValue } from './PreviewImageField';
|
||||
import { SlugField } from './SlugField';
|
||||
import { TextField } from './TextField';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
@@ -360,6 +362,26 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
|
||||
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
|
||||
</FieldBoundary>
|
||||
);
|
||||
} else if (field.type === 'list') {
|
||||
return (
|
||||
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
|
||||
<ListField
|
||||
label={field.title || field.name}
|
||||
value={fieldValue}
|
||||
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
|
||||
</FieldBoundary>
|
||||
);
|
||||
} else if (field.type === 'slug') {
|
||||
return (
|
||||
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
|
||||
<SlugField
|
||||
label={field.title || field.name}
|
||||
titleValue={metadata.title as string}
|
||||
value={fieldValue}
|
||||
editable={field.editable}
|
||||
onChange={(value => onSendUpdate(field.name, value, parentFields))} />
|
||||
</FieldBoundary>
|
||||
);
|
||||
} else {
|
||||
console.warn(`Unknown field type: ${field.type}`);
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
|
||||
.block_field__form {
|
||||
.block_field__form,
|
||||
.list_field__form {
|
||||
background-color: transparent;
|
||||
border: 1px dashed var(--vscode-button-background);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.block_field__form__buttons {
|
||||
.block_field__form__buttons,
|
||||
.list_field__form__buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
@@ -23,7 +25,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.block_field__form__button__save {
|
||||
.block_field__form__button__save,
|
||||
.list_field__form__button__save {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
|
||||
@@ -32,7 +35,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.block_field__form__button__cancel {
|
||||
.block_field__form__button__cancel,
|
||||
.list_field__form__button__cancel {
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
|
||||
@@ -41,7 +45,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.json_data__field {
|
||||
.json_data__field,
|
||||
.block_field,
|
||||
.list_field {
|
||||
border: 1px dashed var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode--settings-headerForeground);
|
||||
padding: .5rem 1rem;
|
||||
@@ -120,7 +126,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.json_data__list {
|
||||
.list_field__list {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.json_data__list,
|
||||
.list_field__list {
|
||||
margin-top: 1rem;
|
||||
|
||||
label {
|
||||
@@ -229,7 +240,8 @@
|
||||
}
|
||||
|
||||
.sortable_item button,
|
||||
.json_data__list__button {
|
||||
.json_data__list__button,
|
||||
.list_field__list__button {
|
||||
width: auto;
|
||||
background: none;
|
||||
color: inherit;
|
||||
@@ -244,7 +256,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.json_data__list__button_icon {
|
||||
.json_data__list__button_icon,
|
||||
.list_field__list__button_icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
@@ -355,6 +368,29 @@ vscode-divider {
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
.article__tags__input button {
|
||||
margin: 1px 0;
|
||||
padding: 0 .5rem;
|
||||
border-left: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
|
||||
&:disabled {
|
||||
background: none;
|
||||
filter: brightness(100%);
|
||||
color: var(--vscode-disabledForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.article__tags__items {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
@@ -420,6 +456,57 @@ vscode-divider {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Slug field */
|
||||
.metadata_field__slug {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadata_field__slug input {
|
||||
padding-right: 2.5rem;
|
||||
border: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
outline: none;
|
||||
|
||||
&:disabled {
|
||||
color: var(--vscode-disabledForeground);
|
||||
}
|
||||
}
|
||||
|
||||
.metadata_field__slug button {
|
||||
background: var(--vscode-input-background);
|
||||
border: none;
|
||||
border-left: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
color: inherit;
|
||||
outline: none !important;
|
||||
outline-offset: inherit !important;
|
||||
margin: 1px;
|
||||
padding: 0 .5rem;
|
||||
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-right: .5rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.metadata_field__slug__button_update {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quill changes */
|
||||
.ql-toolbar.ql-snow,
|
||||
|
||||
24
tsconfig.e2e.json
Normal file
24
tsconfig.e2e.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es6",
|
||||
"outDir": "e2e/out",
|
||||
"lib": [
|
||||
"es6",
|
||||
"dom"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": true,
|
||||
"experimentalDecorators": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"e2e/src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".vscode-test",
|
||||
"docs"
|
||||
"docs",
|
||||
"e2e"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user