fixed styleguide

This commit is contained in:
Mason Houtz
2021-02-21 12:25:13 -08:00
parent a568eaee7d
commit be81d2ec09
32 changed files with 612 additions and 48 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ doc/build
doc/schema.md
doc/source/admin/config_logging_default_yaml.rst
doc/source/dev/schema.md
client/styleguide
client/styleguide/dist
# webpack stats
client/webpack-stats.json
+15
View File
@@ -0,0 +1,15 @@
/**
* Component root for rendering inside the styleguide, injects vuex store and other common elements
* into each component example section.
*/
import store from "../src/store";
export default (previewComponent) => {
return {
store,
render(h) {
return h(previewComponent);
},
};
};
+124
View File
@@ -0,0 +1,124 @@
/**
* Functions for generating the sections for the style guide. Has the old style doc glob and a
* recursive tree-walker that looks through the components folder and tries to arrange found
* markdown docs in a tree.
*/
const path = require("path");
const glob = require("glob");
const { humanize, titleize } = require("underscore.string");
/**
* Gets the table of contents sections for documentation in the components folder.
*
* @param {string} rootPath absolute path to components folder
* @return {Object} Rootnode, section map, childMap
*/
function getDocSections(rootPath, options = {}) {
const { ignore = [], docSelector = "*.@(vue|md)" } = options;
const sections = new Map(); // absolute section directory path -> section object
const childToParent = new Map(); // absolute child path -> absolute parent path
// create root node
const rootNode = newSection(rootPath, ignore);
sections.set(rootPath, rootNode);
childToParent.set(rootPath, null);
// const singleGlob = path.join(rootPath, "**/*.(md|js|vue)");
const selector = path.join(rootPath, "**", docSelector);
const allFiles = glob.sync(selector, { ignore });
allFiles.forEach((file) => {
const p = path.parse(file);
// intermediate dir names between this dir and root
const relPath = path.relative(rootPath, p.dir);
const midFolders = relPath.split(path.sep);
// build intermediate parent sections in the event that this is deeply-nested
while (midFolders.length) {
const sectionPath = path.join(rootPath, ...midFolders);
if (!sections.has(sectionPath)) {
// create new section
const section = newSection(sectionPath, ignore);
sections.set(sectionPath, section);
// register relationship for tree-build later
const parentPath = path.join(sectionPath, "..");
childToParent.set(sectionPath, parentPath);
}
midFolders.pop();
}
// add file to section now that it exists
const section = sections.get(p.dir);
if (section && file !== section.content) {
section.children.add({
name: titleize(humanize(p.name)),
content: file,
});
}
});
// assemble recursive section tree under rootNode
buildSectionTree(sections, childToParent);
// rootNode is the main result, returning the maps so the user has the option to manipulate the
// tree before handing it over to the styleguide configs
return { sections, childToParent, rootNode };
}
/**
* Creates a new section in the TOC from passed path.
*
* @param {string} dir absolute directory path
* @return {Object} section object
*/
function newSection(dir, ignore = []) {
const section = {
name: titleize(humanize(path.basename(dir))),
components: () => glob.sync(path.join(dir, "*.vue"), { ignore }),
children: new Set(),
sectionDepth: 1,
};
// summary doc is readme/docs/index.md
const summarySelector = path.join(dir, "@(readme|docs|index).md");
const summaryDocs = glob.sync(summarySelector, { ignore, nocase: true });
if (summaryDocs.length) {
section.content = summaryDocs[0];
}
return section;
}
/**
* Turns the pair of maps into a nested object for use in the styleguide config script. Operates on
* sections array by reference.
*
* @param {Map} sections Map of path -> section object
* @param {Map} childToParent Map of childPath -> parentPath
*/
function buildSectionTree(sections, childToParent) {
// add each child to its parent
for (const [childPath, parentPath] of childToParent) {
const child = sections.get(childPath);
const parent = sections.get(parentPath);
if (child && parent && parent.children) {
parent.children.add(child);
}
}
// convert child Sets to arrays now that de-dupe not required
for (const section of sections.values()) {
section.sections = Array.from(section.children).sort((a, b) => a.name.localeCompare(b.name));
delete section.children;
}
}
module.exports = {
// public function
getDocSections,
// exporting these in case user wants to tweak the result tree and rebuild it
buildSectionTree,
newSection,
};
@@ -0,0 +1,14 @@
Don't directly reference window.Galaxy in Vue components
You've got components. Components take props. Please pass in any values you might need from
window.Galaxy as props and avoid referencing global Galaxy inside your components. I've even created
basic providers which give you access to the Galaxy.config, current user, and current user
histories. Please use them to retrieve your values, and bypass importing Galaxy altogether.
There are use-cases where the Backbone models update over time and we need to update some value
inside Vue. Let me help you solve those problems instead of importing backbone models into Vue
components. Usually the answer is a backbone event listener that updates some relevant Vuex store.
When writing a new component, your goal should be to replace old Galaxy functionality, not to
repackage it in another format so we continue to have Galaxy's inherent problems, the most important
of which is...
@@ -0,0 +1,14 @@
### Classes exist but they aren't as important as you may be used to.
You may come from a class-based programming background and think that your first step should be to
make a class hierchy that does the thing you want. It doesn't help that Vue superficially looks like
a class definition, (even though what you're really doing is configuring an Observable tree).
### Javascript is a pretty functional language.
Javascript is largely a functional language. Its class support is limited and less useful, and
unless we switch to Typescript, we don't even have interfaces or typing, arguably the most useful
parts of a class-based language.
In general, you will get more mileage out of javascript by embracing functional programming
approaches because that is what Javascript is good at.
@@ -0,0 +1,14 @@
Did you know, jQuery is old enough to drive? It's old enough to get a driver's license. jQuery is a
tool that was built to deal with inconsistencies in browsers that NO LONGER EXIST. In a couple
years, jQuery will be voting, drinking, and capable of being tried as an adult.
If you think you need jQuery, you are mistaken. Please seek help from somebody in the wg-ui-ux
workgroup. There is nothing jQuery can provide you that isn't already part of vanilla javascript or
a standard well-tested 3rd party modern npm module.
But that's not even the main problem. jQuery injects global initializations into every page on the
site whether you want it or not. jQuery leverages an outdated initialization paradigm which is
hugely problematic when it comes to unit testing and module building.
One of our most important goals in redesigning Galaxy is the complete elimination of this library
from our source, along with all its invasive plugins.
@@ -0,0 +1,164 @@
We are using components in two very distinct ways. The first, "normal", kind of component will
probably look familiar to anybody whis is already passingly familiar with Vue. Here the relevant
information comes in as properties, any internal variables get defined in "data", changes go out as
events.
## Composition Example
```html static
<!-- Use of a renderless component with a display component -->
<DoodadProvider v-slot="{ doodad, saveDoodad }">
<DoodadEditor :doodad="doodad" @update:doodad="saveDoodad" />
</DoodadProvider>
```
In this example, we've created a component whose job is to deal with loading and updating the doodad
object. Notice that there is no markup inside the DoodadProvider other than the explicit renderless
component we previously made, but you are free to putput whatever you want in there, accessing the
doodad and saveDoddad properties as desired, as well as any other local data with the only
restriction that Vue needs a single root element in which to render.
## The Renderer
```html static
<!-- DoodadEditor.vue, a simple "rendering" component -->
<template>
<AutoComplete
:options="options"
:value="doodad.category"
@select="saveCategory"
/>
</template>
<script>
export default {
props: {
doodad: { type: Object, required: true },
options: { type: Array, required: true },
},
methods: {
saveCategory(newCategory) {
this.$emit('update:doodad', { ...this.doodad, category: newCategory });
}
}
}
</script>
```
This component accepts a mandatory input object (doodad), lets the user play with a category prop,
then emits a fresh object after it's done. So what, what's the big deal? The important part to walk
away from this dumb example is the things that are NOT in this sample component.
This component doesn't save the data. This component doesn't make ajax calls, and this component
doesn't mutate its props. What it does do is to allow the user to edit some object named "doodad"
and emits a new fresh version of that doodad when it's done. (Note also that we are using the
update:propname event syntax whenever possible [to facilitate .sync
binds](https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier)).
Whatever happens to that new object is somebody else's job. As soon as you tie the data management
to the rendering, the re-usability of your components craters.
## The Provider
As the opposite of the rendering component, a provider or renderless component, is pure lobic. It
should not know or care what your renderer is going to do with the data it provides. It is simply a
fancy way of configuring some data manipulation methods. This is one of the many ways of reusing
functionality available in Vue. Some others are [Mixins](https://vuejs.org/v2/guide/mixins.html),
[Provide/Inject](https://v3.vuejs.org/guide/component-provide-inject.html) and (in Vue3) [the
composition API](https://v3.vuejs.org/guide/composition-api-introduction.html).
```js static
// DoodadProvider.js
import { loadDoodad, saveDoodad } from "./someAjaxQueryModule";
export default {
data() {
return {
loaded: false,
doodad: null
}
},
methods: {
async saveDoodad(newVal) {
this.loaded = false;
const newVal = await saveDoodad(newVal);
this.doodad = newVal;
this.loaded = true;
},
async loadDoodad(newVal) {
this.loaded = false;
const newVal = await loadDoodad(newVal);
this.loaded = true;
return newVal;
}
},
async created() {
this.doodad = await loadDoodad();
},
render() {
return return this.$scopedSlots.default({
loaded: this.loaded,
doodad: this.doodad,
saveDoodad: this.saveDoodad
});
}
}
```
Here is an example of a simple possible renderless provider. The important part about this is the
render() function which simply renders [one big default
slot](https://vuejs.org/v2/guide/components-slots.html) and binds some of its own properties to that
slot for use by downstream components.
## Unit Testing a renderless component
Testing of a component like our editor is pretty straightforward and follows the standard Vue
guidelines. If you did a clean-enough job you won't even need to mock anything since all your data
dependencies should be delivered via props.
But it's not so obvious how to unit-test a renderless provider. What do you check for? There's no
mandatory markup, just one big empty slot.
```js static
// Testing a renderless component
import { shallowMount } from "@vue/test-utils";
import { getLocalVue, waitForLifecyleEvent } from "jest/helpers";
import DoodadProvider from "./DoodadProvider";
describe("A renderless component", () => {
const localVue = getLocalVue();
let wrapper;
let slotProps;
beforeEach(async () => {
wrapper = shallowMount(DoodadProvider, {
localVue,
// The mount fn allows you to hook into a slot for this very reason
scopedSlots: {
default(props) {
slotProps = props;
},
},
});
// waits for "updated" Vue lifecycle hook to fire on the renderless
// component. This is often good enough for waiting for
// an initial ajax load to finish, for example
await waitForLifecyleEvent(wrapper.vm, "updated");
})
test("someProp", () => {
const { someProp } = slotProps;
expect(someProp).toExist();
// ...more tests
})
})
```
@@ -0,0 +1,3 @@
## Wrap access to browser-native resources in functions
Unit testing in a modern javascript application runs in node without a browser.
@@ -0,0 +1,82 @@
## Understand that a component is really just a fancy function
I'm not talking about how webpack turns it into a rendering function. That's obvious.
I mean conceptually, props come in (like arguments) and events go out (like the return statements).
A component is a fancy kind of function that can keep emitting results and accept changing inputs
over time. In truth it more closely resembles an Observable, but an observable is ALSO a slightly
fancier kind of function.
If you just think of a component as thing that takes inputs and emits outputs you're well on your
way to using them properly.
The worst components are ones that might as well just be a big single script that runs. That's zero
percent better than the spaghetti we're working so hard to replace. That's just repackaging all the
problems of the old imperative class-based legacy code.
## Javascript is a pretty functional language. Classes exist but they aren't as important as you may be used to.
You may come from a class-based programming background and think that your first step should be to
make a class hierchy that does the thing you want. It doesn't help that Vue superficially looks like
a class definition, (even though what you're really doing is configuring an Observable tree).
But javascript is largely a functional language. Its class support is limited and less useful, and
unless we switch to Typescript, we don't even have interfaces or typing, arguably the most useful
parts of a class-based language.
In general, you will get more mileage out of javascript by embracing functional programming
approaches because that is what Javascript is good at.
## Get comfortable with events, limit your dependence on Vuex
New vue programmers are ok at handing props to components, but they rarely use events effectively
(at first). As a result they end up using a lot of global state, a million little data props and
relying on imperfect globalized tools like Vuex or other imported dependencies for every little
variable.
Vuex has its uses, but not as many as you might think. Data persistence should be something that
happens near the top of your component tree, not down in the guts.
Your first thought with a component should be: "How can I offload the handling of the results of
this component to my caller?" The answer is usually going to be events. A component that simply
accepts props and emits events can be re-used in more contexts than one that relies on an external
global state to operate.
### Read up on .sync and v-model
They're just fancy shorthands for a prop / event handler combination. They are fundamentally no
different from props and events, but the syntax is important to understand.
## Think carefully about what should really be in "data".
Most of good component design boils down to answering the following question: What do I want to put
in data, computed, and props?
Data is the place where temp data goes that is not sensible to persist in Vuex or other
application-wide global state, usually because its use is very specific to the operation of this
particular component. There really should only be a few variables in data.
### Break down your own internal dependencies
Most components only need one or two variables in data. If you take the time to analyze your own
internal dependency tree, you will probably find that almost everything can be written in terms of
computed transformations on a small number of data and propertie
If you have more than a few variables you (a) aren't leveraging computeds and properties, or (b) are
trying to implement too many features for just one component. It is important separate your concerns
in components just like you do in any other kind of programming.
## Have a plan
Create a design plan and an abstraction for the way the guts of your component work. Think about the
way data passes from parent components to children and back again. Break your component into
sub-components just like you would break a class into sub-methods, the same exact principles apply.
Don't just dump a pile of spaghetti into a component, that is no better than the legacy code we are
replacing.
+1
View File
@@ -0,0 +1 @@
# testdocs/a-file.md
@@ -0,0 +1 @@
# testdocs/another-file.md
@@ -0,0 +1 @@
# ignored-file.md
@@ -0,0 +1 @@
# some-loose-file.md
@@ -0,0 +1 @@
# loose file
@@ -0,0 +1 @@
# testdocs/abc/readme.md
@@ -0,0 +1 @@
# foo
@@ -0,0 +1 @@
# another loose file
@@ -0,0 +1 @@
# Loose file
+57
View File
@@ -0,0 +1,57 @@
/**
* Tests the functions which build the sections for the styleguide.
*/
const path = require("path");
const { getDocSections } = require("../sections");
const getSectionByName = (sections, name) => sections.find((o) => o.name == name);
describe("getDocSections", () => {
const ignore = [
// ignoring a whole directory
"**/ignored/*",
// ignoring a file
"**/ignore*",
];
const testDocRoot = path.join(__dirname, "sample-docs");
const { rootNode } = getDocSections(testDocRoot, { ignore });
test("section generation", () => {
expect(rootNode.name).toEqual("Sample Docs");
expect(rootNode.sections.length).toEqual(6);
expect(rootNode.content).toBeUndefined();
});
test("nested subsection should appear even if no docs in intermediate folders", () => {
const deepSection = getSectionByName(rootNode.sections, "Nested Folders");
expect(deepSection.sections.length).toEqual(1);
});
test("subdirectory with readme should register as summary", () => {
const subsection = getSectionByName(rootNode.sections, "Has Readme");
// readme interpreted as content file and not as loose section
// one other loose file
expect(subsection.content).toContain("readme.md");
expect(subsection.sections.length).toEqual(1);
});
test("subdirectory with no readme file", () => {
const subsection = getSectionByName(rootNode.sections, "No Summary File");
// should just see 2 folders no summary
expect(subsection.content).toBeUndefined();
expect(subsection.sections.length).toEqual(2);
});
test("subdirectory with ignored file", () => {
const subsection = getSectionByName(rootNode.sections, "Contains Ommitted File");
expect(subsection.sections.length).toEqual(1);
});
test("ignored subdirectory", () => {
const ignoredSection = getSectionByName(rootNode.sections, "Ignored");
expect(ignoredSection).toBeUndefined();
});
});
+69
View File
@@ -0,0 +1,69 @@
### Beta History Panel Component Tree
This is not intended to be a complete listing, but a general idea of how the components are intended
to interact with each other.
```html static
<CurrentHistoryPanel>
<HistoryPanel :history="history">
<!-- for the right-hand side history we show some
optional nav elements, can be ommitted for histories
shown in multi-history view -->
<slot:nav>
<HistorySelector />
<HistoryMenu />
</slot:nav>
<!-- if main history selected -->
<History :history="history">
<!-- HCP does the heavy-lifting of mixing params, history, and
scroll position to deliver the content for the scroller -->
<HistoryContentProvider :parent="history">
<HistoryDetails />
<HistoryMessages />
<ContentOperations />
<Scroller>
<!-- HistoryContentItem is a dynamic component that becomes
either Dataset or DatasetCollection depending
on the props passed to it -->
(<HistoryContentItem />)
<Dataset />
<!-- or -->
<DatasetCollection />
</Scroller>
</HistoryContentProvider>
</History>
<!-- When a collection is selected for viewing, send in a
breadcrumbs list of collections the user has selected -->
<CurrentCollection :selected-collections="breadcrumbs">
<CollectionContentProvider :parent="selectedCollection">
<CollectionNav />
<Details />
<Scroller>
<!-- Subdataset and Subcollection are similar to the Dataset
and DatasetCollection ContentItem components, but mostly
read-only since they are part of the collection-->
(<CollectionContentItem />)
<Subdataset />
<!-- or -->
<Subcollection />
</Scroller>
</CollectionContentProvider>
</CurrentCollection>
</HistoryPanel>
</CurrentHistoryPanel>
```
+45 -46
View File
@@ -1,58 +1,57 @@
const path = require("path");
const glob = require("glob");
const fs = require("fs");
const merge = require("webpack-merge");
const baseConfig = require("./webpack.config.js");
const { getDocSections } = require("./docs/sections");
const buildWebpack = require("./webpack.config.js");
const webpackConfig = baseConfig();
function getWebpack() {
const cfg = buildWebpack();
const fileLoaderTest = /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/;
// looks like our src plays with the webpack publicPath dynamically,
// presumably to allow for dyamic loads, but this is a problem when
// you're not outputting code to a non-standard location.
// allowing this to happen breaks the styleguide.
cfg.module.rules.push({
test: /onload\/publicPath/,
use: { loader: "ignore-loader" },
});
const fileLoaderConfigRule = { rules: [{ test: fileLoaderTest, use: ["file-loader"] }] };
return cfg;
}
webpackConfig.module = merge.smart(webpackConfig.module, fileLoaderConfigRule);
webpackConfig.output.publicPath = "";
// TODO: Fix broken module imports before attempting to view in styleguidef
const problemChildren = ["**/HistoryView.vue", "**/admin/DataManager/*", "**/LibraryFolder/*"];
if (problemChildren.length) {
console.warn("Not rendering styleguide for the following components:", problemChildren);
}
webpackConfig.resolve.modules.push(path.join(__dirname, "src/style/scss"));
function getSections() {
// Style sections
const docRootPath = path.join(__dirname, "docs/src");
const { rootNode: docRoot } = getDocSections(docRootPath, { docSelector: "*.md" });
const [design, styles] = docRoot.sections;
delete docRoot.components;
const galaxyStyleDocs = [];
glob.sync("./docs/galaxy-*.md").forEach((file) => {
const name = file.match(/galaxy-(\w+).md/)[1];
galaxyStyleDocs.push({ name: name, content: file });
});
// recursive component tree docs
const cmpPath = path.join(__dirname, "src/components");
const { rootNode: componentDocs } = getDocSections(cmpPath, { ignore: problemChildren });
const sections = [
{
name: "Galaxy styles",
sections: galaxyStyleDocs,
},
{
name: "Basic Bootstrap Styles",
content: "./docs/bootstrap.md",
},
{
name: "Components",
// Components that are directories will get their own section
sections: glob
.sync("./src/components/*")
.map((file) => {
if (fs.lstatSync(file).isDirectory()) {
return {
name: path.basename(file),
components: file + "/**/*.vue",
};
}
})
.filter((v) => v),
// ...while top level components are handled here.
components: "./src/components/*.vue",
},
];
return [design, componentDocs, styles];
}
module.exports = {
webpackConfig,
webpackConfig: getWebpack(),
title: "Galaxy Client Resources",
sections: getSections(),
getExampleFilename(componentPath) {
return componentPath.replace(/\.(vue|js)?$/, ".md");
},
require: [
"./src/style/scss/base.scss",
"./src/polyfills.js",
// "./src/bundleEntries.js"
],
tocMode: "collapse",
renderRootJsx: "./docs/root",
styleguideDir: "./docs/dist",
pagePerSection: true,
sections,
require: ["./src/style/scss/base.scss", "./src/polyfills.js", "./src/bundleEntries.js"],
vuex: "./src/store/index.js",
ignore: problemChildren,
};
+1 -1
View File
@@ -118,7 +118,7 @@ module.exports = {
rootDir: path.join(__dirname, "../../"),
// A list of paths to directories that Jest should use to search for files in
roots: ["<rootDir>/src", "<rootDir>/tests/jest/standalone/"],
roots: ["<rootDir>/src", "<rootDir>/tests/jest/standalone/", "<rootDir>/docs/"],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",