Add support for a package extension bundle type (#15672)

* Add bundle type to constants and types

* Add support for API bundle extensions

* Rename generateExtensionsEntry to generateExtensionsEntrypoint

* Add support for App bundle extensions

* Refactor App extension registration

* Replace extensions inject with useExtensions()

* Replace getInterfaces() with useExtensions()

* Replace getDisplays() with useExtensions()

* Replace getLayouts() with useExtensions()

* Replace getModules() with useExtensions()

* Replace getPanels() with useExtensions()

* Replace getOperations() with useExtensions()

* Add useExtension() composable

* Replace useExtensions() with useExtension() where applicable

* Remove interface getters

* Remove display getters

* Remove layout getters

* Remove module getter

* Remove panel getters

* Remove operation getters

* Rename extension register.ts files to index.ts

* Perform module pre register check in parallel

* Remove Refs from AppExtensionConfigs type

* Remove old extension shims

* Ensure registration of modules is awaited when hydrating

* Add support for scaffolding package extensions

* Add support for building bundle extensions

* Add JsonValue type

* Use json for complex command line flags

* Load internal extensions if custom ones are not available

* Fix extension manifest validation for pack extensions

* Fix tests in shared

* Add SplitEntrypoint type

* Move command specific utils to helpers

* Add SDK version getter

* Move extension dev deps generation to helpers

* Move template path to getter util

* Move template copying to a helper

* Only rename copied template files

* Add directus-extension add command

* Convert provided extension source path to url

* Replace deprecated import.meta.globEager

* Mock URL.createObjectURL to make App unit tests pass

* Update rollup-plugin-typescript2

* indentation

* sort vite glob imported modules

* fix unintentional wrong commit

* Simplify app extension import logic

* reinstall @rollup/plugin-virtual

* add test for getInterfaces() expected sort order

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Nicola Krumschmidt
2022-11-16 17:28:52 +01:00
committed by GitHub
parent 0859102a61
commit 7bf90efa62
127 changed files with 1898 additions and 957 deletions

View File

@@ -35,27 +35,20 @@ router.get(
);
router.get(
'/:type/index.js',
'/sources/index.js',
asyncHandler(async (req, res) => {
const type = depluralize(req.params.type as Plural<string>);
if (!isIn(type, APP_OR_HYBRID_EXTENSION_TYPES)) {
throw new RouteNotFoundException(req.path);
}
const extensionManager = getExtensionManager();
const extensionSource = extensionManager.getAppExtensions(type);
if (extensionSource === undefined) {
const extensionSource = extensionManager.getAppExtensions();
if (extensionSource === null) {
throw new RouteNotFoundException(req.path);
}
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
if (env.EXTENSIONS_CACHE_TTL) {
res.setHeader('Cache-Control', getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)));
} else {
res.setHeader('Cache-Control', 'no-store');
}
res.setHeader(
'Cache-Control',
env.EXTENSIONS_CACHE_TTL ? getCacheControlHeader(req, ms(env.EXTENSIONS_CACHE_TTL as string)) : 'no-store'
);
res.setHeader('Vary', 'Origin, Cache-Control');
res.end(extensionSource);
})

View File

@@ -3,21 +3,20 @@ import path from 'path';
import {
ActionHandler,
ApiExtension,
AppExtensionType,
BundleExtension,
EndpointConfig,
Extension,
ExtensionType,
FilterHandler,
HookConfig,
HybridExtension,
HybridExtensionType,
InitHandler,
OperationApiConfig,
ScheduleHandler,
} from '@directus/shared/types';
import {
ensureExtensionDirs,
generateExtensionsEntry,
generateExtensionsEntrypoint,
getLocalExtensions,
getPackageExtensions,
pathToRelativeUrl,
@@ -26,12 +25,10 @@ import {
import {
API_OR_HYBRID_EXTENSION_PACKAGE_TYPES,
API_OR_HYBRID_EXTENSION_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
PACKAGE_EXTENSION_TYPES,
} from '@directus/shared/constants';
import getDatabase from './database';
import emitter, { Emitter } from './emitter';
@@ -69,13 +66,15 @@ export function getExtensionManager(): ExtensionManager {
return extensionManager;
}
type AppExtensions = Partial<Record<AppExtensionType | HybridExtensionType, string>>;
type ApiExtensions = {
hooks: { path: string; events: EventHandler[] }[];
endpoints: { path: string }[];
operations: { path: string }[];
type BundleConfig = {
endpoints: { name: string; config: EndpointConfig }[];
hooks: { name: string; config: HookConfig }[];
operations: { name: string; config: OperationApiConfig }[];
};
type AppExtensions = string | null;
type ApiExtensions = { path: string }[];
type Options = {
schedule: boolean;
watch: boolean;
@@ -92,10 +91,11 @@ class ExtensionManager {
private extensions: Extension[] = [];
private appExtensions: AppExtensions = {};
private apiExtensions: ApiExtensions = { hooks: [], endpoints: [], operations: [] };
private appExtensions: AppExtensions = null;
private apiExtensions: ApiExtensions = [];
private apiEmitter: Emitter;
private hookEvents: EventHandler[] = [];
private endpointRouter: Router;
private reloadQueue: JobQueue;
@@ -179,8 +179,8 @@ class ExtensionManager {
}
}
public getAppExtensions(type: AppExtensionType | HybridExtensionType): string | undefined {
return this.appExtensions[type];
public getAppExtensions(): string | null {
return this.appExtensions;
}
public getEndpointRouter(): Router {
@@ -198,25 +198,24 @@ class ExtensionManager {
}
await this.registerHooks();
this.registerEndpoints();
await this.registerEndpoints();
await this.registerOperations();
await this.registerBundles();
if (env.SERVE_APP) {
this.appExtensions = await this.generateExtensionBundles();
this.appExtensions = await this.generateExtensionBundle();
}
this.isLoaded = true;
}
private async unload(): Promise<void> {
this.unregisterHooks();
this.unregisterEndpoints();
this.unregisterOperations();
this.unregisterApiExtensions();
this.apiEmitter.offAll();
if (env.SERVE_APP) {
this.appExtensions = {};
this.appExtensions = null;
}
this.isLoaded = false;
@@ -257,9 +256,9 @@ class ExtensionManager {
extensions
.filter((extension) => !extension.local)
.flatMap((extension) =>
isTypeIn(extension, PACKAGE_EXTENSION_TYPES)
extension.type === 'pack'
? path.resolve(extension.path, 'package.json')
: isTypeIn(extension, HYBRID_EXTENSION_TYPES)
: isTypeIn(extension, HYBRID_EXTENSION_TYPES) || extension.type === 'bundle'
? [
path.resolve(extension.path, extension.entrypoint.app),
path.resolve(extension.path, extension.entrypoint.api),
@@ -288,40 +287,36 @@ class ExtensionManager {
return [...packageExtensions, ...localExtensions];
}
private async generateExtensionBundles() {
private async generateExtensionBundle(): Promise<string | null> {
const sharedDepsMapping = await this.getSharedDepsMapping(APP_SHARED_DEPS);
const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({
find: name,
replacement: path,
}));
const bundles: Partial<Record<AppExtensionType | HybridExtensionType, string>> = {};
const entrypoint = generateExtensionsEntrypoint(this.extensions);
for (const extensionType of APP_OR_HYBRID_EXTENSION_TYPES) {
const entry = generateExtensionsEntry(extensionType, this.extensions);
try {
const bundle = await rollup({
input: 'entry',
external: Object.values(sharedDepsMapping),
makeAbsoluteExternalsRelative: false,
plugins: [virtual({ entry: entrypoint }), alias({ entries: internalImports })],
});
const { output } = await bundle.generate({ format: 'es', compact: true });
try {
const bundle = await rollup({
input: 'entry',
external: Object.values(sharedDepsMapping),
makeAbsoluteExternalsRelative: false,
plugins: [virtual({ entry }), alias({ entries: internalImports })],
});
const { output } = await bundle.generate({ format: 'es', compact: true });
await bundle.close();
bundles[extensionType] = output[0].code;
await bundle.close();
} catch (error: any) {
logger.warn(`Couldn't bundle App extensions`);
logger.warn(error);
}
return output[0].code;
} catch (error: any) {
logger.warn(`Couldn't bundle App extensions`);
logger.warn(error);
}
return bundles;
return null;
}
private async getSharedDepsMapping(deps: string[]) {
private async getSharedDepsMapping(deps: string[]): Promise<Record<string, string>> {
const appDir = await fse.readdir(path.join(resolvePackage('@directus/app', __dirname), 'dist', 'assets'));
const depsMapping: Record<string, string> = {};
@@ -350,7 +345,9 @@ class ExtensionManager {
const config = getModuleDefault(hookInstance);
this.registerHook(config, hookPath);
this.registerHook(config);
this.apiExtensions.push({ path: hookPath });
} catch (error: any) {
logger.warn(`Couldn't register hook "${hook.name}"`);
logger.warn(error);
@@ -358,7 +355,7 @@ class ExtensionManager {
}
}
private registerEndpoints(): void {
private async registerEndpoints(): Promise<void> {
const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint');
for (const endpoint of endpoints) {
@@ -368,7 +365,9 @@ class ExtensionManager {
const config = getModuleDefault(endpointInstance);
this.registerEndpoint(config, endpointPath, endpoint.name, this.endpointRouter);
this.registerEndpoint(config, endpoint.name);
this.apiExtensions.push({ path: endpointPath });
} catch (error: any) {
logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
logger.warn(error);
@@ -400,7 +399,9 @@ class ExtensionManager {
const config = getModuleDefault(operationInstance);
this.registerOperation(config, operationPath);
this.registerOperation(config);
this.apiExtensions.push({ path: operationPath });
} catch (error: any) {
logger.warn(`Couldn't register operation "${operation.name}"`);
logger.warn(error);
@@ -408,17 +409,42 @@ class ExtensionManager {
}
}
private registerHook(register: HookConfig, path: string) {
const hookHandler: { path: string; events: EventHandler[] } = {
path,
events: [],
};
private async registerBundles(): Promise<void> {
const bundles = this.extensions.filter((extension): extension is BundleExtension => extension.type === 'bundle');
for (const bundle of bundles) {
try {
const bundlePath = path.resolve(bundle.path, bundle.entrypoint.api);
const bundleInstances: BundleConfig | { default: BundleConfig } = require(bundlePath);
const configs = getModuleDefault(bundleInstances);
for (const { config } of configs.hooks) {
this.registerHook(config);
}
for (const { config, name } of configs.endpoints) {
this.registerEndpoint(config, name);
}
for (const { config } of configs.operations) {
this.registerOperation(config);
}
this.apiExtensions.push({ path: bundlePath });
} catch (error: any) {
logger.warn(`Couldn't register bundle "${bundle.name}"`);
logger.warn(error);
}
}
}
private registerHook(register: HookConfig): void {
const registerFunctions = {
filter: (event: string, handler: FilterHandler) => {
emitter.onFilter(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'filter',
name: event,
handler,
@@ -427,7 +453,7 @@ class ExtensionManager {
action: (event: string, handler: ActionHandler) => {
emitter.onAction(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'action',
name: event,
handler,
@@ -436,7 +462,7 @@ class ExtensionManager {
init: (event: string, handler: InitHandler) => {
emitter.onInit(event, handler);
hookHandler.events.push({
this.hookEvents.push({
type: 'init',
name: event,
handler,
@@ -454,7 +480,7 @@ class ExtensionManager {
}
});
hookHandler.events.push({
this.hookEvents.push({
type: 'schedule',
task,
});
@@ -473,16 +499,14 @@ class ExtensionManager {
logger,
getSchema,
});
this.apiExtensions.hooks.push(hookHandler);
}
private registerEndpoint(config: EndpointConfig, path: string, name: string, router: Router) {
private registerEndpoint(config: EndpointConfig, name: string): void {
const register = typeof config === 'function' ? config : config.handler;
const routeName = typeof config === 'function' ? name : config.id;
const scopedRouter = express.Router();
router.use(`/${routeName}`, scopedRouter);
this.endpointRouter.use(`/${routeName}`, scopedRouter);
register(scopedRouter, {
services,
@@ -493,66 +517,44 @@ class ExtensionManager {
logger,
getSchema,
});
this.apiExtensions.endpoints.push({
path,
});
}
private registerOperation(config: OperationApiConfig, path: string) {
private registerOperation(config: OperationApiConfig): void {
const flowManager = getFlowManager();
flowManager.addOperation(config.id, config.handler);
this.apiExtensions.operations.push({
path,
});
}
private unregisterHooks(): void {
for (const hook of this.apiExtensions.hooks) {
for (const event of hook.events) {
switch (event.type) {
case 'filter':
emitter.offFilter(event.name, event.handler);
break;
case 'action':
emitter.offAction(event.name, event.handler);
break;
case 'init':
emitter.offInit(event.name, event.handler);
break;
case 'schedule':
event.task.stop();
break;
}
private unregisterApiExtensions(): void {
for (const event of this.hookEvents) {
switch (event.type) {
case 'filter':
emitter.offFilter(event.name, event.handler);
break;
case 'action':
emitter.offAction(event.name, event.handler);
break;
case 'init':
emitter.offInit(event.name, event.handler);
break;
case 'schedule':
event.task.stop();
break;
}
delete require.cache[require.resolve(hook.path)];
}
this.apiExtensions.hooks = [];
}
private unregisterEndpoints(): void {
for (const endpoint of this.apiExtensions.endpoints) {
delete require.cache[require.resolve(endpoint.path)];
}
this.hookEvents = [];
this.endpointRouter.stack = [];
this.apiExtensions.endpoints = [];
}
private unregisterOperations(): void {
for (const operation of this.apiExtensions.operations) {
delete require.cache[require.resolve(operation.path)];
}
const flowManager = getFlowManager();
flowManager.clearOperations();
this.apiExtensions.operations = [];
for (const apiExtension of this.apiExtensions) {
delete require.cache[require.resolve(apiExtension.path)];
}
this.apiExtensions = [];
}
}

View File

@@ -0,0 +1,21 @@
import { afterEach } from 'vitest';
const originals = new Map<string, any>();
originals.set('createObjectURL', globalThis.URL.createObjectURL);
Object.defineProperty(globalThis.URL, 'createObjectURL', {
value: () => 'blob:http://localhost/0',
writable: true,
enumerable: false,
configurable: true,
});
afterEach(() => {
Object.defineProperty(globalThis.URL, 'createObjectURL', {
value: originals.get('createObjectURL'),
writable: true,
enumerable: false,
configurable: true,
});
});

View File

@@ -48,8 +48,8 @@
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
import { Field } from '@directus/shared/types';
import { getInterface } from '@/interfaces';
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
import { useExtension } from '@/composables/use-extension';
interface Props {
field: Field;
@@ -82,7 +82,12 @@ defineEmits(['update:modelValue', 'setFieldValue']);
const { t } = useI18n();
const interfaceExists = computed(() => !!getInterface(props.field?.meta?.interface || 'input'));
const inter = useExtension(
'interface',
computed(() => props.field?.meta?.interface ?? 'input')
);
const interfaceExists = computed(() => !!inter.value);
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,19 @@
import { Ref, computed, unref } from 'vue';
import { AppExtensionConfigs, AppExtensionType, HybridExtensionType, Plural } from '@directus/shared/types';
import { useExtensions } from '@/extensions';
import { pluralize } from '@directus/shared/utils';
export function useExtension<T extends AppExtensionType | HybridExtensionType>(
type: T,
name: string | Ref<string | null>
): Ref<AppExtensionConfigs[Plural<T>][number] | null> {
const extensions = useExtensions();
return computed(() => {
const nameRaw = unref(name);
if (nameRaw === null) return null;
return (extensions[pluralize(type)].value as any[]).find(({ id }) => id === nameRaw) ?? null;
});
}

View File

@@ -1,10 +1,10 @@
import { FormField } from '@/components/v-form/types';
import { getInterface } from '@/interfaces';
import { Field } from '@directus/shared/types';
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
import { cloneDeep, orderBy } from 'lodash';
import { computed, ComputedRef, Ref } from 'vue';
import { translate } from '@/utils/translate-object-values';
import { useExtension } from './use-extension';
export function useFormFields(fields: Ref<Field[]>): { formFields: ComputedRef<Field[]> } {
const formFields = computed(() => {
@@ -24,10 +24,10 @@ export function useFormFields(fields: Ref<Field[]>): { formFields: ComputedRef<F
formFields = formFields.map((field, index) => {
if (!field.meta) return field;
let interfaceUsed = getInterface(field.meta.interface);
if (interfaceUsed === undefined) {
let interfaceUsed = field.meta.interface ? useExtension('interface', field.meta.interface).value : null;
if (interfaceUsed === null) {
field.meta.interface = getDefaultInterfaceForType(field.type);
interfaceUsed = getInterface(field.meta.interface);
interfaceUsed = useExtension('interface', field.meta.interface).value;
}
if (interfaceUsed?.hideLabel === true) {

View File

@@ -1,12 +1,6 @@
import { provide } from 'vue';
import api from '@/api';
import { API_INJECT, EXTENSIONS_INJECT, STORES_INJECT } from '@directus/shared/constants';
import { getInterfaces } from '@/interfaces';
import { getDisplays } from '@/displays';
import { getLayouts } from '@/layouts';
import { getModules } from '@/modules';
import { getPanels } from '@/panels';
import { getOperations } from '@/operations';
import { useAppStore } from '@/stores/app';
import { useCollectionsStore } from '@/stores/collections';
import { useFieldsStore } from '@/stores/fields';
@@ -21,6 +15,7 @@ import { useServerStore } from '@/stores/server';
import { useSettingsStore } from '@/stores/settings';
import { useUserStore } from '@/stores/user';
import { useFlowsStore } from '@/stores/flows';
import { useExtensions } from '@/extensions';
export function useSystem(): void {
provide(STORES_INJECT, {
@@ -42,12 +37,5 @@ export function useSystem(): void {
provide(API_INJECT, api);
provide(EXTENSIONS_INJECT, {
interfaces: getInterfaces().interfaces,
displays: getDisplays().displays,
layouts: getLayouts().layouts,
modules: getModules().modules,
panels: getPanels().panels,
operations: getOperations().operations,
});
provide(EXTENSIONS_INJECT, useExtensions());
}

View File

@@ -1,13 +1,19 @@
import { shallowRef, Ref } from 'vue';
import { App } from 'vue';
import { DisplayConfig } from '@directus/shared/types';
import { sortBy } from 'lodash';
const displaysRaw: Ref<DisplayConfig[]> = shallowRef([]);
const displays: Ref<DisplayConfig[]> = shallowRef([]);
export function getInternalDisplays(): DisplayConfig[] {
const displays = import.meta.glob<DisplayConfig>('./*/index.ts', { import: 'default', eager: true });
export function getDisplays(): { displays: Ref<DisplayConfig[]>; displaysRaw: Ref<DisplayConfig[]> } {
return { displays, displaysRaw };
return sortBy(Object.values(displays), 'id');
}
export function getDisplay(name?: string | null): DisplayConfig | undefined {
return !name ? undefined : displays.value.find(({ id }) => id === name);
export function registerDisplays(displays: DisplayConfig[], app: App): void {
for (const display of displays) {
app.component(`display-${display.id}`, display.component);
if (typeof display.options !== 'function' && Array.isArray(display.options) === false && display.options !== null) {
app.component(`display-options-${display.id}`, display.options);
}
}
}

View File

@@ -1,34 +0,0 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getDisplays } from './index';
import { DisplayConfig } from '@directus/shared/types';
const { displaysRaw } = getDisplays();
export async function registerDisplays(app: App): Promise<void> {
const displayModules = import.meta.globEager('./*/**/index.ts');
const displays: DisplayConfig[] = Object.values(displayModules).map((module) => module.default);
try {
const customDisplays: { default: DisplayConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-display')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/displays/index.js`);
displays.push(...customDisplays.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom displays`);
// eslint-disable-next-line no-console
console.warn(err);
}
displaysRaw.value = displays;
displaysRaw.value.forEach((display: DisplayConfig) => {
app.component(`display-${display.id}`, display.component);
if (typeof display.options !== 'function' && Array.isArray(display.options) === false && display.options !== null) {
app.component(`display-options-${display.id}`, display.options);
}
});
}

View File

@@ -4,9 +4,9 @@ import { getFieldsFromTemplate } from '@directus/shared/utils';
import { getRelatedCollection } from '@/utils/get-related-collection';
import DisplayRelatedValues from './related-values.vue';
import { useFieldsStore } from '@/stores/fields';
import { getDisplay } from '@/displays';
import { get, set } from 'lodash';
import { renderPlainStringTemplate } from '@/utils/render-string-template';
import { useExtension } from '@/composables/use-extension';
type Options = {
template: string;
@@ -79,10 +79,10 @@ export default defineDisplay({
continue;
}
const display = getDisplay(field.meta.display);
const display = useExtension('display', field.meta.display);
const stringValue = display?.handler
? display.handler(fieldValue, field?.meta?.display_options ?? {}, {
const stringValue = display.value?.handler
? display.value.handler(fieldValue, field?.meta?.display_options ?? {}, {
interfaceOptions: field?.meta?.options ?? {},
field: field ?? undefined,
collection: collection,

View File

@@ -1,4 +1,3 @@
import { getDisplay } from '@/displays';
import { i18n } from '@/lang';
import { useFieldsStore } from '@/stores/fields';
import { useRelationsStore } from '@/stores/relations';
@@ -7,6 +6,7 @@ import { renderPlainStringTemplate } from '@/utils/render-string-template';
import { defineDisplay, getFieldsFromTemplate } from '@directus/shared/utils';
import { get, set } from 'lodash';
import DisplayTranslations from './translations.vue';
import { useExtension } from '@/composables/use-extension';
export default defineDisplay({
id: 'translations',
@@ -79,10 +79,10 @@ export default defineDisplay({
continue;
}
const display = getDisplay(field.meta.display);
const display = useExtension('display', field.meta.display);
const stringValue = display?.handler
? display.handler(fieldValue, field?.meta?.display_options ?? {}, {
const stringValue = display.value?.handler
? display.value.handler(fieldValue, field?.meta?.display_options ?? {}, {
interfaceOptions: field?.meta?.options ?? {},
field: field ?? undefined,
collection: collection,

99
app/src/extensions.ts Normal file
View File

@@ -0,0 +1,99 @@
import { AppExtensionConfigs, RefRecord } from '@directus/shared/types';
import { App, shallowRef, watch } from 'vue';
import { getInternalDisplays, registerDisplays } from './displays';
import { getInternalInterfaces, registerInterfaces } from './interfaces';
import { i18n } from './lang';
import { getInternalLayouts, registerLayouts } from './layouts';
import { getInternalModules, registerModules } from './modules';
import { getInternalOperations, registerOperations } from './operations';
import { getInternalPanels, registerPanels } from './panels';
import { getRootPath } from './utils/get-root-path';
import { translate } from './utils/translate-object-values';
let customExtensions: AppExtensionConfigs | null = null;
const extensions: RefRecord<AppExtensionConfigs> = {
interfaces: shallowRef([]),
displays: shallowRef([]),
layouts: shallowRef([]),
modules: shallowRef([]),
panels: shallowRef([]),
operations: shallowRef([]),
};
const onHydrateCallbacks: (() => Promise<void>)[] = [];
const onDehydrateCallbacks: (() => Promise<void>)[] = [];
export async function loadExtensions(): Promise<void> {
try {
customExtensions = import.meta.env.DEV
? await import('@directus-extensions')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/sources/index.js`);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load extensions`);
// eslint-disable-next-line no-console
console.warn(err);
}
}
export function registerExtensions(app: App): void {
const interfaces = getInternalInterfaces();
const displays = getInternalDisplays();
const layouts = getInternalLayouts();
const modules = getInternalModules();
const panels = getInternalPanels();
const operations = getInternalOperations();
if (customExtensions !== null) {
interfaces.push(...customExtensions.interfaces);
displays.push(...customExtensions.displays);
layouts.push(...customExtensions.layouts);
modules.push(...customExtensions.modules);
panels.push(...customExtensions.panels);
operations.push(...customExtensions.operations);
}
registerInterfaces(interfaces, app);
registerDisplays(displays, app);
registerLayouts(layouts, app);
registerPanels(panels, app);
registerOperations(operations, app);
watch(
i18n.global.locale,
() => {
extensions.interfaces.value = translate(interfaces);
extensions.displays.value = translate(displays);
extensions.layouts.value = translate(layouts);
extensions.panels.value = translate(panels);
extensions.operations.value = translate(operations);
},
{ immediate: true }
);
const { registeredModules, onHydrateModules, onDehydrateModules } = registerModules(modules);
watch(
[i18n.global.locale, registeredModules],
() => {
extensions.modules.value = translate(registeredModules.value);
},
{ immediate: true }
);
onHydrateCallbacks.push(onHydrateModules);
onDehydrateCallbacks.push(onDehydrateModules);
}
export async function onHydrateExtensions() {
await Promise.all(onHydrateCallbacks.map((onHydrate) => onHydrate()));
}
export async function onDehydrateExtensions() {
await Promise.all(onDehydrateCallbacks.map((onDehydrate) => onDehydrate()));
}
export function useExtensions(): RefRecord<AppExtensionConfigs> {
return extensions;
}

View File

@@ -1,5 +1,4 @@
import { setLanguage } from '@/lang/set-language';
import { register as registerModules, unregister as unregisterModules } from '@/modules/register';
import { getBasemapSources } from '@/utils/geometry/basemap';
import { useAppStore } from '@/stores/app';
import { useCollectionsStore } from '@/stores/collections';
@@ -16,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings';
import { useUserStore } from '@/stores/user';
import { useNotificationsStore } from '@/stores/notifications';
import { useTranslationStrings } from '@/composables/use-translation-strings';
import { onDehydrateExtensions, onHydrateExtensions } from './extensions';
type GenericStore = {
$id: string;
@@ -76,7 +76,7 @@ export async function hydrate(): Promise<void> {
const hydratedStores = ['userStore', 'permissionsStore'];
await Promise.all(stores.filter(({ $id }) => !hydratedStores.includes($id)).map((store) => store.hydrate?.()));
await registerModules();
await onHydrateExtensions();
await hydrateTranslationStrings();
if (userStore.currentUser?.language) lang = userStore.currentUser?.language;
@@ -103,7 +103,7 @@ export async function dehydrate(stores = useStores()): Promise<void> {
await store.dehydrate?.();
}
unregisterModules();
await onDehydrateExtensions();
appStore.hydrated = false;
}

View File

@@ -29,7 +29,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, inject, ref } from 'vue';
import { getInterface } from '@/interfaces';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
props: {
@@ -65,15 +65,8 @@ export default defineComponent({
const values = inject('values', ref<Record<string, any>>({}));
const selectedInterface = computed(() => {
if (props.interface) {
return getInterface(props.interface);
}
if (!values.value[props.interfaceField]) return;
return getInterface(values.value[props.interfaceField]);
});
const selectedInterfaceId = computed(() => props.interface ?? values.value[props.interfaceField] ?? null);
const selectedInterface = useExtension('interface', selectedInterfaceId);
const usesCustomComponent = computed(() => {
if (!selectedInterface.value) return false;

View File

@@ -14,8 +14,8 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, inject, ref, watch } from 'vue';
import { getInterfaces } from '@/interfaces';
import { InterfaceConfig } from '@directus/shared/types';
import { useExtensions } from '@/extensions';
export default defineComponent({
props: {
@@ -32,7 +32,7 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const { interfaces } = getInterfaces();
const { interfaces } = useExtensions();
const values = inject('values', ref<Record<string, any>>({}));

View File

@@ -60,7 +60,6 @@
<script lang="ts">
import { defineComponent, PropType, computed, ref } from 'vue';
import { getModules } from '@/modules';
import { Settings, SettingsModuleBarModule, SettingsModuleBarLink } from '@directus/shared/types';
import { hideDragImage } from '@/utils/hide-drag-image';
import Draggable from 'vuedraggable';
@@ -69,6 +68,7 @@ import { useI18n } from 'vue-i18n';
import { nanoid } from 'nanoid';
import { Field, DeepPartial } from '@directus/shared/types';
import { MODULE_BAR_DEFAULT } from '@/constants';
import { useExtensions } from '@/extensions';
type PreviewExtra = {
to: string;
@@ -130,7 +130,7 @@ export default defineComponent({
const values = ref<SettingsModuleBarLink | null>();
const initialValues = ref<SettingsModuleBarLink | null>();
const { modules: registeredModules } = getModules();
const { modules: registeredModules } = useExtensions();
const availableModulesAsBarModule = computed<SettingsModuleBarModule[]>(() => {
return registeredModules.value

View File

@@ -0,0 +1,37 @@
import { expect, test, describe } from 'vitest';
import { getInternalInterfaces } from './index';
/**
* Vite v3 reworked the glob import with it's own internal sorting,
* so this is to ensure any changes to vite's internal sorting to not affect the expected sort outcome.
*
* @see {@link https://github.com/directus/directus/pull/15672#issuecomment-1289975933}
*/
const expectedInterfacesSortOrder = [
'boolean',
'input',
'input-autocomplete-api',
'input-code',
'input-hash',
'input-multiline',
'input-rich-text-html',
'input-rich-text-md',
'system-collection',
];
describe('interfaces', () => {
test('getInterfaces() should return the expected sorted order of interfaces', () => {
const interfaces = getInternalInterfaces();
const interfaceIds = interfaces.map((inter) => inter.id);
const interfacesToTest = interfaceIds.filter((inter) => expectedInterfacesSortOrder.includes(inter));
// test all expected sort order
expect(interfacesToTest).toEqual(expectedInterfacesSortOrder);
// test whether input interface is the first one among all the input related interfaces
expect(interfacesToTest.filter((inter) => inter.includes('input')).findIndex((inter) => inter === 'input')).toBe(0);
// system-collection should be not be at the start after sorting. Currently it is within the folder called "_system",
// so it will be the first item by default without our added sort logic in getInterface().
expect(interfacesToTest.findIndex((inter) => inter === 'system-collection')).not.toBe(0);
});
});

View File

@@ -1,13 +1,22 @@
import { shallowRef, Ref } from 'vue';
import { App } from 'vue';
import { InterfaceConfig } from '@directus/shared/types';
import { sortBy } from 'lodash';
const interfacesRaw: Ref<InterfaceConfig[]> = shallowRef([]);
const interfaces: Ref<InterfaceConfig[]> = shallowRef([]);
export function getInternalInterfaces(): InterfaceConfig[] {
const interfaces = import.meta.glob<InterfaceConfig>(['./*/index.ts', './_system/*/index.ts'], {
import: 'default',
eager: true,
});
export function getInterfaces(): { interfaces: Ref<InterfaceConfig[]>; interfacesRaw: Ref<InterfaceConfig[]> } {
return { interfaces, interfacesRaw };
return sortBy(Object.values(interfaces), 'id');
}
export function getInterface(name?: string | null): InterfaceConfig | undefined {
return !name ? undefined : interfaces.value.find(({ id }) => id === name);
export function registerInterfaces(interfaces: InterfaceConfig[], app: App): void {
for (const inter of interfaces) {
app.component(`interface-${inter.id}`, inter.component);
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false && inter.options !== null) {
app.component(`interface-options-${inter.id}`, inter.options);
}
}
}

View File

@@ -1,35 +0,0 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getInterfaces } from './index';
import { InterfaceConfig } from '@directus/shared/types';
const { interfacesRaw } = getInterfaces();
export async function registerInterfaces(app: App): Promise<void> {
const interfaceModules = import.meta.globEager('./*/**/index.ts');
const interfaces: InterfaceConfig[] = Object.values(interfaceModules).map((module) => module.default);
try {
const customInterfaces: { default: InterfaceConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-interface')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/interfaces/index.js`);
interfaces.push(...customInterfaces.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom interfaces`);
// eslint-disable-next-line no-console
console.warn(err);
}
interfacesRaw.value = interfaces;
interfacesRaw.value.forEach((inter: InterfaceConfig) => {
app.component(`interface-${inter.id}`, inter.component);
if (typeof inter.options !== 'function' && Array.isArray(inter.options) === false && inter.options !== null) {
app.component(`interface-options-${inter.id}`, inter.options);
}
});
}

View File

@@ -1,21 +1,8 @@
import { getDisplays } from '@/displays';
import { getInterfaces } from '@/interfaces';
import { getPanels } from '@/panels';
import { getLayouts } from '@/layouts';
import { getModules } from '@/modules';
import { useCollectionsStore } from '@/stores/collections';
import { useFieldsStore } from '@/stores/fields';
import { translate } from '@/utils/translate-object-values';
import availableLanguages from './available-languages.yaml';
import { i18n, Language, loadedLanguages } from './index';
import { getOperations } from '@/operations';
import { useTranslationStrings } from '@/composables/use-translation-strings';
const { modules, modulesRaw } = getModules();
const { layouts, layoutsRaw } = getLayouts();
const { interfaces, interfacesRaw } = getInterfaces();
const { panels, panelsRaw } = getPanels();
const { displays, displaysRaw } = getDisplays();
const { operations, operationsRaw } = getOperations();
import { loadDateFNSLocale } from '@/utils/get-date-fns-locale';
export async function setLanguage(lang: Language): Promise<boolean> {
@@ -43,13 +30,6 @@ export async function setLanguage(lang: Language): Promise<boolean> {
(document.querySelector('html') as HTMLElement).setAttribute('lang', lang);
}
modules.value = translate(modulesRaw.value);
layouts.value = translate(layoutsRaw.value);
interfaces.value = translate(interfacesRaw.value);
panels.value = translate(panelsRaw.value);
displays.value = translate(displaysRaw.value);
operations.value = translate(operationsRaw.value);
collectionsStore.translateCollections();
fieldsStore.translateFields();
mergeTranslationStringsForLanguage(lang);

View File

@@ -1,13 +1,18 @@
import { shallowRef, Ref } from 'vue';
import { App } from 'vue';
import { LayoutConfig } from '@directus/shared/types';
import { sortBy } from 'lodash';
const layoutsRaw: Ref<LayoutConfig[]> = shallowRef([]);
const layouts: Ref<LayoutConfig[]> = shallowRef([]);
export function getInternalLayouts(): LayoutConfig[] {
const layouts = import.meta.glob<LayoutConfig>('./*/index.ts', { import: 'default', eager: true });
export function getLayouts(): { layouts: Ref<LayoutConfig[]>; layoutsRaw: Ref<LayoutConfig[]> } {
return { layouts, layoutsRaw };
return sortBy(Object.values(layouts), 'id');
}
export function getLayout(name?: string | null): LayoutConfig | undefined {
return !name ? undefined : layouts.value.find(({ id }) => id === name);
export function registerLayouts(layouts: LayoutConfig[], app: App): void {
for (const layout of layouts) {
app.component(`layout-${layout.id}`, layout.component);
app.component(`layout-options-${layout.id}`, layout.slots.options);
app.component(`layout-sidebar-${layout.id}`, layout.slots.sidebar);
app.component(`layout-actions-${layout.id}`, layout.slots.actions);
}
}

View File

@@ -1,34 +0,0 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getLayouts } from './index';
import { LayoutConfig } from '@directus/shared/types';
const { layoutsRaw } = getLayouts();
export async function registerLayouts(app: App): Promise<void> {
const layoutModules = import.meta.globEager('./*/**/index.ts');
const layouts: LayoutConfig[] = Object.values(layoutModules).map((module) => module.default);
try {
const customLayouts: { default: LayoutConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-layout')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/layouts/index.js`);
layouts.push(...customLayouts.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom layouts`);
// eslint-disable-next-line no-console
console.warn(err);
}
layoutsRaw.value = layouts;
layoutsRaw.value.forEach((layout) => {
app.component(`layout-${layout.id}`, layout.component);
app.component(`layout-options-${layout.id}`, layout.slots.options);
app.component(`layout-sidebar-${layout.id}`, layout.slots.sidebar);
app.component(`layout-actions-${layout.id}`, layout.slots.actions);
});
}

View File

@@ -7,16 +7,11 @@ import App from './app.vue';
import { registerComponents } from './components/register';
import { DIRECTUS_LOGO } from './constants';
import { registerDirectives } from './directives/register';
import { registerPanels } from './panels/register';
import { registerDisplays } from './displays/register';
import { registerInterfaces } from './interfaces/register';
import { i18n } from './lang/';
import { registerLayouts } from './layouts/register';
import { loadModules } from './modules/register';
import { router } from './router';
import './styles/main.scss';
import { registerViews } from './views/register';
import { registerOperations } from './operations/register';
import { loadExtensions, registerExtensions } from './extensions';
init();
@@ -44,14 +39,9 @@ async function init() {
registerComponents(app);
registerViews(app);
await Promise.all([
registerInterfaces(app),
registerPanels(app),
registerDisplays(app),
registerLayouts(app),
registerOperations(app),
loadModules(),
]);
await loadExtensions();
registerExtensions(app);
app.mount('#app');

View File

@@ -295,9 +295,9 @@ import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import DrawerBatch from '@/views/private/components/drawer-batch.vue';
import { unexpectedError } from '@/utils/unexpected-error';
import { getLayouts } from '@/layouts';
import { mergeFilters } from '@directus/shared/utils';
import { Filter } from '@directus/shared/types';
import { useExtension } from '@/composables/use-extension';
type Item = {
[field: string]: any;
@@ -336,7 +336,6 @@ export default defineComponent({
const router = useRouter();
const { layouts } = getLayouts();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const layoutRef = ref();
@@ -382,7 +381,7 @@ export default defineComponent({
const { bookmarkDialogActive, creatingBookmark, createBookmark } = useBookmarks();
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
const currentLayout = useExtension('layout', layout);
watch(
collection,

View File

@@ -1,9 +1,61 @@
import { shallowRef, Ref } from 'vue';
import { router } from '@/router';
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import RouterPass from '@/utils/router-passthrough';
import { ModuleConfig } from '@directus/shared/types';
import { ShallowRef, shallowRef } from 'vue';
import { sortBy } from 'lodash';
const modulesRaw: Ref<ModuleConfig[]> = shallowRef([]);
const modules: Ref<ModuleConfig[]> = shallowRef([]);
export function getInternalModules(): ModuleConfig[] {
const modules = import.meta.glob<ModuleConfig>('./*/index.ts', { import: 'default', eager: true });
export function getModules(): { modules: Ref<ModuleConfig[]>; modulesRaw: Ref<ModuleConfig[]> } {
return { modules, modulesRaw };
return sortBy(Object.values(modules), 'id');
}
export function registerModules(modules: ModuleConfig[]): {
registeredModules: ShallowRef<ModuleConfig[]>;
onHydrateModules: () => Promise<void>;
onDehydrateModules: () => Promise<void>;
} {
const registeredModules = shallowRef<ModuleConfig[]>([]);
const onHydrateModules = async () => {
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
if (!userStore.currentUser) return;
registeredModules.value = (
await Promise.all(
modules.map(async (module) => {
if (!module.preRegisterCheck) return module;
const allowed = await module.preRegisterCheck(userStore.currentUser, permissionsStore.permissions);
if (allowed) return module;
return null;
})
)
).filter((module): module is ModuleConfig => module !== null);
for (const module of registeredModules.value) {
router.addRoute({
name: module.id,
path: `/${module.id}`,
component: RouterPass,
children: module.routes,
});
}
};
const onDehydrateModules = async () => {
for (const module of modules) {
router.removeRoute(module.id);
}
registeredModules.value = [];
};
return { registeredModules, onHydrateModules, onDehydrateModules };
}

View File

@@ -194,7 +194,7 @@
import { AppTile } from '@/components/v-workspace-tile.vue';
import { useEditsGuard } from '@/composables/use-edits-guard';
import { useShortcut } from '@/composables/use-shortcut';
import { getPanels } from '@/panels';
import { useExtensions } from '@/extensions';
import { router } from '@/router';
import { useAppStore } from '@/stores/app';
import { useInsightsStore } from '@/stores/insights';
@@ -217,7 +217,7 @@ const props = withDefaults(defineProps<Props>(), { panelKey: null });
const { t } = useI18n();
const { panels: panelsInfo } = getPanels();
const { panels: panelsInfo } = useExtensions();
const insightsStore = useInsightsStore();
const appStore = useAppStore();

View File

@@ -95,7 +95,8 @@
<script lang="ts" setup>
import { useDialogRoute } from '@/composables/use-dialog-route';
import { getPanel, getPanels } from '@/panels';
import { useExtension } from '@/composables/use-extension';
import { useExtensions } from '@/extensions';
import { useInsightsStore } from '@/stores/insights';
import { CreatePanel } from '@/stores/insights';
import { Panel } from '@directus/shared/types';
@@ -135,7 +136,7 @@ const edits = reactive<Partial<Panel>>({
const insightsStore = useInsightsStore();
const { panels } = storeToRefs(insightsStore);
const { panels: panelTypes } = getPanels();
const { panels: panelTypes } = useExtensions();
const router = useRouter();
@@ -158,9 +159,10 @@ const selectItems = computed<FancySelectItem[]>(() => {
});
});
const currentTypeInfo = computed(() => {
return unref(panel).type ? getPanel(unref(panel).type) : null;
});
const currentTypeInfo = useExtension(
'panel',
computed(() => panel.value.type ?? null)
);
const customOptionsFields = computed(() => {
if (typeof currentTypeInfo.value?.options === 'function') {

View File

@@ -1,69 +0,0 @@
import { router } from '@/router';
import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { getRootPath } from '@/utils/get-root-path';
import RouterPass from '@/utils/router-passthrough';
import { getModules } from './index';
import { ModuleConfig } from '@directus/shared/types';
const { modulesRaw } = getModules();
let queuedModules: ModuleConfig[] = [];
export async function loadModules(): Promise<void> {
const moduleModules = import.meta.globEager('./*/index.ts');
const modules: ModuleConfig[] = Object.values(moduleModules).map((module) => module.default);
try {
const customModules: { default: ModuleConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-module')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/modules/index.js`);
modules.push(...customModules.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom modules`);
// eslint-disable-next-line no-console
console.warn(err);
}
queuedModules = modules;
}
export async function register(): Promise<void> {
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const registeredModules = [];
for (const mod of queuedModules) {
if (!userStore.currentUser) continue;
if (mod.preRegisterCheck) {
const allowed = await mod.preRegisterCheck(userStore.currentUser, permissionsStore.permissions);
if (allowed) registeredModules.push(mod);
} else {
registeredModules.push(mod);
}
}
for (const module of registeredModules) {
router.addRoute({
name: module.id,
path: `/${module.id}`,
component: RouterPass,
children: module.routes,
});
}
modulesRaw.value = registeredModules;
}
export function unregister(): void {
for (const module of modulesRaw.value) {
router.removeRoute(module.id);
}
modulesRaw.value = [];
}

View File

@@ -10,7 +10,7 @@ import { Field, DeepPartial } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store';
import { storeToRefs } from 'pinia';
import { getInterface } from '@/interfaces';
import { useExtension } from '@/composables/use-extension';
const { t } = useI18n();
@@ -19,7 +19,7 @@ const fieldDetailStore = useFieldDetailStore();
const { field, collection } = storeToRefs(fieldDetailStore);
const conditions = syncFieldDetailStoreProperty('field.meta.conditions');
const interfaceID = computed(() => field.value.meta?.interface);
const interfaceId = computed(() => field.value.meta?.interface ?? null);
const repeaterFields = computed<DeepPartial<Field>[]>(() => [
{
@@ -90,23 +90,24 @@ const repeaterFields = computed<DeepPartial<Field>[]>(() => [
meta: {
interface: 'system-interface-options',
options: {
interface: interfaceID.value,
interface: interfaceId.value,
},
},
},
]);
const selectedInterface = useExtension('interface', interfaceId);
const optionDefaults = computed(() => {
const selectedInterface = getInterface(interfaceID.value);
if (!selectedInterface || !selectedInterface.options) return [];
if (!selectedInterface.value || !selectedInterface.value.options) return [];
// Indicates a custom vue component is used for the interface options
if ('render' in selectedInterface.options) return [];
if ('render' in selectedInterface.value.options) return [];
let optionsObjectOrArray;
if (typeof selectedInterface.options === 'function') {
optionsObjectOrArray = selectedInterface.options({
if (typeof selectedInterface.value.options === 'function') {
optionsObjectOrArray = selectedInterface.value.options({
field: {
type: 'unknown',
},
@@ -133,7 +134,7 @@ const optionDefaults = computed(() => {
saving: false,
});
} else {
optionsObjectOrArray = selectedInterface.options;
optionsObjectOrArray = selectedInterface.value.options;
}
const optionsArray = Array.isArray(optionsObjectOrArray)

View File

@@ -21,12 +21,11 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed } from 'vue';
import { getDisplay } from '@/displays';
import { getInterface } from '@/interfaces';
import { clone } from 'lodash';
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store';
import { storeToRefs } from 'pinia';
import ExtensionOptions from '../shared/extension-options.vue';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
components: { ExtensionOptions },
@@ -37,11 +36,11 @@ export default defineComponent({
const { field, displaysForType } = storeToRefs(fieldDetailStore);
const interfaceID = computed(() => field.value.meta?.interface);
const interfaceId = computed(() => field.value.meta?.interface ?? null);
const display = syncFieldDetailStoreProperty('field.meta.display');
const selectedInterface = computed(() => getInterface(interfaceID.value));
const selectedDisplay = computed(() => getDisplay(display.value));
const selectedInterface = useExtension('interface', interfaceId);
const selectedDisplay = useExtension('display', display);
const selectItems = computed(() => {
let recommended = clone(selectedInterface.value?.recommendedDisplays) || [];

View File

@@ -1,19 +1,19 @@
<template>
<div>
<v-fancy-select v-model="interfaceID" class="select" :items="selectItems" />
<v-fancy-select v-model="interfaceId" class="select" :items="selectItems" />
<v-notice v-if="interfaceID && !selectedInterface" class="not-found" type="danger">
{{ t('interface_not_found', { interface: interfaceID }) }}
<v-notice v-if="interfaceId && !selectedInterface" class="not-found" type="danger">
{{ t('interface_not_found', { interface: interfaceId }) }}
<div class="spacer" />
<button @click="interfaceID = null">{{ t('reset_interface') }}</button>
<button @click="interfaceId = null">{{ t('reset_interface') }}</button>
</v-notice>
<extension-options
v-if="interfaceID && selectedInterface"
v-if="interfaceId && selectedInterface"
v-model="options"
type="interface"
:options="customOptionsFields"
:extension="interfaceID"
:extension="interfaceId"
show-advanced
/>
</div>
@@ -22,10 +22,10 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed } from 'vue';
import { getInterface } from '@/interfaces';
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
import { storeToRefs } from 'pinia';
import ExtensionOptions from '../shared/extension-options.vue';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
components: { ExtensionOptions },
@@ -34,7 +34,7 @@ export default defineComponent({
const fieldDetailStore = useFieldDetailStore();
const interfaceID = syncFieldDetailStoreProperty('field.meta.interface');
const interfaceId = syncFieldDetailStoreProperty('field.meta.interface');
const { field, interfacesForType } = storeToRefs(fieldDetailStore);
const type = computed(() => field.value.type);
@@ -93,15 +93,11 @@ export default defineComponent({
return recommendedItems;
});
const selectedInterface = computed(() => getInterface(interfaceID.value));
const extensionInfo = computed(() => {
return getInterface(interfaceID.value);
});
const selectedInterface = useExtension('interface', interfaceId);
const customOptionsFields = computed(() => {
if (typeof extensionInfo.value?.options === 'function') {
return extensionInfo.value?.options(fieldDetailStore);
if (typeof selectedInterface.value?.options === 'function') {
return selectedInterface.value?.options(fieldDetailStore);
}
return null;
@@ -121,7 +117,7 @@ export default defineComponent({
},
});
return { t, selectItems, selectedInterface, interfaceID, customOptionsFields, options };
return { t, selectItems, selectedInterface, interfaceId, customOptionsFields, options };
},
});
</script>

View File

@@ -63,12 +63,13 @@
<script lang="ts">
import { defineComponent, computed, watch } from 'vue';
import { getInterface, getInterfaces } from '@/interfaces';
import { useI18n } from 'vue-i18n';
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
import { storeToRefs } from 'pinia';
import ExtensionOptions from '../shared/extension-options.vue';
import RelationshipConfiguration from './relationship-configuration.vue';
import { useExtensions } from '@/extensions';
import { useExtension } from '@/composables/use-extension';
import { nanoid } from 'nanoid/non-secure';
export default defineComponent({
@@ -94,7 +95,7 @@ export default defineComponent({
const required = syncFieldDetailStoreProperty('field.meta.required', false);
const note = syncFieldDetailStoreProperty('field.meta.note');
const chosenInterfaceConfig = computed(() => getInterface(chosenInterface.value));
const chosenInterfaceConfig = useExtension('interface', chosenInterface);
const typeOptions = computed(() => {
if (!chosenInterfaceConfig.value) return [];
@@ -107,7 +108,7 @@ export default defineComponent({
const typeDisabled = computed(() => typeOptions.value.length === 1 || localType.value !== 'standard');
const { interfaces } = getInterfaces();
const { interfaces } = useExtensions();
const interfaceIdsWithHiddenLabel = computed(() =>
interfaces.value.filter((inter) => inter.hideLabel === true).map((inter) => inter.id)

View File

@@ -42,11 +42,11 @@
import { defineComponent, PropType, computed, toRefs, watch } from 'vue';
import { Collection } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { getInterfaces } from '@/interfaces';
import { orderBy } from 'lodash';
import { useFieldDetailStore, syncFieldDetailStoreProperty } from '../store/';
import { syncRefProperty } from '@/utils/sync-ref-property';
import FieldConfiguration from './field-configuration.vue';
import { useExtensions } from '@/extensions';
export default defineComponent({
components: { FieldConfiguration },
@@ -69,7 +69,7 @@ export default defineComponent({
const fieldDetail = useFieldDetailStore();
watch(collection, () => fieldDetail.update({ collection: collection.value.collection }), { immediate: true });
const { interfaces } = getInterfaces();
const { interfaces } = useExtensions();
const interfacesSorted = computed(() => {
return orderBy(

View File

@@ -26,13 +26,10 @@
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { getOperation } from '@/operations';
import { getInterface } from '@/interfaces';
import { getDisplay } from '@/displays';
import { getPanel } from '@/panels';
import { useI18n } from 'vue-i18n';
import { useFieldDetailStore } from '../store';
import { storeToRefs } from 'pinia';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
props: {
@@ -73,20 +70,7 @@ export default defineComponent({
const { collection, field } = storeToRefs(fieldDetailStore);
const extensionInfo = computed(() => {
switch (props.type) {
case 'interface':
return getInterface(props.extension);
case 'display':
return getDisplay(props.extension);
case 'panel':
return getPanel(props.extension);
case 'operation':
return getOperation(props.extension);
default:
return null;
}
});
const extensionInfo = useExtension(props.type, props.extension);
const usesCustomComponent = computed(() => {
if (!extensionInfo.value) return false;

View File

@@ -1,6 +1,6 @@
import { useExtension } from '@/composables/use-extension';
import { set } from 'lodash';
import { State, StateUpdates } from '../types';
import { getInterface } from '@/interfaces';
/**
* In case a relational field removed the schema object, we'll have to make sure it's re-added
@@ -20,11 +20,11 @@ export function resetSchema(updates: StateUpdates, state: State) {
export function setLocalTypeForInterface(updates: StateUpdates) {
if (!updates.field?.meta?.interface) return;
const chosenInterface = getInterface(updates.field.meta.interface);
const chosenInterface = useExtension('interface', updates.field.meta.interface);
if (!chosenInterface) return;
if (!chosenInterface.value) return;
const localType = chosenInterface?.localTypes?.[0] ?? 'standard';
const localType = chosenInterface.value?.localTypes?.[0] ?? 'standard';
set(updates, 'localType', localType);
}
@@ -36,13 +36,13 @@ export function setLocalTypeForInterface(updates: StateUpdates) {
export function setTypeForInterface(updates: StateUpdates, state: State) {
if (!updates.field?.meta?.interface) return;
const chosenInterface = getInterface(updates.field.meta.interface);
const chosenInterface = useExtension('interface', updates.field.meta.interface);
if (!chosenInterface) return updates;
if (!chosenInterface.value) return updates;
if (state.field.type && chosenInterface.types.includes(state.field.type)) return;
if (state.field.type && chosenInterface.value.types.includes(state.field.type)) return;
const defaultType = chosenInterface?.types[0];
const defaultType = chosenInterface.value?.types[0];
set(updates, 'field.type', defaultType);
}

View File

@@ -1,6 +1,6 @@
import { HelperFunctions, State, StateUpdates } from '../types';
import { getInterface } from '@/interfaces';
import { set } from 'lodash';
import { useExtension } from '@/composables/use-extension';
import { getSpecialForType } from '@/utils/get-special-for-type';
export function applyChanges(updates: StateUpdates, _state: State, helperFn: HelperFunctions) {
@@ -20,9 +20,10 @@ function setSpecialForType(updates: StateUpdates) {
}
function updateInterface(updates: StateUpdates, fn: HelperFunctions) {
const interface_ = getInterface(fn.getCurrent('field.meta.interface'));
const inter = useExtension('interface', fn.getCurrent('field.meta.interface'));
const type = updates.field?.type;
if (type && !interface_?.types.includes(type)) {
if (type && !inter.value?.types.includes(type)) {
set(updates, 'field.meta.interface', undefined);
}
}

View File

@@ -1,6 +1,4 @@
import { defineStore } from 'pinia';
import { getInterfaces } from '@/interfaces';
import { getDisplays } from '@/displays';
import { has, isEmpty, orderBy, cloneDeep } from 'lodash';
import {
InterfaceConfig,
@@ -22,6 +20,7 @@ import { useRelationsStore } from '@/stores/relations';
import * as alterations from './alterations';
import { getLocalTypeForField } from '@/utils/get-local-type';
import api from '@/api';
import { useExtensions } from '@/extensions';
export function syncFieldDetailStoreProperty(path: string, defaultValue?: any) {
const fieldDetailStore = useFieldDetailStore();
@@ -259,7 +258,7 @@ export const useFieldDetailStore = defineStore({
return missing.length === 0;
},
interfacesForType(): InterfaceConfig[] {
const { interfaces } = getInterfaces();
const { interfaces } = useExtensions();
return orderBy(
interfaces.value.filter((inter: InterfaceConfig) => {
@@ -275,7 +274,7 @@ export const useFieldDetailStore = defineStore({
);
},
displaysForType(): DisplayConfig[] {
const { displays } = getDisplays();
const { displays } = useExtensions();
return orderBy(
displays.value.filter((inter: DisplayConfig) => {

View File

@@ -148,7 +148,6 @@ import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, ref, computed } from 'vue';
import { useCollectionsStore } from '@/stores/collections';
import { useFieldsStore } from '@/stores/fields';
import { getInterface } from '@/interfaces';
import { useRouter } from 'vue-router';
import { cloneDeep } from 'lodash';
import { getLocalTypeForField } from '@/utils/get-local-type';
@@ -160,6 +159,7 @@ import FieldSelectMenu from './field-select-menu.vue';
import { hideDragImage } from '@/utils/hide-drag-image';
import Draggable from 'vuedraggable';
import formatTitle from '@directus/format-title';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
name: 'FieldSelect',
@@ -192,7 +192,11 @@ export default defineComponent({
const { deleteActive, deleting, deleteField } = useDeleteField();
const { duplicateActive, duplicateName, collections, duplicateTo, saveDuplicate, duplicating } = useDuplicate();
const interfaceName = computed(() => getInterface(props.field.meta?.interface)?.name);
const inter = useExtension(
'interface',
computed(() => props.field.meta?.interface ?? null)
);
const interfaceName = computed(() => inter.value?.name ?? null);
const hidden = computed(() => props.field.meta?.hidden === true);

View File

@@ -79,7 +79,7 @@
<script lang="ts" setup>
import { useRevisions } from '@/composables/use-revisions';
import { getOperations } from '@/operations';
import { useExtensions } from '@/extensions';
import { Action, FlowRaw } from '@directus/shared/types';
import { computed, ref, toRefs, unref } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -96,7 +96,7 @@ const props = defineProps<Props>();
const { flow } = toRefs(props);
const { triggers } = getTriggers();
const { operations } = getOperations();
const { operations } = useExtensions();
const usedTrigger = computed(() => {
return triggers.find((trigger) => trigger.id === unref(flow).trigger);

View File

@@ -45,7 +45,7 @@
<v-notice v-if="operationType && !selectedOperation" class="not-found" type="danger">
{{ t('operation_not_found', { operation: operationType }) }}
<div class="spacer" />
<button @click="operationType = undefined">{{ t('reset_interface') }}</button>
<button @click="operationType = null">{{ t('reset_interface') }}</button>
</v-notice>
<extension-options
@@ -68,12 +68,13 @@
<script setup lang="ts">
import { useDialogRoute } from '@/composables/use-dialog-route';
import ExtensionOptions from '@/modules/settings/routes/data-model/field-detail/shared/extension-options.vue';
import { getOperation, getOperations } from '@/operations';
import { translate } from '@/utils/translate-object-values';
import { FlowRaw } from '@directus/shared/types';
import slugify from '@sindresorhus/slugify';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useExtensions } from '@/extensions';
import { useExtension } from '@/composables/use-extension';
import { customAlphabet } from 'nanoid/non-secure';
const generateSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 5);
@@ -98,7 +99,7 @@ const isOpen = useDialogRoute();
const { t } = useI18n();
const options = ref<Record<string, any>>(props.operation?.options ?? {});
const operationType = ref<string | undefined>(props.operation?.type);
const operationType = ref<string | null>(props.operation?.type ?? null);
const operationKey = ref<string | null>(props.operation?.key ?? null);
const operationName = ref<string | null>(props.operation?.name ?? null);
@@ -149,7 +150,7 @@ watch(
{ immediate: true }
);
const selectedOperation = computed(() => getOperation(operationType.value));
const selectedOperation = useExtension('operation', operationType);
const generatedName = computed(() => (selectedOperation.value ? selectedOperation.value?.name : t('operation_name')));
@@ -159,7 +160,7 @@ const generatedKey = computed(() =>
: t('operation_key')
);
const { operations } = getOperations();
const { operations } = useExtensions();
const displayOperations = computed(() => {
return operations.value.map((operation) => ({

View File

@@ -136,7 +136,7 @@
<script lang="ts" setup>
import { useClipboard } from '@/composables/use-clipboard';
import { getOperations } from '@/operations';
import { useExtensions } from '@/extensions';
import { translate } from '@/utils/translate-object-values';
import { Vector2 } from '@/utils/vector2';
import { FlowRaw } from '@directus/shared/types';
@@ -174,7 +174,7 @@ const props = withDefaults(
const { panelsToBeDeleted } = toRefs(props);
const { operations } = getOperations();
const { operations } = useExtensions();
const { triggers } = getTriggers();
const emit = defineEmits([

View File

@@ -201,7 +201,7 @@ import { Vector2 } from '@/utils/vector2';
import FlowDrawer from './flow-drawer.vue';
import LogsSidebarDetail from './components/logs-sidebar-detail.vue';
import { getOperations } from '@/operations';
import { useExtensions } from '@/extensions';
// Maps the x and y coordinates of attachments of panels to their id
export type Attachments = Record<number, Record<number, string>>;
@@ -263,7 +263,7 @@ async function deleteFlow() {
// ------------- Manage Panels ------------- //
const { operations } = getOperations();
const { operations } = useExtensions();
const triggerDetailOpen = ref(false);
const stagedPanels = ref<Partial<OperationRaw & { borderRadius: [boolean, boolean, boolean, boolean] }>[]>([]);

View File

@@ -155,8 +155,8 @@ import { usePermissionsStore } from '@/stores/permissions';
import { useUserStore } from '@/stores/user';
import { usePresetsStore } from '@/stores/presets';
import DrawerBatch from '@/views/private/components/drawer-batch.vue';
import { getLayouts } from '@/layouts';
import { usePreset } from '@/composables/use-preset';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
name: 'ContentCollection',
@@ -175,7 +175,6 @@ export default defineComponent({
const { t } = useI18n();
const { layouts } = getLayouts();
const userStore = useUserStore();
const permissionsStore = usePermissionsStore();
const layoutRef = ref();
@@ -187,7 +186,7 @@ export default defineComponent({
const { confirmDelete, deleting, batchDelete, error: deleteError, batchEditActive } = useBatch();
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
const currentLayout = useExtension('layout', layout);
const { batchEditAllowed, batchDeleteAllowed, createAllowed } = usePermissions();

View File

@@ -138,13 +138,13 @@ import { Preset, Filter } from '@directus/shared/types';
import api from '@/api';
import { useCollectionsStore } from '@/stores/collections';
import { usePresetsStore } from '@/stores/presets';
import { getLayouts } from '@/layouts';
import { useRouter } from 'vue-router';
import { unexpectedError } from '@/utils/unexpected-error';
import { useLayout } from '@directus/shared/composables';
import { useShortcut } from '@/composables/use-shortcut';
import { useEditsGuard } from '@/composables/use-edits-guard';
import { isEqual } from 'lodash';
import { useExtensions } from '@/extensions';
type FormattedPreset = {
id: number;
@@ -174,7 +174,7 @@ const router = useRouter();
const collectionsStore = useCollectionsStore();
const presetsStore = usePresetsStore();
const { layouts } = getLayouts();
const { layouts } = useExtensions();
const isNew = computed(() => props.id === '+');

View File

@@ -1,13 +1,29 @@
import { shallowRef, Ref } from 'vue';
import { App } from 'vue';
import { OperationAppConfig } from '@directus/shared/types';
import { sortBy } from 'lodash';
const operationsRaw: Ref<OperationAppConfig[]> = shallowRef([]);
const operations: Ref<OperationAppConfig[]> = shallowRef([]);
export function getInternalOperations(): OperationAppConfig[] {
const operations = import.meta.glob<OperationAppConfig>('./*/index.ts', { import: 'default', eager: true });
export function getOperations(): { operations: Ref<OperationAppConfig[]>; operationsRaw: Ref<OperationAppConfig[]> } {
return { operations, operationsRaw };
return sortBy(Object.values(operations), 'id');
}
export function getOperation(name?: string | null): OperationAppConfig | undefined {
return !name ? undefined : operations.value.find(({ id }) => id === name);
export function registerOperations(operations: OperationAppConfig[], app: App): void {
for (const operation of operations) {
if (
typeof operation.overview !== 'function' &&
Array.isArray(operation.overview) === false &&
operation.overview !== null
) {
app.component(`operation-overview-${operation.id}`, operation.overview);
}
if (
typeof operation.options !== 'function' &&
Array.isArray(operation.options) === false &&
operation.options !== null
) {
app.component(`operation-options-${operation.id}`, operation.options);
}
}
}

View File

@@ -1,45 +0,0 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getOperations } from './index';
import { OperationAppConfig } from '@directus/shared/types';
const { operationsRaw } = getOperations();
export async function registerOperations(app: App): Promise<void> {
const operationModules = import.meta.globEager('./*/**/index.ts');
const operations: OperationAppConfig[] = Object.values(operationModules).map((module) => module.default);
try {
const customOperations: { default: OperationAppConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-operation')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/operations/index.js`);
operations.push(...customOperations.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom operations`);
// eslint-disable-next-line no-console
console.warn(err);
}
operationsRaw.value = operations;
operationsRaw.value.forEach((operation: OperationAppConfig) => {
if (
typeof operation.overview !== 'function' &&
Array.isArray(operation.overview) === false &&
operation.overview !== null
) {
app.component(`operation-overview-${operation.id}`, operation.overview);
}
if (
typeof operation.options !== 'function' &&
Array.isArray(operation.options) === false &&
operation.options !== null
) {
app.component(`operation-options-${operation.id}`, operation.options);
}
});
}

View File

@@ -1,13 +1,19 @@
import { shallowRef, Ref } from 'vue';
import { App } from 'vue';
import { PanelConfig } from '@directus/shared/types';
import { sortBy } from 'lodash';
const panelsRaw: Ref<PanelConfig[]> = shallowRef([]);
const panels: Ref<PanelConfig[]> = shallowRef([]);
export function getInternalPanels(): PanelConfig[] {
const panels = import.meta.glob<PanelConfig>('./*/index.ts', { import: 'default', eager: true });
export function getPanels(): { panels: Ref<PanelConfig[]>; panelsRaw: Ref<PanelConfig[]> } {
return { panels, panelsRaw };
return sortBy(Object.values(panels), 'id');
}
export function getPanel(name?: string | null): PanelConfig | undefined {
return !name ? undefined : panels.value.find(({ id }) => id === name);
export function registerPanels(panels: PanelConfig[], app: App): void {
for (const panel of panels) {
app.component(`panel-${panel.id}`, panel.component);
if (typeof panel.options !== 'function' && Array.isArray(panel.options) === false && panel.options !== null) {
app.component(`panel-options-${panel.id}`, panel.options);
}
}
}

View File

@@ -1,35 +0,0 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getPanels } from './index';
import { PanelConfig } from '@directus/shared/types';
const { panelsRaw } = getPanels();
export async function registerPanels(app: App): Promise<void> {
const panelModules = import.meta.globEager('./*/**/index.ts');
const panels: PanelConfig[] = Object.values(panelModules).map((module) => module.default);
try {
const customPanels: { default: PanelConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-panel')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/panels/index.js`);
panels.push(...customPanels.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom panels`);
// eslint-disable-next-line no-console
console.warn(err);
}
panelsRaw.value = panels;
panelsRaw.value.forEach((panel: PanelConfig) => {
app.component(`panel-${panel.id}`, panel.component);
if (typeof panel.options !== 'function' && Array.isArray(panel.options) === false && panel.options !== null) {
app.component(`panel-options-${panel.id}`, panel.options);
}
});
}

36
app/src/shims.d.ts vendored
View File

@@ -33,38 +33,4 @@ declare module 'frappe-charts/src/js/charts/AxisChart' {
}
}
declare module '@directus-extensions-operation' {
import { OperationAppConfig } from '@directus/shared/types';
const operations: OperationAppConfig[];
export default operations;
}
declare module '@directus-extensions-interface' {
import { InterfaceConfig } from '@directus/shared/types';
const interfaces: InterfaceConfig[];
export default interfaces;
}
declare module '@directus-extensions-display' {
import { DisplayConfig } from '@directus/shared/types';
const displays: DisplayConfig[];
export default displays;
}
declare module '@directus-extensions-layout' {
import { LayoutConfig } from '@directus/shared/types';
const layouts: LayoutConfig[];
export default layouts;
}
declare module '@directus-extensions-panel' {
import { PanelConfig } from '@directus/shared/types';
const panel: PanelConfig[];
export default panel;
}
declare module '@directus-extensions-module' {
import { ModuleConfig } from '@directus/shared/types';
const modules: ModuleConfig[];
export default modules;
}
declare module '@directus-extensions' {}

View File

@@ -1,5 +1,4 @@
import api from '@/api';
import { getPanels } from '@/panels';
import { usePermissionsStore } from '@/stores/permissions';
import { queryToGqlString } from '@/utils/query-to-gql-string';
import { unexpectedError } from '@/utils/unexpected-error';
@@ -12,6 +11,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia';
import { computed, reactive, ref, unref } from 'vue';
import { Dashboard } from '../types';
import escapeStringRegexp from 'escape-string-regexp';
import { useExtensions } from '@/extensions';
export type CreatePanel = Partial<Panel> &
Pick<Panel, 'id' | 'width' | 'height' | 'position_x' | 'position_y' | 'type' | 'options'>;
@@ -72,7 +72,7 @@ export const useInsightsStore = defineStore('insightsStore', () => {
];
});
const { panels: panelTypes } = getPanels();
const { panels: panelTypes } = useExtensions();
return {
dashboards,

View File

@@ -1,6 +1,7 @@
import { getDisplay } from '@/displays';
import { useExtension } from '@/composables/use-extension';
import { useFieldsStore } from '@/stores/fields';
import { Field } from '@directus/shared/types';
import { computed } from 'vue';
export function adjustFieldsForDisplays(fields: readonly string[], parentCollection: string): string[] {
const fieldsStore = useFieldsStore();
@@ -12,19 +13,22 @@ export function adjustFieldsForDisplays(fields: readonly string[], parentCollect
if (!field) return fieldKey;
if (field.meta?.display === null) return fieldKey;
const display = getDisplay(field.meta?.display);
const display = useExtension(
'display',
computed(() => field.meta?.display ?? null)
);
if (!display) return fieldKey;
if (!display?.fields) return fieldKey;
if (!display.value?.fields) return fieldKey;
let fieldKeys: string[] | null = null;
if (Array.isArray(display.fields)) {
fieldKeys = display.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
if (Array.isArray(display.value.fields)) {
fieldKeys = display.value.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
}
if (typeof display.fields === 'function') {
fieldKeys = display
if (typeof display.value.fields === 'function') {
fieldKeys = display.value
.fields(field.meta?.display_options, {
collection: field.collection,
field: field.field,

View File

@@ -1,11 +1,11 @@
import { useAliasFields } from '@/composables/use-alias-fields';
import { getDisplay } from '@/displays';
import { useFieldsStore } from '@/stores/fields';
import { DisplayConfig, Field } from '@directus/shared/types';
import { Field } from '@directus/shared/types';
import { getFieldsFromTemplate } from '@directus/shared/utils';
import { render, renderFn } from 'micromustache';
import { computed, ComputedRef, Ref, ref, unref } from 'vue';
import { get, set } from 'lodash';
import { useExtension } from '@/composables/use-extension';
type StringTemplate = {
fieldsInTemplate: ComputedRef<string[]>;
@@ -77,18 +77,17 @@ export function renderDisplayStringTemplate(
? get(item, key)
: get(item, aliasFields.value[key].fullAlias);
let display: DisplayConfig | undefined;
if (fieldsUsed[key]?.meta?.display) {
display = getDisplay(fieldsUsed[key]!.meta!.display);
}
const display = useExtension(
'display',
computed(() => fieldsUsed[key]?.meta?.display ?? null)
);
if (value !== undefined && value !== null) {
set(
parsedItem,
key,
display?.handler
? display.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
display.value?.handler
? display.value.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
interfaceOptions: fieldsUsed[key]?.meta?.options ?? {},
field: fieldsUsed[key] ?? undefined,
collection: collection,

View File

@@ -1,11 +1,11 @@
import { useAliasFields } from '@/composables/use-alias-fields';
import { getDisplay } from '@/displays';
import { useFieldsStore } from '@/stores/fields';
import { get } from '@directus/shared/utils';
import { DisplayConfig, Field, Item } from '@directus/shared/types';
import { Field, Item } from '@directus/shared/types';
import { saveAs } from 'file-saver';
import { parse } from 'json2csv';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useExtension } from '@/composables/use-extension';
/**
* Saves the given collection + items combination as a CSV file
@@ -48,15 +48,14 @@ export async function saveAsCSV(collection: string, fields: string[], items: Ite
? get(item, key)
: get(item, aliasFields.value[key].fullAlias);
let display: DisplayConfig | undefined;
if (fieldsUsed[key]?.meta?.display) {
display = getDisplay(fieldsUsed[key]!.meta!.display);
}
const display = useExtension(
'display',
computed(() => fieldsUsed[key]?.meta?.display ?? null)
);
if (value !== undefined && value !== null) {
parsedItem[name] = display?.handler
? await display.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
parsedItem[name] = display.value?.handler
? await display.value.handler(value, fieldsUsed[key]?.meta?.display_options ?? {}, {
interfaceOptions: fieldsUsed[key]?.meta?.options ?? {},
field: fieldsUsed[key] ?? undefined,
collection: collection,

View File

@@ -18,8 +18,9 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed } from 'vue';
import { getLayouts, getLayout } from '@/layouts';
import { useSync } from '@directus/shared/composables';
import { useExtensions } from '@/extensions';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
props: {
@@ -32,9 +33,11 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const { layouts } = getLayouts();
const { layouts } = useExtensions();
const currentLayout = computed(() => getLayout(props.modelValue) ?? getLayout('tabular'));
const selectedLayout = useExtension('layout', props.modelValue);
const fallbackLayout = useExtension('layout', 'tabular');
const currentLayout = computed(() => selectedLayout.value ?? fallbackLayout.value);
const layout = useSync(props, 'modelValue', emit);

View File

@@ -30,13 +30,13 @@
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { getModules } from '@/modules/';
import ModuleBarLogo from './module-bar-logo.vue';
import ModuleBarAvatar from './module-bar-avatar.vue';
import { useSettingsStore } from '@/stores/settings';
import { translate } from '@/utils/translate-object-values';
import { MODULE_BAR_DEFAULT } from '@/constants';
import { omit } from 'lodash';
import { useExtensions } from '@/extensions';
export default defineComponent({
components: {
@@ -45,7 +45,7 @@ export default defineComponent({
},
setup() {
const settingsStore = useSettingsStore();
const { modules: registeredModules } = getModules();
const { modules: registeredModules } = useExtensions();
const registeredModuleIDs = computed(() => registeredModules.value.map((module) => module.id));

View File

@@ -15,9 +15,9 @@
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { getDisplay } from '@/displays';
import { defineComponent } from 'vue';
import { translate } from '@/utils/translate-object-values';
import { useExtension } from '@/composables/use-extension';
export default defineComponent({
props: {
@@ -55,7 +55,8 @@ export default defineComponent({
},
},
setup(props) {
const displayInfo = computed(() => getDisplay(props.display));
const displayInfo = useExtension('display', props.display);
return { displayInfo, translate };
},
});

View File

@@ -25,9 +25,9 @@ import { computed, ref } from 'vue';
import { useFieldsStore } from '@/stores/fields';
import { get } from 'lodash';
import { Field } from '@directus/shared/types';
import { getDisplay } from '@/displays';
import { getDefaultDisplayForType } from '@/utils/get-default-display-for-type';
import { translate } from '@/utils/translate-object-values';
import { useExtension } from '@/composables/use-extension';
interface Props {
template: string;
@@ -79,12 +79,15 @@ function handleArray(fieldKeyBefore: string, fieldKeyAfter: string) {
if (!field) return value;
const displayInfo = getDisplay(field.meta?.display);
const displayInfo = useExtension(
'display',
computed(() => field.meta?.display ?? null)
);
let component = field.meta?.display;
let options = field.meta?.display_options;
if (!displayInfo) {
if (!displayInfo.value) {
component = 'related-values';
options = { template: `{{${fieldKeyAfter}}}` };
}
@@ -131,10 +134,13 @@ function handleObject(fieldKey: string) {
// No need to render the empty display overhead in this case
if (display === 'raw') return value;
const displayInfo = getDisplay(field.meta?.display);
const displayInfo = useExtension(
'display',
computed(() => field.meta?.display ?? null)
);
// If used display doesn't exist in the current project, return raw value
if (!displayInfo) return value;
if (!displayInfo.value) return value;
return {
component: field.meta?.display,

View File

@@ -5,7 +5,7 @@ import {
} from '@directus/shared/constants';
import {
ensureExtensionDirs,
generateExtensionsEntry,
generateExtensionsEntrypoint,
getLocalExtensions,
getPackageExtensions,
} from '@directus/shared/utils/node';
@@ -156,6 +156,7 @@ export default defineConfig({
},
test: {
environment: 'happy-dom',
setupFiles: ['src/__setup__/mock-globals.ts'],
},
});
@@ -170,10 +171,9 @@ function getExtensionsRealPaths() {
}
function directusExtensions() {
const prefix = '@directus-extensions-';
const virtualIds = APP_OR_HYBRID_EXTENSION_TYPES.map((type) => `${prefix}${type}`);
const virtualExtensionsId = '@directus-extensions';
let extensionEntrypoints = {};
let extensionsEntrypoint = null;
return [
{
@@ -188,15 +188,13 @@ function directusExtensions() {
await loadExtensions();
},
resolveId(id) {
if (virtualIds.includes(id)) {
if (id === virtualExtensionsId) {
return id;
}
},
load(id) {
if (virtualIds.includes(id)) {
const extensionType = id.substring(prefix.length);
return extensionEntrypoints[extensionType];
if (id === virtualExtensionsId) {
return extensionsEntrypoint;
}
},
},
@@ -213,7 +211,7 @@ function directusExtensions() {
output: {
entryFileNames: 'assets/[name].[hash].entry.js',
},
external: virtualIds,
external: [virtualExtensionsId],
preserveEntrySignatures: 'exports-only',
},
},
@@ -228,8 +226,6 @@ function directusExtensions() {
const extensions = [...packageExtensions, ...localExtensions];
for (const extensionType of APP_OR_HYBRID_EXTENSION_TYPES) {
extensionEntrypoints[extensionType] = generateExtensionsEntry(extensionType, extensions);
}
extensionsEntrypoint = generateExtensionsEntrypoint(extensions);
}
}

View File

@@ -2,7 +2,7 @@
'use strict';
const inquirer = require('inquirer');
const { EXTENSION_TYPES, EXTENSION_LANGUAGES } = require('@directus/shared/constants');
const { EXTENSION_LANGUAGES, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES } = require('@directus/shared/constants');
const { create } = require('@directus/extensions-sdk/cli');
run();
@@ -16,7 +16,7 @@ async function run() {
type: 'list',
name: 'type',
message: 'Choose the extension type',
choices: EXTENSION_TYPES,
choices: EXTENSION_PACKAGE_TYPES,
},
{
type: 'input',
@@ -28,6 +28,7 @@ async function run() {
name: 'language',
message: 'Choose the language to use',
choices: EXTENSION_LANGUAGES,
when: ({ type }) => EXTENSION_TYPES.includes(type),
},
]);

View File

@@ -54,11 +54,13 @@
"@rollup/plugin-node-resolve": "15.0.0",
"@rollup/plugin-replace": "5.0.0",
"@rollup/plugin-terser": "0.1.0",
"@rollup/plugin-virtual": "2.1.0",
"@vue/compiler-sfc": "3.2.41",
"chalk": "4.1.1",
"commander": "9.4.1",
"execa": "5.1.1",
"fs-extra": "10.1.0",
"inquirer": "^8.2.4",
"ora": "5.4.0",
"rollup": "3.2.3",
"rollup-plugin-styles": "4.0.0",
@@ -67,6 +69,7 @@
},
"devDependencies": {
"@types/fs-extra": "9.0.13",
"@types/inquirer": "8.2.1",
"npm-run-all": "4.1.5",
"rimraf": "3.0.2",
"typescript": "4.8.4"

View File

@@ -0,0 +1,304 @@
import path from 'path';
import chalk from 'chalk';
import fse from 'fs-extra';
import inquirer from 'inquirer';
import { log } from '../utils/logger';
import {
ExtensionManifestRaw,
ExtensionOptions,
ExtensionOptionsBundleEntry,
ExtensionType,
} from '@directus/shared/types';
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
import { pathToRelativeUrl } from '@directus/shared/utils/node';
import {
EXTENSION_LANGUAGES,
EXTENSION_NAME_REGEX,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { getLanguageFromPath, isLanguage, languageToShort } from '../utils/languages';
import { Language } from '../types';
import getExtensionDevDeps from './helpers/get-extension-dev-deps';
import execa from 'execa';
import ora from 'ora';
import copyTemplate from './helpers/copy-template';
import detectJsonIndent from '../utils/detect-json-indent';
export default async function add(): Promise<void> {
const extensionPath = process.cwd();
const packagePath = path.resolve('package.json');
if (!(await fse.pathExists(packagePath))) {
log(`Current directory is not a valid package.`, 'error');
process.exit(1);
}
const extensionManifestFile = await fse.readFile(packagePath, 'utf8');
const extensionManifest: ExtensionManifestRaw = JSON.parse(extensionManifestFile);
const indent = detectJsonIndent(extensionManifestFile);
if (!validateExtensionManifest(extensionManifest)) {
log(`Current directory is not a valid Directus extension.`, 'error');
process.exit(1);
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (extensionOptions.type === 'pack') {
log(`Adding entries to extensions with type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
const sourceExists = await fse.pathExists(path.resolve('src'));
if (extensionOptions.type === 'bundle') {
const { type, name, language, alternativeSource } = await inquirer.prompt<{
type: ExtensionType;
name: string;
language: Language;
alternativeSource?: string;
}>([
{
type: 'list',
name: 'type',
message: 'Choose the extension type',
choices: EXTENSION_TYPES,
},
{
type: 'input',
name: 'name',
message: 'Choose a name for the entry',
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
},
{
type: 'list',
name: 'language',
message: 'Choose the language to use',
choices: EXTENSION_LANGUAGES,
},
{
type: 'input',
name: 'alternativeSource',
message: 'Specify the path to the extension source',
when: !sourceExists && extensionOptions.entries.length > 0,
},
]);
const spinner = ora(chalk.bold('Modifying Directus extension...')).start();
const source = alternativeSource ?? 'src';
const sourcePath = path.resolve(source, name);
await fse.ensureDir(sourcePath);
await copyTemplate(type, extensionPath, sourcePath, language);
const newEntries: ExtensionOptionsBundleEntry[] = [
...extensionOptions.entries,
isIn(type, HYBRID_EXTENSION_TYPES)
? {
type,
name,
source: {
app: `${pathToRelativeUrl(source)}/${name}/app.${languageToShort(language)}`,
api: `${pathToRelativeUrl(source)}/${name}/api.${languageToShort(language)}`,
},
}
: {
type,
name,
source: `${pathToRelativeUrl(source)}/${name}/index.${languageToShort(language)}`,
},
];
const newExtensionOptions: ExtensionOptions = { ...extensionOptions, entries: newEntries };
const newExtensionManifest = {
...extensionManifest,
[EXTENSION_PKG_KEY]: newExtensionOptions,
devDependencies: await getExtensionDevDeps(
newEntries.map((entry) => entry.type),
getLanguageFromEntries(newEntries)
),
};
await fse.writeJSON(packagePath, newExtensionManifest, { spaces: indent ?? '\t' });
await execa('npm', ['install'], { cwd: extensionPath });
spinner.succeed(chalk.bold('Done'));
} else {
const { proceed } = await inquirer.prompt<{ proceed: boolean }>([
{
type: 'confirm',
name: 'proceed',
message: 'This will convert your extension to a bundle. Do you want to proceed?',
},
]);
if (!proceed) {
log(`Extension has not been modified.`, 'info');
process.exit(1);
}
const oldName = extensionManifest.name.match(EXTENSION_NAME_REGEX)?.[1] ?? extensionManifest.name;
const { type, name, language, convertName, extensionName, alternativeSource } = await inquirer.prompt<{
type: ExtensionType;
name: string;
language: Language;
convertName: string;
extensionName: string;
alternativeSource?: string;
}>([
{
type: 'list',
name: 'type',
message: 'Choose the extension type',
choices: EXTENSION_TYPES,
},
{
type: 'input',
name: 'name',
message: 'Choose a name for the entry',
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
},
{
type: 'list',
name: 'language',
message: 'Choose the language to use',
choices: EXTENSION_LANGUAGES,
},
{
type: 'input',
name: 'convertName',
message: 'Choose a name for the extension that is converted to an entry',
default: oldName,
validate: (name: string) => (name.length === 0 ? 'Entry name can not be empty.' : true),
},
{
type: 'input',
name: 'extensionName',
message: 'Choose a name for the extension',
default: ({ convertName }: { convertName: string }) => (convertName !== oldName ? oldName : null),
validate: (name: string) => (name.length === 0 ? 'Extension name can not be empty.' : true),
},
{
type: 'input',
name: 'alternativeSource',
message: 'Specify the path to the extension source',
when: !sourceExists,
},
]);
const spinner = ora(chalk.bold('Modifying Directus extension...')).start();
const source = alternativeSource ?? 'src';
const convertSourcePath = path.resolve(source, convertName);
const entrySourcePath = path.resolve(source, name);
const convertFiles = await fse.readdir(source);
await Promise.all(
convertFiles.map((file) => fse.move(path.resolve(source, file), path.join(convertSourcePath, file)))
);
await fse.ensureDir(entrySourcePath);
await copyTemplate(type, extensionPath, entrySourcePath, language);
const toConvertSourceUrl = (entrypoint: string) =>
path.posix.join(pathToRelativeUrl(source), convertName, path.posix.relative(source, entrypoint));
const entries: ExtensionOptionsBundleEntry[] = [
isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)
? {
type: extensionOptions.type,
name: convertName,
source: {
app: toConvertSourceUrl(extensionOptions.source.app),
api: toConvertSourceUrl(extensionOptions.source.api),
},
}
: {
type: extensionOptions.type,
name: convertName,
source: toConvertSourceUrl(extensionOptions.source),
},
isIn(type, HYBRID_EXTENSION_TYPES)
? {
type,
name,
source: {
app: `${pathToRelativeUrl(source)}/${name}/app.${languageToShort(language)}`,
api: `${pathToRelativeUrl(source)}/${name}/api.${languageToShort(language)}`,
},
}
: {
type,
name,
source: `${pathToRelativeUrl(source)}/${name}/index.${languageToShort(language)}`,
},
];
const newExtensionOptions: ExtensionOptions = {
type: 'bundle',
path: { app: 'dist/app.js', api: 'dist/api.js' },
entries,
host: extensionOptions.host,
hidden: extensionOptions.hidden,
};
const newExtensionManifest = {
...extensionManifest,
name: EXTENSION_NAME_REGEX.test(extensionName) ? extensionName : `directus-extension-${extensionName}`,
keywords: ['directus', 'directus-extension', `directus-custom-bundle`],
[EXTENSION_PKG_KEY]: newExtensionOptions,
devDependencies: await getExtensionDevDeps(
entries.map((entry) => entry.type),
getLanguageFromEntries(entries)
),
};
await fse.writeJSON(packagePath, newExtensionManifest, { spaces: indent ?? '\t' });
await execa('npm', ['install'], { cwd: extensionPath });
spinner.succeed(chalk.bold('Done'));
}
}
function getLanguageFromEntries(entries: ExtensionOptionsBundleEntry[]): Language[] {
const languages = new Set<Language>();
for (const entry of entries) {
if (isTypeIn(entry, HYBRID_EXTENSION_TYPES)) {
const languageApp = getLanguageFromPath(entry.source.app);
const languageApi = getLanguageFromPath(entry.source.api);
if (!isLanguage(languageApp)) {
log(`App language ${chalk.bold(languageApp)} is not supported.`, 'error');
process.exit(1);
}
if (!isLanguage(languageApi)) {
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
process.exit(1);
}
languages.add(languageApp);
languages.add(languageApi);
} else {
const language = getLanguageFromPath(entry.source);
if (!isLanguage(language)) {
log(`Language ${chalk.bold(language)} is not supported.`, 'error');
process.exit(1);
}
languages.add(language);
}
}
return Array.from(languages);
}

View File

@@ -2,17 +2,23 @@ import {
API_SHARED_DEPS,
APP_EXTENSION_TYPES,
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { ApiExtensionType, AppExtensionType, ExtensionManifestRaw } from '@directus/shared/types';
import {
ApiExtensionType,
AppExtensionType,
ExtensionManifestRaw,
ExtensionOptionsBundleEntry,
} from '@directus/shared/types';
import { isIn, isTypeIn, validateExtensionManifest } from '@directus/shared/utils';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';
import virtual from '@rollup/plugin-virtual';
import chalk from 'chalk';
import fse from 'fs-extra';
import ora from 'ora';
@@ -30,9 +36,11 @@ import typescript from 'rollup-plugin-typescript2';
import vue from 'rollup-plugin-vue';
import { Language, RollupConfig, RollupMode } from '../types';
import { getLanguageFromPath, isLanguage } from '../utils/languages';
import loadConfig from '../utils/load-config';
import { clear, log } from '../utils/logger';
import toObject from '../utils/to-object';
import tryParseJson from '../utils/try-parse-json';
import generateBundleEntrypoint from './helpers/generate-bundle-entrypoint';
import loadConfig from './helpers/load-config';
import { validateBundleEntriesOption, validateSplitEntrypointOption } from './helpers/validate-cli-options';
type BuildOptions = {
type?: string;
@@ -50,35 +58,36 @@ export default async function build(options: BuildOptions): Promise<void> {
if (!options.type && !options.input && !options.output) {
const packagePath = path.resolve('package.json');
let extensionManifest: ExtensionManifestRaw = {};
if (!(await fse.pathExists(packagePath))) {
log(`Current directory is not a valid package.`, 'error');
process.exit(1);
} else {
extensionManifest = await fse.readJSON(packagePath);
}
if (!validateExtensionManifest(extensionManifest)) {
log(`Current directory is not a valid Directus extension.`, 'error');
process.exit(1);
}
const extensionManifest: ExtensionManifestRaw = await fse.readJSON(packagePath);
if (!validateExtensionManifest(extensionManifest)) {
log(`Current directory is not a valid Directus extension.`, 'error');
process.exit(1);
}
const extensionOptions = extensionManifest[EXTENSION_PKG_KEY];
if (!isTypeIn(extensionOptions, EXTENSION_TYPES)) {
log(
`Extension type ${chalk.bold(
extensionOptions.type
)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) => chalk.bold.magenta(t)).join(
', '
)}.`,
'error'
);
if (extensionOptions.type === 'pack') {
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
if (extensionOptions.type === 'bundle') {
await buildBundleExtension({
entries: extensionOptions.entries,
outputApp: extensionOptions.path.app,
outputApi: extensionOptions.path.api,
watch,
sourcemap,
minify,
});
} else if (isTypeIn(extensionOptions, HYBRID_EXTENSION_TYPES)) {
await buildHybridExtension({
inputApp: extensionOptions.source.app,
inputApi: extensionOptions.source.api,
@@ -106,16 +115,23 @@ export default async function build(options: BuildOptions): Promise<void> {
if (!type) {
log(`Extension type has to be specified using the ${chalk.blue('[-t, --type <type>]')} option.`, 'error');
process.exit(1);
} else if (!isIn(type, EXTENSION_TYPES)) {
}
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
log(
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
chalk.bold.magenta(t)
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
(t) => chalk.bold.magenta(t)
).join(', ')}.`,
'error'
);
process.exit(1);
}
if (type === 'pack') {
log(`Building extension type ${chalk.bold('pack')} is not currently supported.`, 'error');
process.exit(1);
}
if (!input) {
log(`Extension entrypoint has to be specified using the ${chalk.blue('[-i, --input <file>]')} option.`, 'error');
process.exit(1);
@@ -128,30 +144,65 @@ export default async function build(options: BuildOptions): Promise<void> {
process.exit(1);
}
if (isIn(type, HYBRID_EXTENSION_TYPES)) {
const inputObject = toObject(input);
const outputObject = toObject(output);
if (type === 'bundle') {
const entries = tryParseJson(input);
const splitOutput = tryParseJson(output);
if (!inputObject || !inputObject.app || !inputObject.api) {
if (!validateBundleEntriesOption(entries)) {
log(
`Input option needs to be of the format ${chalk.blue('[-i app:<app-entrypoint>,api:<api-entrypoint>]')}.`,
`Input option needs to be of the format ${chalk.blue(
`[-i '[{"type":"<extension-type>","name":"<extension-name>","source":<entrypoint>}]']`
)}.`,
'error'
);
process.exit(1);
}
if (!outputObject || !outputObject.app || !outputObject.api) {
if (!validateSplitEntrypointOption(splitOutput)) {
log(
`Output option needs to be of the format ${chalk.blue('[-o app:<app-output-file>,api:<api-output-file>]')}.`,
`Output option needs to be of the format ${chalk.blue(
`[-o '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
)}.`,
'error'
);
process.exit(1);
}
await buildBundleExtension({
entries,
outputApp: splitOutput.app,
outputApi: splitOutput.api,
watch,
sourcemap,
minify,
});
} else if (isIn(type, HYBRID_EXTENSION_TYPES)) {
const splitInput = tryParseJson(input);
const splitOutput = tryParseJson(output);
if (!validateSplitEntrypointOption(splitInput)) {
log(
`Input option needs to be of the format ${chalk.blue(
`[-i '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
)}.`,
'error'
);
process.exit(1);
}
if (!validateSplitEntrypointOption(splitOutput)) {
log(
`Output option needs to be of the format ${chalk.blue(
`[-o '{"app":"<app-entrypoint>","api":"<api-entrypoint>"}']`
)}.`,
'error'
);
process.exit(1);
}
await buildHybridExtension({
inputApp: inputObject.app,
inputApi: inputObject.api,
outputApp: outputObject.app,
outputApi: outputObject.api,
inputApp: splitInput.app,
inputApi: splitInput.api,
outputApp: splitOutput.app,
outputApi: splitOutput.api,
watch,
sourcemap,
minify,
@@ -259,7 +310,7 @@ async function buildHybridExtension({
process.exit(1);
}
if (!isLanguage(languageApi)) {
log(`API language ${chalk.bold(languageApp)} is not supported.`, 'error');
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
process.exit(1);
}
@@ -297,6 +348,121 @@ async function buildHybridExtension({
}
}
async function buildBundleExtension({
entries,
outputApp,
outputApi,
watch,
sourcemap,
minify,
}: {
entries: ExtensionOptionsBundleEntry[];
outputApp: string;
outputApi: string;
watch: boolean;
sourcemap: boolean;
minify: boolean;
}) {
if (outputApp.length === 0) {
log(`App output file can not be empty.`, 'error');
process.exit(1);
}
if (outputApi.length === 0) {
log(`API output file can not be empty.`, 'error');
process.exit(1);
}
const languagesApp = new Set<Language>();
const languagesApi = new Set<Language>();
for (const entry of entries) {
if (isTypeIn(entry, HYBRID_EXTENSION_TYPES)) {
const inputApp = entry.source.app;
const inputApi = entry.source.api;
if (!(await fse.pathExists(inputApp)) || !(await fse.stat(inputApp)).isFile()) {
log(`App entrypoint ${chalk.bold(inputApp)} does not exist.`, 'error');
process.exit(1);
}
if (!(await fse.pathExists(inputApi)) || !(await fse.stat(inputApi)).isFile()) {
log(`API entrypoint ${chalk.bold(inputApi)} does not exist.`, 'error');
process.exit(1);
}
const languageApp = getLanguageFromPath(inputApp);
const languageApi = getLanguageFromPath(inputApi);
if (!isLanguage(languageApp)) {
log(`App language ${chalk.bold(languageApp)} is not supported.`, 'error');
process.exit(1);
}
if (!isLanguage(languageApi)) {
log(`API language ${chalk.bold(languageApi)} is not supported.`, 'error');
process.exit(1);
}
languagesApp.add(languageApp);
languagesApi.add(languageApi);
} else {
const input = entry.source;
if (!(await fse.pathExists(input)) || !(await fse.stat(input)).isFile()) {
log(`Entrypoint ${chalk.bold(input)} does not exist.`, 'error');
process.exit(1);
}
const language = getLanguageFromPath(input);
if (!isLanguage(language)) {
log(`Language ${chalk.bold(language)} is not supported.`, 'error');
process.exit(1);
}
if (isIn(entry.type, APP_EXTENSION_TYPES)) {
languagesApp.add(language);
} else {
languagesApi.add(language);
}
}
}
const config = await loadConfig();
const plugins = config.plugins ?? [];
const entrypointApp = generateBundleEntrypoint('app', entries);
const entrypointApi = generateBundleEntrypoint('api', entries);
const rollupOptionsApp = getRollupOptions({
mode: 'browser',
input: { entry: entrypointApp },
language: Array.from(languagesApp),
sourcemap,
minify,
plugins,
});
const rollupOptionsApi = getRollupOptions({
mode: 'node',
input: { entry: entrypointApi },
language: Array.from(languagesApi),
sourcemap,
minify,
plugins,
});
const rollupOutputOptionsApp = getRollupOutputOptions({ mode: 'browser', output: outputApp, sourcemap });
const rollupOutputOptionsApi = getRollupOutputOptions({ mode: 'node', output: outputApi, sourcemap });
const rollupOptionsAll = [
{ rollupOptions: rollupOptionsApp, rollupOutputOptions: rollupOutputOptionsApp },
{ rollupOptions: rollupOptionsApi, rollupOutputOptions: rollupOutputOptionsApi },
];
if (watch) {
await watchExtension(rollupOptionsAll);
} else {
await buildExtension(rollupOptionsAll);
}
}
async function buildExtension(config: RollupConfig | RollupConfig[]) {
const configs = Array.isArray(config) ? config : [config];
@@ -388,19 +554,22 @@ function getRollupOptions({
plugins,
}: {
mode: RollupMode;
input: string;
language: Language;
input: string | Record<string, string>;
language: Language | Language[];
sourcemap: boolean;
minify: boolean;
plugins: Plugin[];
}): RollupOptions {
const languages = Array.isArray(language) ? language : [language];
if (mode === 'browser') {
return {
input,
input: typeof input !== 'string' ? 'entry' : input,
external: APP_SHARED_DEPS,
plugins: [
typeof input !== 'string' ? virtual(input) : null,
vue({ preprocessStyles: true }) as Plugin,
language === 'typescript' ? typescript({ check: false }) : null,
languages.includes('typescript') ? typescript({ check: false }) : null,
styles(),
...plugins,
nodeResolve({ browser: true }),
@@ -417,10 +586,11 @@ function getRollupOptions({
};
} else {
return {
input,
input: typeof input !== 'string' ? 'entry' : input,
external: API_SHARED_DEPS,
plugins: [
language === 'typescript' ? typescript({ check: false }) : null,
typeof input !== 'string' ? virtual(input) : null,
languages.includes('typescript') ? typescript({ check: false }) : null,
...plugins,
nodeResolve(),
commonjs({ sourceMap: sourcemap }),
@@ -457,7 +627,7 @@ function getRollupOutputOptions({
return {
file: output,
format: 'cjs',
exports: 'default',
exports: 'auto',
inlineDynamicImports: true,
sourcemap,
};

View File

@@ -4,36 +4,31 @@ import fse from 'fs-extra';
import execa from 'execa';
import ora from 'ora';
import {
EXTENSION_TYPES,
EXTENSION_PKG_KEY,
EXTENSION_LANGUAGES,
HYBRID_EXTENSION_TYPES,
API_OR_HYBRID_EXTENSION_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
EXTENSION_NAME_REGEX,
EXTENSION_PACKAGE_TYPES,
PACKAGE_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { isIn } from '@directus/shared/utils';
import { ExtensionType } from '@directus/shared/types';
import { ExtensionOptions, ExtensionPackageType, ExtensionType, PackageExtensionType } from '@directus/shared/types';
import { log } from '../utils/logger';
import { isLanguage, languageToShort } from '../utils/languages';
import renameMap from '../utils/rename-map';
import { Language } from '../types';
import getPackageVersion from '../utils/get-package-version';
import getSdkVersion from '../utils/get-sdk-version';
import getExtensionDevDeps from './helpers/get-extension-dev-deps';
import copyTemplate from './helpers/copy-template';
const pkg = require('../../../../package.json');
const TEMPLATE_PATH = path.resolve(__dirname, '../../../../templates');
type CreateOptions = { language: string };
type CreateOptions = { language?: string };
export default async function create(type: string, name: string, options: CreateOptions): Promise<void> {
const targetDir = name.substring(name.lastIndexOf('/') + 1);
const targetPath = path.resolve(targetDir);
if (!isIn(type, EXTENSION_TYPES)) {
if (!isIn(type, EXTENSION_PACKAGE_TYPES)) {
log(
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_TYPES.map((t) =>
chalk.bold.magenta(t)
`Extension type ${chalk.bold(type)} is not supported. Available extension types: ${EXTENSION_PACKAGE_TYPES.map(
(t) => chalk.bold.magenta(t)
).join(', ')}.`,
'error'
);
@@ -61,9 +56,61 @@ export default async function create(type: string, name: string, options: Create
}
}
if (!isLanguage(options.language)) {
if (isIn(type, PACKAGE_EXTENSION_TYPES)) {
await createPackageExtension({ type, name, targetDir, targetPath });
} else {
const language = options.language ?? 'javascript';
await createLocalExtension({ type, name, targetDir, targetPath, language });
}
}
async function createPackageExtension({
type,
name,
targetDir,
targetPath,
}: {
type: PackageExtensionType;
name: string;
targetDir: string;
targetPath: string;
}) {
const spinner = ora(chalk.bold('Scaffolding Directus extension...')).start();
await fse.ensureDir(targetPath);
await copyTemplate(type, targetPath);
const host = `^${getSdkVersion()}`;
const options: ExtensionOptions =
type === 'bundle' ? { type, path: { app: 'dist/app.js', api: 'dist/api.js' }, entries: [], host } : { type, host };
const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type));
await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' });
await execa('npm', ['install'], { cwd: targetPath });
spinner.succeed(chalk.bold('Done'));
log(getDoneMessage(type, targetDir, targetPath));
}
async function createLocalExtension({
type,
name,
targetDir,
targetPath,
language,
}: {
type: ExtensionType;
name: string;
targetDir: string;
targetPath: string;
language: string;
}) {
if (!isLanguage(language)) {
log(
`Language ${chalk.bold(options.language)} is not supported. Available languages: ${EXTENSION_LANGUAGES.map((t) =>
`Language ${chalk.bold(language)} is not supported. Available languages: ${EXTENSION_LANGUAGES.map((t) =>
chalk.bold.magenta(t)
).join(', ')}.`,
'error'
@@ -74,32 +121,23 @@ export default async function create(type: string, name: string, options: Create
const spinner = ora(chalk.bold('Scaffolding Directus extension...')).start();
await fse.ensureDir(targetPath);
await copyTemplate(type, targetPath, 'src', language);
await fse.copy(path.join(TEMPLATE_PATH, 'common', options.language), targetPath);
await fse.copy(path.join(TEMPLATE_PATH, type, options.language), targetPath);
await renameMap(targetPath, (name) => (name.startsWith('_') ? `.${name.substring(1)}` : null));
const entryPath = isIn(type, HYBRID_EXTENSION_TYPES) ? { app: 'dist/app.js', api: 'dist/api.js' } : 'dist/index.js';
const sourcePath = isIn(type, HYBRID_EXTENSION_TYPES)
? { app: `src/app.${languageToShort(options.language)}`, api: `src/api.${languageToShort(options.language)}` }
: `src/index.${languageToShort(options.language)}`;
const packageManifest = {
name: EXTENSION_NAME_REGEX.test(name) ? name : `directus-extension-${name}`,
version: '1.0.0',
keywords: ['directus', 'directus-extension', `directus-custom-${type}`],
[EXTENSION_PKG_KEY]: {
type,
path: entryPath,
source: sourcePath,
host: `^${pkg.version}`,
},
scripts: {
build: 'directus-extension build',
dev: 'directus-extension build -w --no-minify',
},
devDependencies: await getPackageDeps(type, options.language),
};
const host = `^${getSdkVersion()}`;
const options: ExtensionOptions = isIn(type, HYBRID_EXTENSION_TYPES)
? {
type,
path: { app: 'dist/app.js', api: 'dist/api.js' },
source: { app: `src/app.${languageToShort(language)}`, api: `src/api.${languageToShort(language)}` },
host,
}
: {
type,
path: 'dist/index.js',
source: `src/index.${languageToShort(language)}`,
host,
};
const packageManifest = getPackageManifest(name, options, await getExtensionDevDeps(type, language));
await fse.writeJSON(path.join(targetPath, 'package.json'), packageManifest, { spaces: '\t' });
@@ -107,29 +145,32 @@ export default async function create(type: string, name: string, options: Create
spinner.succeed(chalk.bold('Done'));
log(`
log(getDoneMessage(type, targetDir, targetPath));
}
function getPackageManifest(name: string, options: ExtensionOptions, deps: Record<string, string>) {
return {
name: EXTENSION_NAME_REGEX.test(name) ? name : `directus-extension-${name}`,
version: '1.0.0',
keywords: ['directus', 'directus-extension', `directus-custom-${options.type}`],
[EXTENSION_PKG_KEY]: options,
scripts: {
build: 'directus-extension build',
dev: 'directus-extension build -w --no-minify',
},
devDependencies: deps,
};
}
function getDoneMessage(type: ExtensionPackageType, targetDir: string, targetPath: string) {
return `
Your ${type} extension has been created at ${chalk.green(targetPath)}
To start developing, run:
${chalk.blue('cd')} ${targetDir}
${chalk.blue('npm run')} dev
${chalk.blue('cd')} ${targetDir}
${chalk.blue('npm run')} dev
and then to build for production, run:
${chalk.blue('npm run')} build
`);
}
async function getPackageDeps(type: ExtensionType, language: Language) {
return {
'@directus/extensions-sdk': pkg.version,
...(language === 'typescript'
? {
...(isIn(type, API_OR_HYBRID_EXTENSION_TYPES)
? { '@types/node': `^${await getPackageVersion('@types/node')}` }
: {}),
typescript: `^${await getPackageVersion('typescript')}`,
}
: {}),
...(isIn(type, APP_OR_HYBRID_EXTENSION_TYPES) ? { vue: `^${await getPackageVersion('vue')}` } : {}),
};
${chalk.blue('npm run')} build
`;
}

View File

@@ -0,0 +1,72 @@
import path from 'path';
import fse from 'fs-extra';
import getTemplatePath from '../../utils/get-template-path';
import { ExtensionPackageType } from '@directus/shared/types';
import { Language } from '../../types';
type TemplateFile = { type: 'config' | 'source'; path: string };
async function copyTemplateFile(templateFile: TemplateFile, extensionPath: string, sourcePath?: string) {
if (sourcePath !== undefined) {
const oldName = path.basename(templateFile.path);
const newName = oldName.startsWith('_') ? `.${oldName.substring(1)}` : oldName;
const targetPath =
templateFile.type === 'config'
? path.join(extensionPath, newName)
: path.resolve(extensionPath, sourcePath, newName);
await fse.copy(templateFile.path, targetPath, { overwrite: false });
}
}
async function getFilesInDir(templatePath: string): Promise<string[]> {
if (!(await fse.pathExists(templatePath))) return [];
const files = await fse.readdir(templatePath);
return files.map((file) => path.join(templatePath, file));
}
async function getLanguageTemplateFiles(templateLanguagePath: string): Promise<TemplateFile[]> {
const [configFiles, sourceFiles] = await Promise.all([
getFilesInDir(path.join(templateLanguagePath, 'config')),
getFilesInDir(path.join(templateLanguagePath, 'source')),
]);
const configTemplateFiles: TemplateFile[] = configFiles.map((file) => ({ type: 'config', path: file }));
const sourceTemplateFiles: TemplateFile[] = sourceFiles.map((file) => ({ type: 'source', path: file }));
return [...configTemplateFiles, ...sourceTemplateFiles];
}
async function getTypeTemplateFiles(templateTypePath: string, language?: Language): Promise<TemplateFile[]> {
const [commonTemplateFiles, languageTemplateFiles] = await Promise.all([
getLanguageTemplateFiles(path.join(templateTypePath, 'common')),
language ? getLanguageTemplateFiles(path.join(templateTypePath, language)) : null,
]);
return [...commonTemplateFiles, ...(languageTemplateFiles ? languageTemplateFiles : [])];
}
async function getTemplateFiles(type: ExtensionPackageType, language?: Language): Promise<TemplateFile[]> {
const templatePath = getTemplatePath();
const [commonTemplateFiles, typeTemplateFiles] = await Promise.all([
getTypeTemplateFiles(path.join(templatePath, 'common'), language),
getTypeTemplateFiles(path.join(templatePath, type), language),
]);
return [...commonTemplateFiles, ...typeTemplateFiles];
}
export default async function copyTemplate(
type: ExtensionPackageType,
extensionPath: string,
sourcePath?: string,
language?: Language
): Promise<void> {
const templateFiles = await getTemplateFiles(type, language);
await Promise.all(templateFiles.map((templateFile) => copyTemplateFile(templateFile, extensionPath, sourcePath)));
}

View File

@@ -0,0 +1,40 @@
import path from 'path';
import {
API_OR_HYBRID_EXTENSION_TYPES,
APP_OR_HYBRID_EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
} from '@directus/shared/constants';
import { ExtensionOptionsBundleEntry } from '@directus/shared/types';
import { isIn, isTypeIn, pluralize } from '@directus/shared/utils';
import { pathToRelativeUrl } from '@directus/shared/utils/node';
export default function generateBundleEntrypoint(mode: 'app' | 'api', entries: ExtensionOptionsBundleEntry[]): string {
const types = mode === 'app' ? APP_OR_HYBRID_EXTENSION_TYPES : API_OR_HYBRID_EXTENSION_TYPES;
const entriesForTypes = entries.filter((entry) => isIn(entry.type, types));
const imports = entriesForTypes.map(
(entry, i) =>
`import e${i} from './${pathToRelativeUrl(
path.resolve(
isTypeIn(entry, HYBRID_EXTENSION_TYPES)
? mode === 'app'
? entry.source.app
: entry.source.api
: entry.source
)
)}';`
);
const exports = types.map(
(type) =>
`export const ${pluralize(type)} = [${entriesForTypes
.map((entry, i) =>
entry.type === type ? (mode === 'app' ? `e${i}` : `{name:'${entry.name}',config:e${i}}`) : null
)
.filter((e): e is string => e !== null)
.join(',')}];`
);
return `${imports.join('')}${exports.join('')}`;
}

View File

@@ -0,0 +1,32 @@
import { API_OR_HYBRID_EXTENSION_TYPES, APP_OR_HYBRID_EXTENSION_TYPES } from '@directus/shared/constants';
import { ExtensionPackageType } from '@directus/shared/types';
import { isIn } from '@directus/shared/utils';
import { Language } from '../../types';
import getPackageVersion from '../../utils/get-package-version';
import getSdkVersion from '../../utils/get-sdk-version';
export default async function getExtensionDevDeps(
type: ExtensionPackageType | ExtensionPackageType[],
language: Language | Language[] = []
): Promise<Record<string, string>> {
const types = Array.isArray(type) ? type : [type];
const languages = Array.isArray(language) ? language : [language];
const deps: Record<string, string> = {
'@directus/extensions-sdk': getSdkVersion(),
};
if (languages.includes('typescript')) {
if (types.some((type) => isIn(type, API_OR_HYBRID_EXTENSION_TYPES))) {
deps['@types/node'] = `^${await getPackageVersion('@types/node')}`;
}
deps['typescript'] = `^${await getPackageVersion('typescript')}`;
}
if (types.some((type) => isIn(type, APP_OR_HYBRID_EXTENSION_TYPES))) {
deps['vue'] = `^${await getPackageVersion('vue')}`;
}
return deps;
}

View File

@@ -1,6 +1,6 @@
import path from 'path';
import fse from 'fs-extra';
import { Config } from '../types';
import { Config } from '../../types';
import { pathToRelativeUrl } from '@directus/shared/utils/node';
const CONFIG_FILE_NAMES = ['extension.config.js', 'extension.config.mjs', 'extension.config.cjs'];

View File

@@ -0,0 +1,48 @@
import { ExtensionOptionsBundleEntry, JsonValue, SplitEntrypoint } from '@directus/shared/types';
import { validateExtensionOptionsBundleEntry } from '@directus/shared/utils';
function validateNonPrimitive(value: JsonValue | undefined): value is JsonValue[] | { [key: string]: JsonValue } {
if (
value === undefined ||
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return false;
}
return true;
}
export function validateSplitEntrypointOption(option: JsonValue | undefined): option is SplitEntrypoint {
if (!validateNonPrimitive(option) || Array.isArray(option)) {
return false;
}
if (!option.app || !option.api) {
return false;
}
return true;
}
export function validateBundleEntriesOption(option: JsonValue | undefined): option is ExtensionOptionsBundleEntry[] {
if (!validateNonPrimitive(option) || !Array.isArray(option)) {
return false;
}
if (
!option.every((entry) => {
if (!validateNonPrimitive(entry) || Array.isArray(entry)) {
return false;
}
return validateExtensionOptionsBundleEntry(entry);
})
) {
return false;
}
return true;
}

View File

@@ -1,5 +1,6 @@
import { Command } from 'commander';
import create from './commands/create';
import add from './commands/add';
import build from './commands/build';
const pkg = require('../../../package.json');
@@ -13,9 +14,11 @@ program
.command('create')
.arguments('<type> <name>')
.description('Scaffold a new Directus extension')
.option('-l, --language <language>', 'specify the language to use', 'javascript')
.option('-l, --language <language>', 'specify the language to use')
.action(create);
program.command('add').description('Add entries to an existing Directus extension').action(add);
program
.command('build')
.description('Bundle a Directus extension to a single entrypoint')

View File

@@ -0,0 +1,13 @@
export default function detectJsonIndent(json: string) {
const lines = json.split(/\r?\n/);
const braceLine = lines.findIndex((line) => /^(?:\{|\[)/.test(line));
if (braceLine === -1 || braceLine + 1 > lines.length - 1) return null;
const indent = lines[braceLine + 1]!.match(/[ \t]+/)?.[0];
if (indent === undefined) return null;
return indent;
}

View File

@@ -0,0 +1,5 @@
export default function getSdkVersion(): string {
const pkg = require('../../../../package.json');
return pkg.version;
}

View File

@@ -0,0 +1,5 @@
import path from 'path';
export default function getTemplatePath(): string {
return path.resolve(__dirname, '..', '..', '..', '..', 'templates');
}

View File

@@ -1,20 +0,0 @@
import path from 'path';
import fse from 'fs-extra';
export default async function renameMap(file: string, map: (name: string) => string | null): Promise<void> {
const info = await fse.stat(file);
if (info.isFile()) {
const newName = map(path.basename(file));
if (newName !== null) {
fse.rename(file, path.join(path.dirname(file), newName));
}
} else {
const subFiles = await fse.readdir(file);
for (const subFile of subFiles) {
await renameMap(path.join(file, subFile), map);
}
}
}

View File

@@ -1,16 +0,0 @@
export default function toObject(val: string): Record<string, string> | null {
const arr = val.split(',');
const obj: Record<string, string> = {};
for (const v of arr) {
const sub = v.match(/^([^:]+):(.+)$/);
if (sub) {
obj[sub[1]!] = sub[2]!;
} else {
return null;
}
}
return obj;
}

View File

@@ -0,0 +1,9 @@
import { JsonValue } from '@directus/shared/types';
export default function tryParseJson(str: string): JsonValue | undefined {
try {
return JSON.parse(str);
} catch {
return undefined;
}
}

View File

@@ -1,3 +0,0 @@
.DS_Store
node_modules
dist

Some files were not shown because too many files have changed in this diff Show More