Overhauled how documentation works

This commit is contained in:
Stefan Zermatten
2022-11-21 18:22:49 +02:00
parent e619734ee1
commit d2649fd66e
12 changed files with 995 additions and 45 deletions

View File

@@ -1,3 +1,278 @@
if (Meteor.isServer) throw 'Client side only collection, don\'t import on server';
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import SimpleSchema from 'simpl-schema';
import { softRemove } from '/imports/api/parenting/softRemove.js';
import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema.js';
import { storedIconsSchema } from '/imports/api/icons/Icons.js';
import '/imports/api/library/methods/index.js';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
import { restore } from '/imports/api/parenting/softRemove.js';
import { reorderDocs } from '/imports/api/parenting/order.js';
import { getAncestry } from '/imports/api/parenting/parenting.js';
const Docs = new Mongo.Collection('docs');
const RefSchema = new SimpleSchema({
id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
index: 1
},
collection: {
type: String,
max: STORAGE_LIMITS.collectionName,
},
urlName: {
type: String,
regEx: /[a-z]+(?:[a-z]|-)+/,
min: 2,
max: STORAGE_LIMITS.variableName,
optional: true,
},
name: {
type: String,
max: STORAGE_LIMITS.description,
optional: true,
},
});
let ChildSchema = new SimpleSchema({
order: {
type: Number,
},
parent: {
type: RefSchema,
optional: true,
},
ancestors: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.ancestorCount,
},
'ancestors.$': {
type: RefSchema,
},
});
let DocSchema = new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
name: {
type: String,
max: STORAGE_LIMITS.description,
},
urlName: {
type: String,
regEx: /[a-z]+(?:[a-z]|-)+/,
min: 2,
max: STORAGE_LIMITS.variableName,
},
href: {
type: String,
},
description: {
type: String,
optional: true,
},
published: {
type: Boolean,
optional: true,
},
icon: {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
},
});
let schema = new SimpleSchema({});
schema.extend(DocSchema);
schema.extend(ChildSchema);
schema.extend(SoftRemovableSchema);
Docs.attachSchema(schema);
function assertDocsEditPermission(userId) {
if (!userId || typeof userId !== 'string') throw new Meteor.Error('No user id provided');
const user = Meteor.users.findOne(userId);
if (!user) throw new Meteor.Error('User does not exist');
if (!user?.roles?.includes?.('docsWriter')) throw ('Permission denied')
}
function getDocLink(doc, urlName) {
if (!urlName) urlName = doc.urlName;
const address = ['/docs'];
doc.ancestors?.forEach(a => {
address.push(a.urlName);
});
address.push(urlName);
return address.join('/');
}
const insertDoc = new ValidatedMethod({
name: 'docs.insert',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ doc, parentRef }) {
delete doc._id;
assertDocsEditPermission(this.userId);
// get the new ancestry for the properties
if (parentRef) {
var { ancestors } = getAncestry({
parentRef,
inheritedFields: { name: 1, urlName: 1 },
});
}
doc.parent = parentRef;
doc.ancestors = ancestors;
const lastOrder = Docs.find({}, { sort: { order: -1 } }).fetch()[0]?.order || 0;
doc.order = lastOrder + 1;
doc.urlName = 'new-doc-' + (lastOrder + 1);
doc.href = getDocLink(doc);
if (Docs.findOne({ href: doc.href })) {
throw new Meteor.Error('Link collision', 'A document with the same URL already exists');
}
const docId = Docs.insert(doc);
reorderDocs({
collection: Docs,
ancestorId: 'root',
});
return docId;
},
});
const updateDoc = new ValidatedMethod({
name: 'docs.update',
validate({ _id, path }) {
if (!_id) return false;
// We cannot change these fields with a simple update
switch (path[0]) {
case '_is':
return false;
}
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, path, value }) {
assertDocsEditPermission(this.userId);
let pathString = path.join('.');
let modifier;
// unset empty values
if (value === null || value === undefined) {
modifier = { $unset: { [pathString]: 1 } };
} else {
modifier = { $set: { [pathString]: value } };
}
if (pathString === 'urlName') {
const doc = Docs.findOne(_id);
const newLink = getDocLink(doc, value);
if (Docs.findOne({ href: newLink })) {
throw new Meteor.Error('Link collision', 'A document with the same URL already exists');
}
modifier.$set = modifier.$set || {};
modifier.$set.href = newLink;
}
reorderDocs({
collection: Docs,
ancestorId: 'root',
});
return Docs.update(_id, modifier);
},
});
const pushToDoc = new ValidatedMethod({
name: 'docs.push',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, path, value }) {
assertDocsEditPermission(this.userId);
return Docs.update(_id, {
$push: { [path.join('.')]: value },
});
}
});
const pullFromDoc = new ValidatedMethod({
name: 'docs.pull',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, path, itemId }) {
assertDocsEditPermission(this.userId);
return Docs.update(_id, {
$pull: { [path.join('.')]: { _id: itemId } },
});
}
});
const softRemoveDoc = new ValidatedMethod({
name: 'docs.softRemove',
validate: new SimpleSchema({
_id: SimpleSchema.RegEx.Id
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id }) {
assertDocsEditPermission(this.userId);
softRemove({ _id, collection: Docs });
reorderDocs({
collection: Docs,
ancestorId: 'root',
});
}
});
const restoreDoc = new ValidatedMethod({
name: 'docs.restore',
validate: new SimpleSchema({
_id: SimpleSchema.RegEx.Id
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id }) {
assertDocsEditPermission(this.userId);
restore({ _id, collection: Docs });
reorderDocs({
collection: Docs,
ancestorId: 'root',
});
}
});
export {
DocSchema,
insertDoc,
updateDoc,
pushToDoc,
pullFromDoc,
softRemoveDoc,
restoreDoc,
};
export default Docs;

