├── .nvmrc ├── jest.config.js ├── .eslintignore ├── codeship-services.yml ├── .dockerignore ├── sanity.json ├── CONTRIBUTING.md ├── codeship-steps.yml ├── tsconfig.json ├── src ├── component │ ├── types.ts │ ├── utils │ │ ├── get-name-for-type │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── get-edges-from-types │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ └── get-nodes-from-types │ │ │ ├── index.ts │ │ │ └── index.test.ts │ ├── sub-components │ │ ├── switch-with-label │ │ │ └── index.tsx │ │ └── toolbar-header │ │ │ └── index.tsx │ └── index.tsx ├── index.ts └── fixtures.ts ├── v2-incompatible.js ├── tsconfig.dist.json ├── Dockerfile ├── tsconfig.settings.json ├── package.config.ts ├── .gitignore ├── .eslintrc ├── LICENSE ├── README.md ├── .github └── workflows │ └── codeql-analysis.yml ├── package.json └── CODE_OF_CONDUCT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /codeship-services.yml: -------------------------------------------------------------------------------- 1 | build-service: 2 | build: 3 | image: content-model-graph 4 | dockerfile: Dockerfile 5 | cached: true 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.aes 2 | *.decrypted 3 | codeship_deploy_key* 4 | codeship.env 5 | .dockerignore 6 | Dockerfile 7 | codeship* 8 | *.encrypted 9 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Yes, please! 4 | 5 | Repo still needs a way of running locally without a studio, but for now use npm link and develop it against your own studio. 6 | -------------------------------------------------------------------------------- /codeship-steps.yml: -------------------------------------------------------------------------------- 1 | - name: Lint, Build #, Test 2 | type: parallel 3 | service: build-service 4 | steps: 5 | - command: npm run lint 6 | - command: npm t -- --coverage 7 | - command: npm run build 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"], 4 | "compilerOptions": { 5 | "rootDir": ".", 6 | "jsx": "react-jsx", 7 | "noEmit": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/component/types.ts: -------------------------------------------------------------------------------- 1 | export type Field = { 2 | name: string; 3 | to: Array; 4 | type: string; 5 | }; 6 | 7 | export type Type = { 8 | fields: Array; 9 | name: string; 10 | title?: string; 11 | }; 12 | -------------------------------------------------------------------------------- /v2-incompatible.js: -------------------------------------------------------------------------------- 1 | const { showIncompatiblePluginDialog } = require('@sanity/incompatible-plugin'); 2 | 3 | const { name, version, sanityExchangeUrl } = require('./package.json'); 4 | 5 | export default showIncompatiblePluginDialog({ 6 | name: name, 7 | versions: { 8 | v3: version, 9 | v2: '1.2.1', 10 | }, 11 | sanityExchangeUrl, 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ], 10 | "compilerOptions": { 11 | "rootDir": ".", 12 | "outDir": "./dist", 13 | "jsx": "react-jsx", 14 | "emitDeclarationOnly": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | WORKDIR /var/app 4 | 5 | COPY .eslintignore . 6 | COPY .eslintrc . 7 | COPY package-lock.json . 8 | COPY package.json . 9 | COPY package.config.ts . 10 | 11 | RUN npm install 12 | 13 | COPY src ./src 14 | COPY sanity.json . 15 | COPY jest.config.js . 16 | COPY tsconfig.dist.json . 17 | COPY tsconfig.json . 18 | COPY tsconfig.settings.json . 19 | COPY v2-incompatible.js . 20 | -------------------------------------------------------------------------------- /src/component/utils/get-name-for-type/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Field, Type } from '../../types'; 2 | import getNameForType from '.'; 3 | 4 | it('gets name where title is defined', () => 5 | expect(getNameForType({ name: 'bar', title: 'Foo' } as unknown as Field)).toBe('Foo')); 6 | it('gets name where title is undefined, and start cases it', () => 7 | expect(getNameForType({ name: 'bar' } as Type)).toBe('Bar')); 8 | -------------------------------------------------------------------------------- /src/component/utils/get-name-for-type/index.ts: -------------------------------------------------------------------------------- 1 | import { startCase } from 'lodash/fp'; 2 | 3 | import { Field, Type } from '../../types'; 4 | 5 | type FieldOrType = Field | Type; 6 | 7 | const getNameForType = (type: FieldOrType): string | undefined => { 8 | if ('title' in type) { 9 | return type.title; 10 | } 11 | 12 | return startCase(type.name); 13 | }; 14 | 15 | export default getNameForType; 16 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "downlevelIteration": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@sanity/pkg-utils'; 2 | 3 | export default defineConfig({ 4 | legacyExports: true, 5 | dist: 'dist', 6 | tsconfig: 'tsconfig.dist.json', 7 | 8 | // Remove this block to enable strict export validation 9 | extract: { 10 | rules: { 11 | 'ae-forgotten-export': 'off', 12 | 'ae-incompatible-release-tags': 'off', 13 | 'ae-internal-missing-underscore': 'off', 14 | 'ae-missing-release-tag': 'off', 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/component/sub-components/switch-with-label/index.tsx: -------------------------------------------------------------------------------- 1 | import { Inline, Label, Switch } from '@sanity/ui'; 2 | 3 | type SwitchWithLabelProps = { 4 | checked: boolean; 5 | label: string; 6 | onChange: () => void; 7 | }; 8 | const SwitchWithLabel = ({ checked, label, onChange }: SwitchWithLabelProps) => ( 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default SwitchWithLabel; 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { definePlugin } from 'sanity'; 2 | import { FaSitemap } from 'react-icons/fa'; 3 | 4 | import Component from './component'; 5 | 6 | interface ContentModelGraphConfig { 7 | /* nothing here yet */ 8 | } 9 | export const ContentModelGraph = definePlugin((config = {}) => ({ 10 | ...config, 11 | tools: (prev) => { 12 | return [ 13 | ...prev, 14 | { 15 | component: Component, 16 | name: 'sanity-plugin-content-model-graph', 17 | title: 'Content Model Graph', 18 | icon: FaSitemap, 19 | }, 20 | ]; 21 | }, 22 | })); 23 | 24 | export default ContentModelGraph; 25 | -------------------------------------------------------------------------------- /src/component/utils/get-edges-from-types/index.test.ts: -------------------------------------------------------------------------------- 1 | import getEdgesFromTypes from '.'; 2 | import types from '../../../fixtures'; 3 | 4 | it('transforms types to edges', () => { 5 | const output = getEdgesFromTypes(types); 6 | expect(output).toEqual([ 7 | '"memberPerk":offeredBy -> "organization":root [penwidth="2" color="gray30" arrowsize="2" arrowhead="tee"]', 8 | '"memberPerk":urlObject -> "urlObject":root [penwidth="2" color="gray30" arrowsize="2" arrowhead="dot"]', 9 | '"memberPerk":discountCodes -> "discountCode":root [penwidth="2" color="gray30" arrowsize="2" arrowhead="crow"]', 10 | '"memberPerk":discountCodeReferences -> "discountCode":root [penwidth="2" color="gray30" arrowsize="2" arrowhead="crow"]', 11 | ]); 12 | }); 13 | -------------------------------------------------------------------------------- /src/component/utils/get-nodes-from-types/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { Type } from '../../types'; 4 | import getNameForType from '../get-name-for-type'; 5 | 6 | const typeToNode = (isShowingFields: boolean) => (type: Type) => 7 | `"${type.name}" [ label=<${_.map( 12 | isShowingFields ? type.fields : [], 13 | (field) => 14 | ``, 15 | ).join('')}
${getNameForType( 10 | type, 11 | )}
${getNameForType(field)}
> shape="none"]`; 16 | 17 | const getNodesFromTypes = (types: any, isShowingFields: boolean): string[] => _.map(types, typeToNode(isShowingFields)); 18 | 19 | export default getNodesFromTypes; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | # npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist 61 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "@ahmdigital/eslint-config/ts-config", 9 | "@ahmdigital/eslint-config/react-config", 10 | "sanity", 11 | "sanity/typescript", 12 | "sanity/react", 13 | "plugin:import/typescript", 14 | "plugin:react-hooks/recommended", 15 | "plugin:prettier/recommended", 16 | "plugin:react/jsx-runtime" 17 | ], 18 | "settings": { 19 | "import/parsers": { 20 | "@typescript-eslint/parser": [".ts", ".tsx"] 21 | }, 22 | "import/resolver": { 23 | "node": { 24 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 25 | } 26 | } 27 | }, 28 | "rules": { 29 | "jsdoc/require-param-type": "off", 30 | "import/extensions": [ 31 | "error", 32 | "ignorePackages", 33 | { 34 | "js": "never", 35 | "jsx": "never", 36 | "ts": "never", 37 | "tsx": "never", 38 | "mjs": "never" 39 | } 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Josh Edwards 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/fixtures.ts: -------------------------------------------------------------------------------- 1 | const types = [ 2 | { 3 | fields: [ 4 | { 5 | name: 'offeredBy', 6 | to: [ 7 | { 8 | type: 'organization', 9 | }, 10 | ], 11 | type: 'reference', 12 | }, 13 | { 14 | name: 'urlObject', 15 | title: 'URL Object', 16 | type: 'urlObject', 17 | }, 18 | { 19 | name: 'discountCodes', 20 | of: [ 21 | { 22 | type: 'discountCode', 23 | }, 24 | ], 25 | type: 'array', 26 | }, 27 | { 28 | name: 'discountCodeReferences', 29 | of: [ 30 | { 31 | to: [{ type: 'discountCode' }], 32 | type: 'reference', 33 | }, 34 | ], 35 | type: 'array', 36 | }, 37 | ], 38 | name: 'memberPerk', 39 | type: 'document', 40 | }, 41 | { 42 | name: 'organization', 43 | type: 'document', 44 | }, 45 | { 46 | name: 'discountCode', 47 | type: 'document', 48 | }, 49 | { 50 | name: 'urlObject', 51 | type: 'document', 52 | }, 53 | ]; 54 | 55 | export default types; 56 | -------------------------------------------------------------------------------- /src/component/sub-components/toolbar-header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, Flex, Text } from '@sanity/ui'; 2 | import { map } from 'lodash/fp'; 3 | 4 | import SwitchWithLabel from '../switch-with-label'; 5 | 6 | type ToolbarHeaderProps = { 7 | fileDefinitions: any[]; 8 | isShowingEdgeLabels: boolean; 9 | isShowingFields: boolean; 10 | onChangeShowFields: () => void; 11 | onChangeShowEdgeLabels: () => void; 12 | onSaveClicked: (item: any) => void; 13 | }; 14 | 15 | const ToolbarHeader = ({ 16 | fileDefinitions, 17 | isShowingEdgeLabels, 18 | isShowingFields, 19 | onChangeShowFields, 20 | onChangeShowEdgeLabels, 21 | onSaveClicked, 22 | }: ToolbarHeaderProps) => { 23 | return ( 24 | 25 | 26 | 27 | Content Model Graph 28 | 29 | 30 |
31 | 32 | 33 |
34 | 35 | {map( 36 | (item) => ( 37 | 40 | ), 41 | fileDefinitions, 42 | )} 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default ToolbarHeader; 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanity-plugin-content-model-graph 2 | 3 | > This is a **Sanity Studio v3** plugin. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm install sanity-plugin-content-model-graph 9 | ``` 10 | 11 | ## Usage 12 | 13 | Add it as a plugin in `sanity.config.ts` (or .js): 14 | 15 | ```ts 16 | import { defineConfig } from "sanity"; 17 | import { myPlugin } from "sanity-plugin-content-model-graph"; 18 | 19 | export default defineConfig({ 20 | //... 21 | plugins: [myPlugin({})], 22 | }); 23 | ``` 24 | 25 | ## Examples (need updating, it looks nicer now) 26 | 27 | ### Sanity's Movie schema 28 | 29 | ![Screen Shot 1](https://user-images.githubusercontent.com/4197647/68980721-66e8da00-0855-11ea-9d2f-233f69679221.png) 30 | ![Screen Shot 2](https://user-images.githubusercontent.com/4197647/68980734-6e0fe800-0855-11ea-8ec0-d7948ef46014.png) 31 | 32 | ### Sanity's Product schema 33 | 34 | ![Screen Shot 2019-11-16 at 11 52 00 pm](https://user-images.githubusercontent.com/4197647/68993455-77886700-08cc-11ea-8a5c-1653d44fee07.png) 35 | ![Screen Shot 2019-11-16 at 11 52 12 pm](https://user-images.githubusercontent.com/4197647/68993452-77886700-08cc-11ea-8426-02447b894b9f.png) 36 | 37 | ## License 38 | 39 | [MIT](LICENSE) © ahm Digital 40 | 41 | ## Develop & test 42 | 43 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) 44 | with default configuration for build & watch scripts. 45 | 46 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) 47 | on how to run this plugin with hotreload in the studio. 48 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 8 * * 3' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /src/component/utils/get-edges-from-types/index.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import getNameForType from '../get-name-for-type'; 4 | 5 | const toParameter = (key: any, value: any) => (value ? `${key}="${value}"` : ''); 6 | 7 | const builtInFieldTypes = [ 8 | 'array', 9 | 'block', 10 | 'boolean', 11 | 'datetime', 12 | 'file', 13 | 'geopoint', 14 | 'image', 15 | 'number', 16 | 'reference', 17 | 'slug', 18 | 'string', 19 | 'text', 20 | 'url', 21 | ]; 22 | 23 | const ensureArray = (values: any) => (_.isArray(values) ? values : [values]); 24 | 25 | const buildEdge = ({ 26 | arrowHead, 27 | from, 28 | label, 29 | to, 30 | }: { 31 | arrowHead: string; 32 | from: string; 33 | label: string | undefined; 34 | to: string; 35 | }) => { 36 | if (_.includes(builtInFieldTypes, to)) { 37 | return null; 38 | } 39 | return `${from} -> ${`"${to}":root`} [penwidth="2" color="gray30" arrowsize="2" ${[ 40 | toParameter('label', label), 41 | toParameter('arrowhead', arrowHead), 42 | ].join(' ')}]`; 43 | }; 44 | 45 | const getUnusualInlinedFieldType = ({ field, from }: { field: any; from: string }) => 46 | buildEdge({ arrowHead: 'dot', from, label: undefined, to: field.type }); 47 | 48 | const getReferences = ({ 49 | arrowHead, 50 | from, 51 | label, 52 | values, 53 | }: { 54 | arrowHead: string; 55 | from: string; 56 | label: string | undefined; 57 | values: any; 58 | }): any => 59 | _.map(_.compact(ensureArray(values)), ({ type, to }) => { 60 | if (type === 'reference') { 61 | return _.map(_.compact(ensureArray(to)), (toItem) => 62 | getReferences({ arrowHead, from, label, values: { type: toItem.type } }), 63 | ); 64 | } 65 | return buildEdge({ arrowHead, from, label, to: type }); 66 | }); 67 | 68 | const buildFieldToEdges = 69 | ({ fromType, isShowingEdgeLabels }: { fromType: any; isShowingEdgeLabels: boolean }) => 70 | (field: any) => { 71 | const label = isShowingEdgeLabels ? getNameForType(field) : undefined; 72 | const from = `"${fromType}":${field.name}`; 73 | return [ 74 | getUnusualInlinedFieldType({ field, from }), 75 | getReferences({ arrowHead: 'tee', from, label, values: field.to }), 76 | getReferences({ arrowHead: 'crow', from, label, values: field.of }), 77 | ]; 78 | }; 79 | 80 | const typeToEdges = ({ isShowingEdgeLabels, type }: { isShowingEdgeLabels: boolean; type: any }) => 81 | _.map(type.fields, buildFieldToEdges({ fromType: type.name, isShowingEdgeLabels })); 82 | 83 | const getEdgesFromTypes = (types: any, isShowingEdgeLabels: boolean = false): any[] => 84 | _(types) 85 | .map((type) => typeToEdges({ isShowingEdgeLabels, type })) 86 | .flattenDeep() 87 | .compact() 88 | .uniq() 89 | .value(); 90 | 91 | export default getEdgesFromTypes; 92 | -------------------------------------------------------------------------------- /src/component/utils/get-nodes-from-types/index.test.ts: -------------------------------------------------------------------------------- 1 | import getNodesFromTypes from '.'; 2 | import types from '../../../fixtures'; 3 | 4 | it('transforms types to nodes without showing fields', () => { 5 | const isShowingFields = false; 6 | const output = getNodesFromTypes(types, isShowingFields); 7 | expect(output).toEqual([ 8 | '"memberPerk" [ label=<
Member Perk
> shape="none"]', 9 | '"organization" [ label=<
Organization
> shape="none"]', 10 | '"discountCode" [ label=<
Discount Code
> shape="none"]', 11 | '"urlObject" [ label=<
Url Object
> shape="none"]', 12 | ]); 13 | }); 14 | 15 | it('transforms types to nodes with showing fields', () => { 16 | const isShowingFields = true; 17 | const output = getNodesFromTypes(types, isShowingFields); 18 | expect(output).toEqual([ 19 | '"memberPerk" [ label=<
Member Perk
Offered By
URL Object
Discount Codes
Discount Code References
> shape="none"]', 20 | '"organization" [ label=<
Organization
> shape="none"]', 21 | '"discountCode" [ label=<
Discount Code
> shape="none"]', 22 | '"urlObject" [ label=<
Url Object
> shape="none"]', 23 | ]); 24 | }); 25 | -------------------------------------------------------------------------------- /src/component/index.tsx: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash/fp'; 2 | // @ts-ignore - It's complaining about typings here, but it's fine 3 | import { Module, render } from 'viz.js/full.render'; 4 | import { useEffect, useState } from 'react'; 5 | import { useSchema } from 'sanity'; 6 | import _ from 'lodash'; 7 | import FileSaver from 'file-saver'; 8 | import styled from 'styled-components'; 9 | import Viz from 'viz.js'; 10 | 11 | import getEdgesFromTypes from './utils/get-edges-from-types'; 12 | import getNodesFromTypes from './utils/get-nodes-from-types'; 13 | import ToolbarHeader from './sub-components/toolbar-header'; 14 | 15 | const newLine = '\n'; 16 | 17 | const header = [ 18 | 'strict digraph ContentModel {', 19 | 'node [fontname="inherit"];', 20 | 'edge [fontname="inherit"];', 21 | 'rankdir="LR"', 22 | 'concentrate="true"', 23 | ]; 24 | 25 | const Container = styled.div` 26 | background-color: white; 27 | padding: 1rem; 28 | 29 | svg { 30 | display: block; 31 | max-width: 1500px; 32 | } 33 | `; 34 | 35 | const Wrapper = styled.div` 36 | margin-top: 1rem; 37 | margin-bottom: 2rem; 38 | `; 39 | 40 | const footer = ['}']; 41 | 42 | const removeExplicitDimensions = (svgHtml: string) => 43 | _.replace(svgHtml, /width="(.*?)" height="(.*?)"/, 'width="100%" height="100%"'); 44 | 45 | const handleSave = ({ content, fileType, mimeType }: { content: string; fileType: string; mimeType: string }) => { 46 | const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); 47 | FileSaver.saveAs(blob, `content-model.${fileType}`); 48 | }; 49 | 50 | export const ContentModelGraph = () => { 51 | const schema = useSchema(); 52 | const types = get('_original.types', schema); 53 | 54 | const [svgString, setSvgString] = useState(''); 55 | const [isShowingFields, setIsShowingFields] = useState(false); 56 | const [isShowingEdgeLabels, setIsShowingEdgeLabels] = useState(false); 57 | 58 | const edges = getEdgesFromTypes(types, isShowingEdgeLabels); 59 | const nodes = getNodesFromTypes(types, isShowingFields); 60 | 61 | const allItems = [header, edges, nodes, footer]; 62 | 63 | const graphVizString = _.invokeMap(allItems, 'join', newLine).join(newLine); 64 | 65 | useEffect(() => { 66 | const viz = new Viz({ Module, render }); 67 | viz.renderString(graphVizString).then(setSvgString).catch(setSvgString); 68 | }, [graphVizString]); 69 | 70 | const fileDefinitions = [ 71 | { content: svgString, fileType: 'svg', mimeType: 'image/svg+xml' }, 72 | { 73 | content: graphVizString, 74 | fileType: 'gv', 75 | mimeType: 'application/octet-stream', 76 | }, 77 | ]; 78 | 79 | return ( 80 | 81 | setIsShowingFields(!isShowingFields)} 86 | onChangeShowEdgeLabels={() => setIsShowingEdgeLabels(!isShowingEdgeLabels)} 87 | onSaveClicked={handleSave} 88 | /> 89 | 95 | 96 | ); 97 | }; 98 | 99 | export default ContentModelGraph; 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-plugin-content-model-graph", 3 | "version": "2.0.0", 4 | "description": "", 5 | "keywords": [ 6 | "cms", 7 | "content-model", 8 | "content", 9 | "graph", 10 | "graph", 11 | "graphing", 12 | "headless", 13 | "modelling", 14 | "realtime", 15 | "sanity-io", 16 | "sanity-plugin", 17 | "sanity-tool", 18 | "sanity", 19 | "tool", 20 | "visualisation", 21 | "vizualisation" 22 | ], 23 | "homepage": "https://github.com/ahmdigital/content-model-graph#readme", 24 | "bugs": { 25 | "url": "https://github.com/ahmdigital/content-model-graph/issues" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+ssh://git@github.com/ahmdigital/content-model-graph.git" 30 | }, 31 | "license": "MIT", 32 | "author": "ahm digital ", 33 | "exports": { 34 | ".": { 35 | "types": "./dist/index.d.ts", 36 | "source": "./src/index.ts", 37 | "require": "./dist/index.js", 38 | "import": "./dist/index.esm.js", 39 | "default": "./dist/index.esm.js" 40 | }, 41 | "./package.json": "./package.json" 42 | }, 43 | "main": "./dist/index.js", 44 | "module": "./dist/index.esm.js", 45 | "source": "./src/index.ts", 46 | "types": "./dist/index.d.ts", 47 | "files": [ 48 | "dist", 49 | "sanity.json", 50 | "src", 51 | "v2-incompatible.js" 52 | ], 53 | "scripts": { 54 | "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict", 55 | "clean": "rimraf dist", 56 | "format": "prettier --write --cache --ignore-unknown .", 57 | "link-watch": "plugin-kit link-watch", 58 | "lint": "eslint .", 59 | "prepublishOnly": "run-s build", 60 | "test": "jest", 61 | "watch": "pkg-utils watch --strict" 62 | }, 63 | "dependencies": { 64 | "@sanity/incompatible-plugin": "^1.0.4", 65 | "@sanity/ui": "^2.0.3", 66 | "file-saver": "^2.0.5", 67 | "lodash": "^4.17.21", 68 | "react-icons": "^5.0.1", 69 | "styled-components": "^5.3.3", 70 | "viz.js": "^2.1.2" 71 | }, 72 | "devDependencies": { 73 | "@ahmdigital/eslint-config": "7.1.48", 74 | "@sanity/pkg-utils": "4.2.0", 75 | "@sanity/plugin-kit": "3.1.10", 76 | "@types/file-saver": "2.0.7", 77 | "@types/jest": "29.5.12", 78 | "@types/react": "18.2.55", 79 | "@types/styled-components": "^5.1.34", 80 | "@types/viz.js": "^2.1.5", 81 | "@typescript-eslint/eslint-plugin": "6.21.0", 82 | "@typescript-eslint/parser": "6.21.0", 83 | "eslint": "8.56.0", 84 | "eslint-config-prettier": "9.1.0", 85 | "eslint-config-sanity": "7.0.1", 86 | "eslint-plugin-import": "2.29.1", 87 | "eslint-plugin-prettier": "5.1.3", 88 | "eslint-plugin-react": "7.33.2", 89 | "eslint-plugin-react-hooks": "4.6.0", 90 | "jest": "29.7.0", 91 | "npm-run-all": "4.1.5", 92 | "prettier": "3.2.5", 93 | "prettier-plugin-packagejson": "2.4.10", 94 | "react": "18.2.0", 95 | "react-dom": "18.2.0", 96 | "react-is": "18.2.0", 97 | "rimraf": "5.0.5", 98 | "sanity": "3.29.0", 99 | "styled-components": "5.3.11", 100 | "ts-jest": "29.1.2", 101 | "typescript": "5.3.3" 102 | }, 103 | "peerDependencies": { 104 | "react": "^18", 105 | "sanity": "^3" 106 | }, 107 | "engines": { 108 | "node": ">=14" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at cameron.chamberlain@medibank.com.au. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | --------------------------------------------------------------------------------