View File

@@ -23,6 +23,7 @@ export default {
type: Number,
default: undefined,
},
singleClick: Boolean,
},
data() {
return {
@@ -52,18 +53,23 @@ export default {
},
methods: {
click() {
this.timesClicked += 1;
this.debounceClicks();
if (this.singleClick) {
this.loading = true;
} else {
this.timesClicked += 1;
this.debounceClicks();
}
this.$emit('click', this.acknowledgeChange);
},
clicks() {
this.$emit('clicks', this.timesClicked, this.acknowledgeChange);
this.loading = true;
this.$emit('clicks', this.timesClicked, this.acknowledgeChange);
this.timesClicked = 0;
},
acknowledgeChange(error){
this.loading = false;
if (error) {
console.error(error)
snackbar({ text: error.reason || error.message || error.toString() });
}
},

View File

@@ -0,0 +1,44 @@
<template>
<v-breadcrumbs
:items="items"
divider=">"
/>
</template>
<script lang="js">
export default {
props: {
doc: {
type: Object,
default: undefined,
},
},
computed: {
items() {
const items = [{
text: 'Home',
to: '/docs',
exact: true,
}];
if (!this.doc?.ancestors) return items;
const address = ['/docs']
this.doc.ancestors?.forEach(a => {
address.push(a.urlName);
items.push({
text: a.name,
to: address.join('/'),
exact: true,
});
});
address.push(this.doc.urlName);
items.push({
text: this.doc.name,
to: address.join('/'),
exact: true,
});
return items;
}
},
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<v-card
:to="doc.href"
class="d-flex flex-column"
@mouseover="hovering = true"
@mouseleave="hovering = false"
>
<v-card-title>
<svg-icon
v-if="doc && doc.icon"
:shape="doc.icon.shape"
class="mr-2"
/>
{{ doc.name }}
</v-card-title>
<v-card-text v-if="doc.description">
<markdown-text
v-if="doc"
:markdown="doc.description"
/>
</v-card-text>
<card-highlight :active="hovering" />
</v-card>
</template>
<script lang="js">
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
import CardHighlight from '/imports/client/ui/components/CardHighlight.vue';
export default {
components: {
MarkdownText,
CardHighlight,
},
props: {
doc: {
type: Object,
required: true,
},
},
data() {return {
hovering: false,
}},
}
</script>
<style scoped>
.v-card {
height: 240px;
}
.v-card__text{
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 95%);
mask-image: linear-gradient(to bottom, black 75%, transparent 95%);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<v-row justify="center">
<template v-if="doc">
<v-col
cols="12"
lg="8"
>
<v-row dense>
<v-col
cols="12"
md="6"
>
<text-field
label="Title"
:value="doc.name"
@change="(value, ack) => change({path: ['name'], value, ack})"
/>
</v-col>
<v-col
cols="12"
md="6"
class="d-flex"
>
<text-field
label="URL title"
:value="doc.urlName"
hint="Only letters, numbers, and dashes"
@change="(value, ack) => change({path: ['urlName'], value, ack})"
/>
<v-menu
bottom
left
transition="slide-y-transition"
>
<template #activator="{ on }">
<v-btn
icon
style="height: 56px; width: 56px;"
v-on="on"
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
@click="remove()"
>
<v-list-item-content>
<v-list-item-title>
Delete
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-icon>mdi-delete</v-icon>
</v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
</v-col>
<v-col
cols="12"
md="6"
>
<smart-switch
label="Published"
:value="doc.published"
@change="(value, ack) => change({path: ['published'], value, ack})"
/>
</v-col>
<v-col
cols="12"
md="6"
class="d-flex align-center"
>
<icon-picker
label="Icon"
:value="doc.icon"
@change="(value, ack) => change({path: ['icon'], value, ack})"
/>
</v-col>
<v-col
cols="12"
>
<text-area
auto-grow
label="Body"
:value="doc.description"
@change="(value, ack) => change({path: ['description'], value, ack})"
/>
</v-col>
</v-row>
</v-col>
</template>
<v-col
cols="12"
lg="8"
>
<v-row>
<v-col
v-for="child in childDocs"
:key="child._id"
cols="12"
sm="6"
md="4"
xl="3"
>
<doc-card :doc="child" />
</v-col>
<v-col
cols="12"
sm="6"
md="4"
xl="3"
>
<smart-btn
single-click
outlined
color="accent"
style="width: 100%; height: 240px;"
@click="ack => add({ ack })"
>
Add child
</smart-btn>
</v-col>
</v-row>
</v-col>
</v-row>
</template>
<script lang="js">
import Docs, {
insertDoc,
pullFromDoc,
pushToDoc,
restoreDoc,
softRemoveDoc,
updateDoc,
} from '/imports/api/docs/Docs.js';
import { get } from 'lodash';
import { snackbar } from '/imports/client/ui/components/snackbars/SnackbarQueue.js';
import DocCard from '/imports/client/ui/docs/DocCard.vue';
import getDocLink from '/imports/client/ui/docs/getDocLink.js';
export default {
components: {
DocCard
},
props: {
doc: {
type: Object,
default: undefined,
},
childDocs: {
type: Array,
required: true,
},
},
computed: {
docId() {
return this.doc?._id;
},
},
methods: {
change({ path, value, ack }) {
updateDoc.call({ _id: this.docId, path, value }, ack);
if (path[0] === 'urlName' && path.length === 1 && value) {
this.$router.push(getDocLink(this.doc, value));
}
},
push({ path, value, ack }) {
pushToDoc.call({ _id: this.docId, path, value }, ack);
},
pull({ path, ack }) {
let itemId = get(this.model, path)._id;
path.pop();
pullFromDoc.call({ _id: this.docId, path, itemId }, ack);
},
add({ ack }) {
insertDoc.call({
doc: {
name: 'New Doc',
},
parentRef: this.doc && {
id: this.docId,
collection: 'docs',
},
}, ack);
},
remove({ ack } = {}) {
const _id = this.docId;
const docName = this.doc.name;
let parentHref = '/docs';
if (this.doc.parent) {
const parent = Docs.findOne({ _id: this.doc.parent.id });
parentHref = parent?.href || parentHref;
}
softRemoveDoc.call({ _id }, (error) => {
ack?.(error);
if (!error) {
snackbar({
text: `Deleted ${docName}`,
callbackName: 'undo',
callback() {
restoreDoc.call({ _id });
},
});
}
});
this.$router.push(parentHref);
},
}
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<v-list-item
:to="doc.href"
style="min-width: 180px;"
>
<v-list-item-avatar v-if="icon">
<svg-icon
v-if="doc && doc.icon"
:shape="doc.icon.shape"
/>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ doc.name }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</template>
<script lang="js">
export default {
props: {
doc: {
type: Object,
required: true,
},
icon: Boolean,
},
}
</script>

View File

@@ -0,0 +1,55 @@
<template lang="html">
<v-app-bar
app
color="secondary"
dark
tabs
extended
dense
>
<v-app-bar-nav-icon @click="toggleDrawer" />
<v-toolbar-title>
Documentation
</v-toolbar-title>
<v-spacer />
<v-btn
v-if="canEdit"
icon
@click="toggleEdit"
>
<v-icon v-if="editing">
mdi-check
</v-icon>
<v-icon v-else>
mdi-pencil
</v-icon>
</v-btn>
</v-app-bar>
</template>
<script lang="js">
import { mapMutations } from 'vuex';
import { Session } from 'meteor/session';
export default {
meteor: {
editing() {
return Session.get('editingDocs');
},
canEdit() {
const user = Meteor.user();
if (!user) return false;
return user.roles?.includes('docsWriter');
}
},
methods: {
...mapMutations([
'toggleDrawer',
]),
toggleEdit() {
if (!this.canEdit) return;
Session.set('editingDocs', !Session.get('editingDocs'));
},
},
}
</script>

View File

@@ -0,0 +1,135 @@
<template>
<v-row justify="center">
<v-col
cols="12"
lg="8"
>
<v-card
style="float: right; z-index: 5;"
class="ma-4"
>
<v-fade-transition mode="out-in">
<v-list
v-if="siblingDocs.length > 1"
>
<doc-list-item
v-for="sibling in siblingDocs"
:key="sibling._id"
:doc="sibling"
:icon="siblingHasIcon"
/>
</v-list>
</v-fade-transition>
</v-card>
<v-fade-transition mode="out-in">
<div
:key="doc && doc.name || 'Documentation Home'"
class="d-flex align-center mb-4"
>
<v-avatar
v-if="(doc && doc.icon) || (!doc)"
size="56"
>
<svg-icon
v-if="doc && doc.icon"
large
:shape="doc.icon.shape"
/>
<v-icon
v-else-if="!doc"
large
>
mdi-home
</v-icon>
</v-avatar>
<h1
v-if="doc"
>
{{ doc.name }}
</h1>
<h1
v-else
>
Documentation
</h1>
</div>
</v-fade-transition>
<v-fade-transition mode="out-in">
<markdown-text
v-if="doc"
:key="doc._id"
:markdown="doc.description"
@click="mdClick"
/>
</v-fade-transition>
</v-col>
<v-col
cols="12"
lg="8"
>
<v-fade-transition
mode="out-in"
leave-absolute
hide-on-leave
>
<v-row :key="doc && doc._id">
<v-col
v-for="child in childDocs"
:key="child._id"
cols="12"
sm="6"
md="4"
xl="3"
>
<doc-card :doc="child" />
</v-col>
</v-row>
</v-fade-transition>
</v-col>
</v-row>
</template>
<script lang="js">
import MarkdownText from '/imports/client/ui/components/MarkdownText.vue';
import DocCard from '/imports/client/ui/docs/DocCard.vue';
import DocListItem from '/imports/client/ui/docs/DocListItem.vue';
import { find } from 'lodash';
export default {
components: {
DocListItem,
MarkdownText,
DocCard,
},
props: {
doc: {
type: Object,
default: undefined,
},
childDocs: {
type: Array,
required: true,
},
siblingDocs: {
type: Array,
required: true,
},
},
computed: {
siblingHasIcon() {
return !!find(this.siblingDocs, doc => doc.icon);
}
},
methods: {
mdClick(e) {
const target = e.target || e.srcElement;
const href = target && target.href;
if (!href) return;
const path = href.split('/docs/')[1];
if (!path) return;
e.preventDefault();
this.$router.push('/docs/' + path);
},
}
}
</script>

View File

@@ -0,0 +1,9 @@
export default function getDocLink(doc, urlName) {
if (!urlName) urlName = doc.urlName;
const address = ['/docs'];
doc.ancestors?.forEach(a => {
address.push(a.urlName);
});
address.push(urlName);
return address.join('/');
}

View File

@@ -0,0 +1,150 @@
<template>
<v-container class="documentation">
<v-row
justify="center"
>
<v-col
cols="12"
md="8"
>
<doc-breadcrumbs :doc="doc" />
</v-col>
</v-row>
<v-fade-transition mode="out-in">
<v-progress-circular
v-if="!$subReady.docs"
key="loading"
indeterminate
color="primary"
size="32"
/>
<v-row
v-else-if="docNotFound"
key="failed"
justify="center"
>
<v-col
cols="12"
md="8"
>
<h1>Documentation not found</h1>
</v-col>
</v-row>
<doc-edit-form
v-else-if="editing"
key="editing"
:doc="doc"
:child-docs="childDocs"
/>
<doc-viewer
v-else
key="viewing"
:doc="doc"
:child-docs="childDocs"
:sibling-docs="siblingDocs"
/>
</v-fade-transition>
</v-container>
</template>
<script lang="js">
import Docs from '/imports/api/docs/Docs.js';
import DocEditForm from '/imports/client/ui/docs/DocEditForm.vue';
import DocViewer from '/imports/client/ui/docs/DocViewer.vue';
import DocBreadcrumbs from '/imports/client/ui/docs/DocBreadcrumbs.vue';
import { Session } from 'meteor/session';
export default {
components: {
DocBreadcrumbs,
DocViewer,
DocEditForm,
},
data() { return {
docNotFound: false,
}},
computed: {
path() {
return this.$route.params.docPath;
},
doc() {
if (!this.docs?.length) return;
return this.docs[this.docs.length - 1];
},
title() {
if (this.doc) {
return this.doc.name;
} else if (this.docNotFound) {
return 'Doc not found';
} else {
return 'Documentation'
}
},
root() {
return !this.path;
}
},
meteor: {
$subscribe: {
'docs': [],
},
docs() {
const docs = [];
this.docNotFound = false;
if (this.root) {
return docs;
}
let currentDoc = undefined;
this.path.split('/').forEach(urlName => {
currentDoc = Docs.findOne({
urlName, 'parent.id': currentDoc?._id,
removed: { $ne: true },
})
if (currentDoc) {
docs.push(currentDoc);
} else {
this.docNotFound = true;
}
});
return docs;
},
childDocs() {
if (!this.doc) return Docs.find({
'parent': undefined,
removed: { $ne: true },
});
return Docs.find({
'parent.id': this.doc._id,
removed: { $ne: true },
})
},
siblingDocs() {
if (!this.doc) return [];
return Docs.find({
'parent.id': this.doc.parent?.id,
removed: { $ne: true },
});
},
editing() {
return Session.get('editingDocs');
},
},
watch: {
title: {
immediate: true,
handler(value) {
this.$store.commit('setPageTitle', value);
}
}
},
}
</script>
<style>
.documentation .fade-transition-enter-active {
transition: all .25s linear !important;
}
.documentation .fade-transition-leave-active {
transition: all .1s linear !important;
}
</style>

View File

@@ -36,7 +36,8 @@ const TabletopRightDrawer = () => import('/imports/client/ui/tabletop/TabletopRi
const Admin = () => import('/imports/client/ui/pages/Admin.vue');
const Maintenance = () => import('/imports/client/ui/pages/Maintenance.vue');
const Files = () => import('/imports/client/ui/pages/Files.vue');
const Documentation = () => import('/imports/client/ui/pages/Documentation.vue');
const DocsPage = () => import('/imports/client/ui/pages/DocsPage.vue');
const DocToolbar = () => import('/imports/client/ui/docs/DocToolbar.vue');
// Not found
const NotFound = () => import('/imports/client/ui/pages/NotFound.vue');
@@ -264,17 +265,10 @@ RouterFactory.configure(router => {
title: 'Functions',
},
}, {
path: '/docs/:docPath([^/]+.*)',
path: '/docs/:docPath([^/]+.*)?',
components: {
default: Documentation,
},
meta: {
title: 'Documentation',
},
}, {
path: '/docs',
components: {
default: Documentation,
default: DocsPage,
toolbar: DocToolbar,
},
meta: {
title: 'Documentation',

View File

@@ -1,35 +1,17 @@
import { propsByDocsPath } from '/imports/constants/PROPERTIES.js';
import Docs from '/imports/api/docs/Docs.js';
// Manual doc paths
const docPaths = [
'computed-fields',
'inline-calculations',
'dependency-loops',
'docs',
'tags',
'walkthroughs/create-a-class',
];
const docs = new Map();
docPaths.forEach(path => {
docs.set(path, Assets.getText(`docs/${path}.md`))
});
// Doc paths for properties
propsByDocsPath.forEach(prop => {
docs.set(prop.docsPath, Assets.getText(`docs/${prop.docsPath}.md`));
});
Meteor.publish('docs', function (path) {
if (!path) {
docs.forEach((text, path) => {
this.added('docs', path, { text });
Meteor.publish('docs', function () {
const filter = { published: true, removed: { $ne: true } };
if (this.userId) {
const user = Meteor.users.findOne(this.userId, {
fields: {
'roles': 1,
}
});
} else {
const text = docs.get(path);
if (text) {
this.added('docs', path, { text });
if (user?.roles?.includes('docsWriter')) {
delete filter.published;
delete filter.removed
}
}
this.ready();
return Docs.find(filter);
});