Merged 2.0.0-m3 into feature/global_config_v2_endpoint

This commit is contained in:
Johannes Schnatterer
2018-07-31 16:21:40 +02:00
113 changed files with 22087 additions and 60 deletions

View File

@@ -29,3 +29,8 @@ Desktop DF$
# jrebel
rebel.xml
\.pyc
# ui
scm-ui/build
scm-ui/coverage
/?node_modules/

4
Jenkinsfile vendored
View File

@@ -1,7 +1,7 @@
#!groovy
// Keep the version in sync with the one used in pom.xml in order to get correct syntax completion.
@Library('github.com/cloudogu/ces-build-lib@9aadeeb')
@Library('github.com/cloudogu/ces-build-lib@59d3e94')
import com.cloudogu.ces.cesbuildlib.*
node() { // No specific label
@@ -31,7 +31,7 @@ node() { // No specific label
}
stage('Unit Test') {
mvn 'test -Dsonia.scm.test.skip.hg=true'
mvn 'test -Dsonia.scm.test.skip.hg=true -Dmaven.test.failure.ignore=true'
}
stage('SonarQube') {

View File

@@ -70,6 +70,7 @@
<module>scm-test</module>
<module>scm-plugins</module>
<module>scm-dao-xml</module>
<module>scm-ui</module>
<module>scm-webapp</module>
<module>scm-server</module>
</modules>

View File

@@ -18,6 +18,7 @@ public class VndMediaType {
public static final String USER_COLLECTION = PREFIX + "userCollection" + SUFFIX;
public static final String GROUP_COLLECTION = PREFIX + "groupCollection" + SUFFIX;
public static final String REPOSITORY_COLLECTION = PREFIX + "repositoryCollection" + SUFFIX;
public static final String ME = PREFIX + "me" + SUFFIX;
public static final String CONFIG = PREFIX + "config" + SUFFIX;

3
scm-ui/.eslintrc Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "react-app"
}

7
scm-ui/.flowconfig Normal file
View File

@@ -0,0 +1,7 @@
[ignore]
[include]
[libs]
[options]

15
scm-ui/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/src"
}
]
}

10
scm-ui/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"javascript.validate.enable": false,
// Set the default
"editor.formatOnSave": false,
// Enable per-language
"[javascript]": {
"editor.formatOnSave": true
},
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow"
}

97
scm-ui/.vscode/snippets/javascript.json vendored Normal file
View File

@@ -0,0 +1,97 @@
{
"React default component": {
"prefix": "rdc",
"body": [
"//@flow",
"import React from \"react\";",
"",
"type Props = {};",
"",
"class $1 extends React.Component<Props> {",
" render() {",
" return <div />;",
" }",
"}",
"",
"export default $1;"
],
"description": "React default component with props"
},
"React styled component": {
"prefix": "rsc",
"body": [
"//@flow",
"import React from \"react\";",
"import injectSheet from \"react-jss\";",
"",
"const styles = {};",
"",
"type Props = {",
" classes: any",
"};",
"",
"class $1 extends React.Component<Props> {",
" render() {",
" const { classes } = this.props;",
" return <div />;",
" }",
"}",
"",
"export default injectSheet(styles)($1);"
],
"description": "React default component with props"
},
"React container component": {
"prefix": "rcc",
"body": [
"//@flow",
"import React from \"react\";",
"import { connect } from \"react-redux\";",
"",
"type Props = {};",
"",
"class $1 extends React.Component<Props> {",
" render() {",
" return <div />;",
" }",
"}",
"",
"const mapStateToProps = state => {",
" return {};",
"};",
"",
"const mapDispatchToProps = (dispatch) => {",
" return {};",
"};",
"",
"export default connect(",
" mapStateToProps,",
" mapDispatchToProps",
")($1);"
],
"description": "React component which is connected to the store"
},
"React component test": {
"prefix": "rct",
"body": [
"//@flow",
"import React from \"react\";",
"import { configure, shallow } from \"enzyme\";",
"import Adapter from \"enzyme-adapter-react-16\";",
"",
"import \"raf/polyfill\";",
"",
"configure({ adapter: new Adapter() });",
"",
"it(\"should render the component\", () => {",
" $0",
" //const label = shallow(<Label value=\"awesome\" />);",
" //expect(label.text()).toBe(\"awesome\");",
"});"
],
"description": "React component test with enzyme and jest"
}
}

27
scm-ui/README.md Normal file
View File

@@ -0,0 +1,27 @@
# scm-ui
## VSCode Plugins
* EditorConfig for VS Code
* Flow Language Support
* Prettier - Code formatter
* Project Snippets
* Debugger for Chrome
```bash
code --install-extension EditorConfig.EditorConfig
code --install-extension flowtype.flow-for-vscode
code --install-extension esbenp.prettier-vscode
code --install-extension rebornix.project-snippets
# debugging with chrome browser
code --install-extension msjsdiag.debugger-for-chrome
```
## Install pre-commit hook
```bash
echo "" >> .hg/hgrc
echo "[hooks]" >> .hg/hgrc
echo "pre-commit = cd scm-ui && yarn run pre-commit" >> .hg/hgrc
```

128
scm-ui/flow-typed/npm/history_v4.x.x.js vendored Normal file
View File

@@ -0,0 +1,128 @@
// flow-typed signature: eb8bd974b677b08dfca89de9ac05b60b
// flow-typed version: 43b30482ac/history_v4.x.x/flow_>=v0.25.x
declare module "history/createBrowserHistory" {
declare function Unblock(): void;
declare export type Action = "PUSH" | "REPLACE" | "POP";
declare export type BrowserLocation = {
pathname: string,
search: string,
hash: string,
// Browser and Memory specific
state: string,
key: string,
};
declare export type BrowserHistory = {
length: number,
location: BrowserLocation,
action: Action,
push: (path: string, Array<mixed>) => void,
replace: (path: string, Array<mixed>) => void,
go: (n: number) => void,
goBack: () => void,
goForward: () => void,
listen: Function,
block: (message: string) => Unblock,
block: ((location: BrowserLocation, action: Action) => string) => Unblock,
push: (path: string) => void,
replace: (path: string) => void,
};
declare type HistoryOpts = {
basename?: string,
forceRefresh?: boolean,
getUserConfirmation?: (
message: string,
callback: (willContinue: boolean) => void,
) => void,
};
declare export default (opts?: HistoryOpts) => BrowserHistory;
}
declare module "history/createMemoryHistory" {
declare function Unblock(): void;
declare export type Action = "PUSH" | "REPLACE" | "POP";
declare export type MemoryLocation = {
pathname: string,
search: string,
hash: string,
// Browser and Memory specific
state: string,
key: string,
};
declare export type MemoryHistory = {
length: number,
location: MemoryLocation,
action: Action,
index: number,
entries: Array<string>,
push: (path: string, Array<mixed>) => void,
replace: (path: string, Array<mixed>) => void,
go: (n: number) => void,
goBack: () => void,
goForward: () => void,
// Memory only
canGo: (n: number) => boolean,
listen: Function,
block: (message: string) => Unblock,
block: ((location: MemoryLocation, action: Action) => string) => Unblock,
push: (path: string) => void,
};
declare type HistoryOpts = {
initialEntries?: Array<string>,
initialIndex?: number,
keyLength?: number,
getUserConfirmation?: (
message: string,
callback: (willContinue: boolean) => void,
) => void,
};
declare export default (opts?: HistoryOpts) => MemoryHistory;
}
declare module "history/createHashHistory" {
declare function Unblock(): void;
declare export type Action = "PUSH" | "REPLACE" | "POP";
declare export type HashLocation = {
pathname: string,
search: string,
hash: string,
};
declare export type HashHistory = {
length: number,
location: HashLocation,
action: Action,
push: (path: string, Array<mixed>) => void,
replace: (path: string, Array<mixed>) => void,
go: (n: number) => void,
goBack: () => void,
goForward: () => void,
listen: Function,
block: (message: string) => Unblock,
block: ((location: HashLocation, action: Action) => string) => Unblock,
push: (path: string) => void,
};
declare type HistoryOpts = {
basename?: string,
hashType: "slash" | "noslash" | "hashbang",
getUserConfirmation?: (
message: string,
callback: (willContinue: boolean) => void,
) => void,
};
declare export default (opts?: HistoryOpts) => HashHistory;
}

597
scm-ui/flow-typed/npm/jest_v20.x.x.js vendored Normal file
View File

@@ -0,0 +1,597 @@
// flow-typed signature: 002f0912eb0f40f562c348561ea3d850
// flow-typed version: a5bbe16c29/jest_v20.x.x/flow_>=v0.39.x
type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
(...args: TArguments): TReturn,
/**
* An object for introspecting mock calls
*/
mock: {
/**
* An array that represents all calls that have been made into this mock
* function. Each call is represented by an array of arguments that were
* passed during the call.
*/
calls: Array<TArguments>,
/**
* An array that contains all the object instances that have been
* instantiated from this mock function.
*/
instances: Array<TReturn>
},
/**
* Resets all information stored in the mockFn.mock.calls and
* mockFn.mock.instances arrays. Often this is useful when you want to clean
* up a mock's usage data between two assertions.
*/
mockClear(): void,
/**
* Resets all information stored in the mock. This is useful when you want to
* completely restore a mock back to its initial state.
*/
mockReset(): void,
/**
* Removes the mock and restores the initial implementation. This is useful
* when you want to mock functions in certain test cases and restore the
* original implementation in others. Beware that mockFn.mockRestore only
* works when mock was created with jest.spyOn. Thus you have to take care of
* restoration yourself when manually assigning jest.fn().
*/
mockRestore(): void,
/**
* Accepts a function that should be used as the implementation of the mock.
* The mock itself will still record all calls that go into and instances
* that come from itself -- the only difference is that the implementation
* will also be executed when the mock is called.
*/
mockImplementation(
fn: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Accepts a function that will be used as an implementation of the mock for
* one call to the mocked function. Can be chained so that multiple function
* calls produce different results.
*/
mockImplementationOnce(
fn: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Just a simple sugar function for returning `this`
*/
mockReturnThis(): void,
/**
* Deprecated: use jest.fn(() => value) instead
*/
mockReturnValue(value: TReturn): JestMockFn<TArguments, TReturn>,
/**
* Sugar for only returning a value once inside your mock
*/
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>
};
type JestAsymmetricEqualityType = {
/**
* A custom Jasmine equality tester
*/
asymmetricMatch(value: mixed): boolean
};
type JestCallsType = {
allArgs(): mixed,
all(): mixed,
any(): boolean,
count(): number,
first(): mixed,
mostRecent(): mixed,
reset(): void
};
type JestClockType = {
install(): void,
mockDate(date: Date): void,
tick(milliseconds?: number): void,
uninstall(): void
};
type JestMatcherResult = {
message?: string | (() => string),
pass: boolean
};
type JestMatcher = (actual: any, expected: any) => JestMatcherResult;
type JestPromiseType = {
/**
* Use rejects to unwrap the reason of a rejected promise so any other
* matcher can be chained. If the promise is fulfilled the assertion fails.
*/
rejects: JestExpectType,
/**
* Use resolves to unwrap the value of a fulfilled promise so any other
* matcher can be chained. If the promise is rejected the assertion fails.
*/
resolves: JestExpectType
};
/**
* Plugin: jest-enzyme
*/
type EnzymeMatchersType = {
toBeChecked(): void,
toBeDisabled(): void,
toBeEmpty(): void,
toBeEmptyRender(): void,
toBePresent(): void,
toContainReact(element: React$Element<any>): void,
toExist(): void,
toHaveClassName(className: string): void,
toHaveHTML(html: string): void,
toHaveProp: ((propKey: string, propValue?: any) => void) & ((props: Object) => void),
toHaveRef(refName: string): void,
toHaveState: ((stateKey: string, stateValue?: any) => void) & ((state: Object) => void),
toHaveStyle: ((styleKey: string, styleValue?: any) => void) & ((style: Object) => void),
toHaveTagName(tagName: string): void,
toHaveText(text: string): void,
toIncludeText(text: string): void,
toHaveValue(value: any): void,
toMatchElement(element: React$Element<any>): void,
toMatchSelector(selector: string): void
};
// DOM testing library extensions https://github.com/kentcdodds/dom-testing-library#custom-jest-matchers
type DomTestingLibraryType = {
toBeInTheDOM(): void,
toHaveTextContent(content: string): void,
toHaveAttribute(name: string, expectedValue?: string): void
};
type JestExpectType = {
not: JestExpectType & EnzymeMatchersType & DomTestingLibraryType,
/**
* If you have a mock function, you can use .lastCalledWith to test what
* arguments it was last called with.
*/
lastCalledWith(...args: Array<any>): void,
/**
* toBe just checks that a value is what you expect. It uses === to check
* strict equality.
*/
toBe(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toBeCalled(): void,
/**
* Use .toBeCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toBeCalledWith(...args: Array<any>): void,
/**
* Using exact equality with floating point numbers is a bad idea. Rounding
* means that intuitive things fail.
*/
toBeCloseTo(num: number, delta: any): void,
/**
* Use .toBeDefined to check that a variable is not undefined.
*/
toBeDefined(): void,
/**
* Use .toBeFalsy when you don't care what a value is, you just want to
* ensure a value is false in a boolean context.
*/
toBeFalsy(): void,
/**
* To compare floating point numbers, you can use toBeGreaterThan.
*/
toBeGreaterThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeGreaterThanOrEqual.
*/
toBeGreaterThanOrEqual(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThan.
*/
toBeLessThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThanOrEqual.
*/
toBeLessThanOrEqual(number: number): void,
/**
* Use .toBeInstanceOf(Class) to check that an object is an instance of a
* class.
*/
toBeInstanceOf(cls: Class<*>): void,
/**
* .toBeNull() is the same as .toBe(null) but the error messages are a bit
* nicer.
*/
toBeNull(): void,
/**
* Use .toBeTruthy when you don't care what a value is, you just want to
* ensure a value is true in a boolean context.
*/
toBeTruthy(): void,
/**
* Use .toBeUndefined to check that a variable is undefined.
*/
toBeUndefined(): void,
/**
* Use .toContain when you want to check that an item is in a list. For
* testing the items in the list, this uses ===, a strict equality check.
*/
toContain(item: any): void,
/**
* Use .toContainEqual when you want to check that an item is in a list. For
* testing the items in the list, this matcher recursively checks the
* equality of all fields, rather than checking for object identity.
*/
toContainEqual(item: any): void,
/**
* Use .toEqual when you want to check that two objects have the same value.
* This matcher recursively checks the equality of all fields, rather than
* checking for object identity.
*/
toEqual(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toHaveBeenCalled(): void,
/**
* Use .toHaveBeenCalledTimes to ensure that a mock function got called exact
* number of times.
*/
toHaveBeenCalledTimes(number: number): void,
/**
* Use .toHaveBeenCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toHaveBeenCalledWith(...args: Array<any>): void,
/**
* Use .toHaveBeenLastCalledWith to ensure that a mock function was last called
* with specific arguments.
*/
toHaveBeenLastCalledWith(...args: Array<any>): void,
/**
* Check that an object has a .length property and it is set to a certain
* numeric value.
*/
toHaveLength(number: number): void,
/**
*
*/
toHaveProperty(propPath: string, value?: any): void,
/**
* Use .toMatch to check that a string matches a regular expression or string.
*/
toMatch(regexpOrString: RegExp | string): void,
/**
* Use .toMatchObject to check that a javascript object matches a subset of the properties of an object.
*/
toMatchObject(object: Object): void,
/**
* This ensures that a React component matches the most recent snapshot.
*/
toMatchSnapshot(name?: string): void,
/**
* Use .toThrow to test that a function throws when it is called.
* If you want to test that a specific error gets thrown, you can provide an
* argument to toThrow. The argument can be a string for the error message,
* a class for the error, or a regex that should match the error.
*
* Alias: .toThrowError
*/
toThrow(message?: string | Error | RegExp): void,
toThrowError(message?: string | Error | RegExp): void,
/**
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
* matching the most recent snapshot when it is called.
*/
toThrowErrorMatchingSnapshot(): void
};
type JestObjectType = {
/**
* Disables automatic mocking in the module loader.
*
* After this method is called, all `require()`s will return the real
* versions of each module (rather than a mocked version).
*/
disableAutomock(): JestObjectType,
/**
* An un-hoisted version of disableAutomock
*/
autoMockOff(): JestObjectType,
/**
* Enables automatic mocking in the module loader.
*/
enableAutomock(): JestObjectType,
/**
* An un-hoisted version of enableAutomock
*/
autoMockOn(): JestObjectType,
/**
* Clears the mock.calls and mock.instances properties of all mocks.
* Equivalent to calling .mockClear() on every mocked function.
*/
clearAllMocks(): JestObjectType,
/**
* Resets the state of all mocks. Equivalent to calling .mockReset() on every
* mocked function.
*/
resetAllMocks(): JestObjectType,
/**
* Removes any pending timers from the timer system.
*/
clearAllTimers(): void,
/**
* The same as `mock` but not moved to the top of the expectation by
* babel-jest.
*/
doMock(moduleName: string, moduleFactory?: any): JestObjectType,
/**
* The same as `unmock` but not moved to the top of the expectation by
* babel-jest.
*/
dontMock(moduleName: string): JestObjectType,
/**
* Returns a new, unused mock function. Optionally takes a mock
* implementation.
*/
fn<TArguments: $ReadOnlyArray<*>, TReturn>(
implementation?: (...args: TArguments) => TReturn,
): JestMockFn<TArguments, TReturn>,
/**
* Determines if the given function is a mocked function.
*/
isMockFunction(fn: Function): boolean,
/**
* Given the name of a module, use the automatic mocking system to generate a
* mocked version of the module for you.
*/
genMockFromModule(moduleName: string): any,
/**
* Mocks a module with an auto-mocked version when it is being required.
*
* The second argument can be used to specify an explicit module factory that
* is being run instead of using Jest's automocking feature.
*
* The third argument can be used to create virtual mocks -- mocks of modules
* that don't exist anywhere in the system.
*/
mock(
moduleName: string,
moduleFactory?: any,
options?: Object
): JestObjectType,
/**
* Resets the module registry - the cache of all required modules. This is
* useful to isolate modules where local state might conflict between tests.
*/
resetModules(): JestObjectType,
/**
* Exhausts the micro-task queue (usually interfaced in node via
* process.nextTick).
*/
runAllTicks(): void,
/**
* Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(),
* setInterval(), and setImmediate()).
*/
runAllTimers(): void,
/**
* Exhausts all tasks queued by setImmediate().
*/
runAllImmediates(): void,
/**
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
* or setInterval() and setImmediate()).
*/
runTimersToTime(msToRun: number): void,
/**
* Executes only the macro-tasks that are currently pending (i.e., only the
* tasks that have been queued by setTimeout() or setInterval() up to this
* point)
*/
runOnlyPendingTimers(): void,
/**
* Explicitly supplies the mock object that the module system should return
* for the specified module. Note: It is recommended to use jest.mock()
* instead.
*/
setMock(moduleName: string, moduleExports: any): JestObjectType,
/**
* Indicates that the module system should never return a mocked version of
* the specified module from require() (e.g. that it should always return the
* real module).
*/
unmock(moduleName: string): JestObjectType,
/**
* Instructs Jest to use fake versions of the standard timer functions
* (setTimeout, setInterval, clearTimeout, clearInterval, nextTick,
* setImmediate and clearImmediate).
*/
useFakeTimers(): JestObjectType,
/**
* Instructs Jest to use the real versions of the standard timer functions.
*/
useRealTimers(): JestObjectType,
/**
* Creates a mock function similar to jest.fn but also tracks calls to
* object[methodName].
*/
spyOn(object: Object, methodName: string): JestMockFn<any, any>
};
type JestSpyType = {
calls: JestCallsType
};
/** Runs this function after every test inside this context */
declare function afterEach(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
/** Runs this function before every test inside this context */
declare function beforeEach(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
/** Runs this function after all tests have finished inside this context */
declare function afterAll(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
/** Runs this function before any tests have started inside this context */
declare function beforeAll(fn: (done: () => void) => ?Promise<mixed>, timeout?: number): void;
/** A context for grouping tests together */
declare var describe: {
/**
* Creates a block that groups together several related tests in one "test suite"
*/
(name: string, fn: () => void): void,
/**
* Only run this describe block
*/
only(name: string, fn: () => void): void,
/**
* Skip running this describe block
*/
skip(name: string, fn: () => void): void,
};
/** An individual test unit */
declare var it: {
/**
* An individual test unit
*
* @param {string} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
/**
* Only run this test
*
* @param {string} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
only(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
/**
* Skip running this test
*
* @param {string} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
skip(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
/**
* Run the test concurrently
*
* @param {string} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
concurrent(name: string, fn?: (done: () => void) => ?Promise<mixed>, timeout?: number): void,
};
declare function fit(
name: string,
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number,
): void;
/** An individual test unit */
declare var test: typeof it;
/** A disabled group of tests */
declare var xdescribe: typeof describe;
/** A focused group of tests */
declare var fdescribe: typeof describe;
/** A disabled individual test */
declare var xit: typeof it;
/** A disabled individual test */
declare var xtest: typeof it;
type JestPrettyFormatColors = {
comment: { close: string, open: string },
content: { close: string, open: string },
prop: { close: string, open: string },
tag: { close: string, open: string },
value: { close: string, open: string },
};
type JestPrettyFormatIndent = string => string;
type JestPrettyFormatRefs = Array<any>;
type JestPrettyFormatPrint = any => string;
type JestPrettyFormatStringOrNull = string | null;
type JestPrettyFormatOptions = {|
callToJSON: boolean,
edgeSpacing: string,
escapeRegex: boolean,
highlight: boolean,
indent: number,
maxDepth: number,
min: boolean,
plugins: JestPrettyFormatPlugins,
printFunctionName: boolean,
spacing: string,
theme: {|
comment: string,
content: string,
prop: string,
tag: string,
value: string,
|},
|};
type JestPrettyFormatPlugin = {
print: (
val: any,
serialize: JestPrettyFormatPrint,
indent: JestPrettyFormatIndent,
opts: JestPrettyFormatOptions,
colors: JestPrettyFormatColors,
) => string,
test: any => boolean,
};
type JestPrettyFormatPlugins = Array<JestPrettyFormatPlugin>;
/** The expect function is used every time you want to test a value */
declare var expect: {
/** The object that you want to make assertions against */
(value: any): JestExpectType & JestPromiseType & EnzymeMatchersType & DomTestingLibraryType,
/** Add additional Jasmine matchers to Jest's roster */
extend(matchers: { [name: string]: JestMatcher }): void,
/** Add a module that formats application-specific data structures. */
addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void,
assertions(expectedAssertions: number): void,
hasAssertions(): void,
any(value: mixed): JestAsymmetricEqualityType,
anything(): void,
arrayContaining(value: Array<mixed>): void,
objectContaining(value: Object): void,
/** Matches any received string that contains the exact expected string. */
stringContaining(value: string): void,
stringMatching(value: string | RegExp): void
};
// TODO handle return type
// http://jasmine.github.io/2.4/introduction.html#section-Spies
declare function spyOn(value: mixed, method: string): Object;
/** Holds all functions related to manipulating test runner */
declare var jest: JestObjectType;
/**
* The global Jasmine object, this is generally not exposed as the public API,
* using features inside here could break in later versions of Jest.
*/
declare var jasmine: {
DEFAULT_TIMEOUT_INTERVAL: number,
any(value: mixed): JestAsymmetricEqualityType,
anything(): void,
arrayContaining(value: Array<mixed>): void,
clock(): JestClockType,
createSpy(name: string): JestSpyType,
createSpyObj(
baseName: string,
methodNames: Array<string>
): { [methodName: string]: JestSpyType },
objectContaining(value: Object): void,
stringMatching(value: string): void
};

76
scm-ui/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"homepage": "/scm",
"name": "scm-ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"bulma": "^0.7.1",
"classnames": "^2.2.5",
"history": "^4.7.2",
"i18next": "^11.4.0",
"i18next-browser-languagedetector": "^2.2.2",
"i18next-fetch-backend": "^0.1.0",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"react-i18next": "^7.9.0",
"react-jss": "^8.6.0",
"react-redux": "^5.0.7",
"react-router-dom": "^4.3.1",
"react-router-redux": "^5.0.0-alpha.9",
"react-scripts": "1.1.4",
"redux": "^4.0.0",
"redux-devtools-extension": "^2.13.5",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0"
},
"scripts": {
"build-css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/",
"watch-css": "npm run build-css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive",
"start-js": "react-scripts start",
"start": "npm-run-all -p watch-css start-js",
"build-js": "react-scripts build",
"build": "npm-run-all build-css build-js",
"test": "jest",
"test-coverage": "jest --coverage",
"test-ci": "jest --ci --coverage",
"eject": "react-scripts eject",
"flow": "flow",
"pre-commit": "jest && flow && eslint src"
},
"proxy": {
"/scm/api": {
"target": "http://localhost:8081"
}
},
"devDependencies": {
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"fetch-mock": "^6.5.0",
"flow-bin": "^0.77.0",
"flow-typed": "^2.5.1",
"jest-junit": "^5.1.0",
"node-sass-chokidar": "^1.3.0",
"npm-run-all": "^4.1.3",
"prettier": "^1.13.7",
"react-test-renderer": "^16.4.1",
"redux-mock-store": "^1.5.3"
},
"babel": {
"presets": [
"react-app"
]
},
"jest": {
"coverageDirectory": "target/jest-reports/coverage",
"coveragePathIgnorePatterns": [
"src/tests/.*"
],
"reporters": [
"default",
"jest-junit"
]
},
"jest-junit": {
"output": "./target/jest-reports/TEST-all.xml"
}
}

78
scm-ui/pom.xml Normal file
View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>sonia.scm</groupId>
<artifactId>scm</artifactId>
<version>2.0.0-SNAPSHOT</version>
</parent>
<groupId>sonia.scm.clients</groupId>
<artifactId>scm-ui</artifactId>
<packaging>pom</packaging>
<version>2.0.0-SNAPSHOT</version>
<name>scm-ui</name>
<properties>
<sonar.language>js</sonar.language>
<sonar.sources>src</sonar.sources>
<sonar.test.exclusions>**/*.test.js,src/tests/**</sonar.test.exclusions>
<sonar.coverage.exclusions>**/*.test.js,src/tests/**</sonar.coverage.exclusions>
<sonar.javascript.jstest.reportsPath>target/jest-reports</sonar.javascript.jstest.reportsPath>
<sonar.javascript.lcov.reportPaths>target/jest-reports/coverage/lcov.info</sonar.javascript.lcov.reportPaths>
</properties>
<build>
<plugins>
<plugin>
<groupId>com.github.sdorra</groupId>
<artifactId>buildfrontend-maven-plugin</artifactId>
<version>2.0.1</version>
<configuration>
<node>
<version>8.11.3</version>
</node>
<pkgManager>
<type>YARN</type>
<version>1.7.0</version>
</pkgManager>
<script>run</script>
</configuration>
<executions>
<execution>
<id>install</id>
<phase>process-resources</phase>
<goals>
<goal>install</goal>
</goals>
</execution>
<execution>
<id>build</id>
<phase>compile</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<script>build</script>
</configuration>
</execution>
<execution>
<id>test</id>
<phase>test</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<script>test-ci</script>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

BIN
scm-ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

40
scm-ui/public/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>SCM-Manager</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -0,0 +1,40 @@
{
"login": {
"title": "Login",
"subtitle": "Please login to proceed.",
"logo-alt": "SCM-Manager",
"username-placeholder": "Your Username",
"password-placeholder": "Your Password",
"submit": "Login"
},
"logout": {
"error": {
"title": "Logout failed",
"subtitle": "Something went wrong during logout"
}
},
"app": {
"error": {
"title": "Error",
"subtitle": "Unknown error occurred"
}
},
"error-notification": {
"prefix": "Error"
},
"loading": {
"alt": "Loading ..."
},
"logo": {
"alt": "SCM-Manager"
},
"primary-navigation": {
"repositories": "Repositories",
"users": "Users",
"logout": "Logout"
},
"paginator": {
"next": "Next",
"previous": "Previous"
}
}

View File

@@ -0,0 +1,7 @@
{
"repositories": {
"title": "Repositories",
"subtitle": "Repositories will be shown here",
"body": "Coming soon ..."
}
}

View File

@@ -0,0 +1,52 @@
{
"user": {
"name": "Username",
"displayName": "Display Name",
"mail": "E-Mail",
"password": "Password",
"admin": "Admin",
"active": "Active"
},
"users": {
"title": "Users",
"subtitle": "Create, read, update and delete users"
},
"create-user-button": {
"label": "Create"
},
"delete-user-button": {
"label": "Delete",
"confirm-alert": {
"title": "Delete user",
"message": "Do you really want to delete the user?",
"submit": "Yes",
"cancel": "No"
}
},
"edit-user-button": {
"label": "Edit"
},
"user-form": {
"submit": "Submit"
},
"add-user": {
"title": "Create User",
"subtitle": "Create a new user"
},
"single-user": {
"error-title": "Error",
"error-subtitle": "Unknown user error",
"navigation-label": "Navigation",
"actions-label": "Actions",
"information-label": "Information",
"back-label": "Back"
},
"validation": {
"mail-invalid": "This email is invalid",
"name-invalid": "This name is invalid",
"displayname-invalid": "This displayname is invalid",
"password-invalid": "Password has to be between 6 and 32 characters",
"passwordValidation-invalid": "Passwords have to be the same",
"validatePassword": "Please validate password here"
}
}

View File

@@ -0,0 +1,15 @@
{
"short_name": "SCM-Manager",
"name": "SCM-Manager",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

116
scm-ui/src/apiclient.js Normal file
View File

@@ -0,0 +1,116 @@
// @flow
// get api base url from environment
const apiUrl = process.env.API_URL || process.env.PUBLIC_URL || "/scm";
export const NOT_FOUND_ERROR = Error("not found");
export const UNAUTHORIZED_ERROR = Error("unauthorized");
const fetchOptions: RequestOptions = {
credentials: "same-origin",
headers: {
Cache: "no-cache"
}
};
function handleStatusCode(response: Response) {
if (!response.ok) {
if (response.status === 401) {
throw UNAUTHORIZED_ERROR;
}
if (response.status === 404) {
throw NOT_FOUND_ERROR;
}
throw new Error("server returned status code " + response.status);
}
return response;
}
export function createUrl(url: string) {
if (url.indexOf("://") > 0) {
return url;
}
let urlWithStartingSlash = url;
if (url.indexOf("/") !== 0) {
urlWithStartingSlash = "/" + urlWithStartingSlash;
}
return `${apiUrl}/api/rest/v2${urlWithStartingSlash}`;
}
class ApiClient {
get(url: string): Promise<Response> {
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
}
post(url: string, payload: any) {
return this.httpRequestWithJSONBody(url, payload, "POST");
}
postWithContentType(url: string, payload: any, contentType: string) {
return this.httpRequestWithContentType(
url,
"POST",
JSON.stringify(payload),
contentType
);
}
putWithContentType(url: string, payload: any, contentType: string) {
return this.httpRequestWithContentType(
url,
"PUT",
JSON.stringify(payload),
contentType
);
}
delete(url: string): Promise<Response> {
let options: RequestOptions = {
method: "DELETE"
};
options = Object.assign(options, fetchOptions);
return fetch(createUrl(url), options).then(handleStatusCode);
}
httpRequestWithJSONBody(
url: string,
payload: any,
method: string
): Promise<Response> {
// let options: RequestOptions = {
// method: method,
// body: JSON.stringify(payload)
// };
// options = Object.assign(options, fetchOptions);
// // $FlowFixMe
// options.headers["Content-Type"] = "application/json";
// return fetch(createUrl(url), options).then(handleStatusCode);
return this.httpRequestWithContentType(
url,
method,
JSON.stringify(payload),
"application/json"
).then(handleStatusCode);
}
httpRequestWithContentType(
url: string,
method: string,
payload: any,
contentType: string
): Promise<Response> {
let options: RequestOptions = {
method: method,
body: payload
};
options = Object.assign(options, fetchOptions);
// $FlowFixMe
options.headers["Content-Type"] = contentType;
return fetch(createUrl(url), options).then(handleStatusCode);
}
}
export let apiClient = new ApiClient();

View File

@@ -0,0 +1,15 @@
// @flow
import { createUrl } from "./apiclient";
describe("create url", () => {
it("should not change absolute urls", () => {
expect(createUrl("https://www.scm-manager.org")).toBe(
"https://www.scm-manager.org"
);
});
it("should add prefix for api", () => {
expect(createUrl("/users")).toBe("/scm/api/rest/v2/users");
expect(createUrl("users")).toBe("/scm/api/rest/v2/users");
});
});

View File

@@ -0,0 +1,25 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import Notification from "./Notification";
type Props = {
t: string => string,
error?: Error
};
class ErrorNotification extends React.Component<Props> {
render() {
const { t, error } = this.props;
if (error) {
return (
<Notification type="danger">
<strong>{t("error-notification.prefix")}:</strong> {error.message}
</Notification>
);
}
return "";
}
}
export default translate("commons")(ErrorNotification);

View File

@@ -0,0 +1,27 @@
//@flow
import React from "react";
import ErrorNotification from "./ErrorNotification";
type Props = {
error: Error,
title: string,
subtitle: string
};
class ErrorPage extends React.Component<Props> {
render() {
const { title, subtitle, error } = this.props;
return (
<section className="section">
<div className="box column is-4 is-offset-4 container">
<h1 className="title">{title}</h1>
<p className="subtitle">{subtitle}</p>
<ErrorNotification error={error} />
</div>
</section>
);
}
}
export default ErrorPage;

View File

@@ -0,0 +1,45 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import injectSheet from "react-jss";
import Image from "../images/loading.svg";
const styles = {
wrapper: {
position: "relative"
},
loading: {
width: "128px",
height: "128px",
position: "absolute",
top: "50%",
left: "50%",
margin: "64px 0 0 -64px"
},
image: {
width: "128px",
height: "128px"
}
};
type Props = {
t: string => string,
classes: any
};
class Loading extends React.Component<Props> {
render() {
const { t, classes } = this.props;
return (
<div className={classes.wrapper}>
<div className={classes.loading}>
<img className={classes.image} src={Image} alt={t("loading.alt")} />
</div>
</div>
);
}
}
export default injectSheet(styles)(translate("commons")(Loading));

View File

@@ -0,0 +1,17 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import Image from "../images/logo.png";
type Props = {
t: string => string
};
class Logo extends React.Component<Props> {
render() {
const { t } = this.props;
return <img src={Image} alt={t("logo.alt")} />;
}
}
export default translate("commons")(Logo);

View File

@@ -0,0 +1,37 @@
//@flow
import * as React from "react";
import classNames from "classnames";
type NotificationType = "primary" | "info" | "success" | "warning" | "danger";
type Props = {
type: NotificationType,
onClose?: () => void,
children?: React.Node
};
class Notification extends React.Component<Props> {
static defaultProps = {
type: "info"
};
renderCloseButton() {
const { onClose } = this.props;
if (onClose) {
return <button className="delete" onClick={onClose} />;
}
return "";
}
render() {
const { type, children } = this.props;
return (
<div className={classNames("notification", "is-" + type)}>
{this.renderCloseButton()}
{children}
</div>
);
}
}
export default Notification;

View File

@@ -0,0 +1,120 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { PagedCollection } from "../types/Collection";
import { Button } from "./buttons";
type Props = {
collection: PagedCollection,
onPageChange: string => void,
t: string => string
};
class Paginator extends React.Component<Props> {
isLinkUnavailable(linkType: string) {
return !this.props.collection || !this.props.collection._links[linkType];
}
createAction = (linkType: string) => () => {
const { collection, onPageChange } = this.props;
const link = collection._links[linkType].href;
onPageChange(link);
};
renderFirstButton() {
return this.renderPageButton(1, "first");
}
renderPreviousButton() {
const { t } = this.props;
return this.renderButton(
"pagination-previous",
t("paginator.previous"),
"prev"
);
}
renderNextButton() {
const { t } = this.props;
return this.renderButton("pagination-next", t("paginator.next"), "next");
}
renderLastButton() {
const { collection } = this.props;
return this.renderPageButton(collection.pageTotal, "last");
}
renderPageButton(page: number, linkType: string) {
return this.renderButton("pagination-link", page.toString(), linkType);
}
renderButton(className: string, label: string, linkType: string) {
return (
<Button
className={className}
label={label}
disabled={this.isLinkUnavailable(linkType)}
action={this.createAction(linkType)}
/>
);
}
seperator() {
return <span className="pagination-ellipsis">&hellip;</span>;
}
currentPage(page: number) {
return (
<Button
className="pagination-link is-current"
label={page}
disabled={true}
/>
);
}
pageLinks() {
const { collection } = this.props;
const links = [];
const page = collection.page + 1;
const pageTotal = collection.pageTotal;
if (page > 1) {
links.push(this.renderFirstButton());
}
if (page > 3) {
links.push(this.seperator());
}
if (page > 2) {
links.push(this.renderPageButton(page - 1, "prev"));
}
links.push(this.currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderPageButton(page + 1, "next"));
links.push(this.seperator());
}
if (page < pageTotal) {
links.push(this.renderLastButton());
}
return links;
}
render() {
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton()}
{this.renderNextButton()}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => {
return <li key={index}>{link}</li>;
})}
</ul>
</nav>
);
}
}
export default translate("commons")(Paginator);

View File

@@ -0,0 +1,253 @@
// @flow
import React from "react";
import { mount, shallow } from "enzyme";
import "../tests/enzyme";
import "../tests/i18n";
import Paginator from "./Paginator";
describe("paginator rendering tests", () => {
const dummyLink = {
href: "https://dummy"
};
it("should render all buttons but disabled, without links", () => {
const collection = {
page: 10,
pageTotal: 20,
_links: {}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(7);
for (let button of buttons) {
expect(button.props.disabled).toBeTruthy();
}
});
it("should render buttons for first page", () => {
const collection = {
page: 0,
pageTotal: 148,
_links: {
first: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(5);
// previous button
expect(buttons.get(0).props.disabled).toBeTruthy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeTruthy();
expect(firstButton.label).toBe(1);
// next button
const nextButton = buttons.get(3).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("2");
// last button
const lastButton = buttons.get(4).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for second page", () => {
const collection = {
page: 1,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(6);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// current button
const currentButton = buttons.get(3).props;
expect(currentButton.disabled).toBeTruthy();
expect(currentButton.label).toBe(2);
// next button
const nextButton = buttons.get(4).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("3");
// last button
const lastButton = buttons.get(5).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for last page", () => {
const collection = {
page: 147,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(5);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeTruthy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// next button
const nextButton = buttons.get(3).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("147");
// last button
const lastButton = buttons.get(4).props;
expect(lastButton.disabled).toBeTruthy();
expect(lastButton.label).toBe(148);
});
it("should render buttons for penultimate page", () => {
const collection = {
page: 146,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(6);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// last button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
const currentButton = buttons.get(3).props;
expect(currentButton.disabled).toBeFalsy();
expect(currentButton.label).toBe("146");
// current button
const nextButton = buttons.get(4).props;
expect(nextButton.disabled).toBeTruthy();
expect(nextButton.label).toBe(147);
// last button
const lastButton = buttons.get(5).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should render buttons for a page in the middle", () => {
const collection = {
page: 41,
pageTotal: 148,
_links: {
first: dummyLink,
prev: dummyLink,
next: dummyLink,
last: dummyLink
}
};
const paginator = shallow(<Paginator collection={collection} />);
const buttons = paginator.find("Button");
expect(buttons.length).toBe(7);
// previous button
expect(buttons.get(0).props.disabled).toBeFalsy();
// next button
expect(buttons.get(1).props.disabled).toBeFalsy();
// first button
const firstButton = buttons.get(2).props;
expect(firstButton.disabled).toBeFalsy();
expect(firstButton.label).toBe("1");
// previous Button
const previousButton = buttons.get(3).props;
expect(previousButton.disabled).toBeFalsy();
expect(previousButton.label).toBe("41");
// current button
const currentButton = buttons.get(4).props;
expect(currentButton.disabled).toBeTruthy();
expect(currentButton.label).toBe(42);
// next button
const nextButton = buttons.get(5).props;
expect(nextButton.disabled).toBeFalsy();
expect(nextButton.label).toBe("43");
// last button
const lastButton = buttons.get(6).props;
expect(lastButton.disabled).toBeFalsy();
expect(lastButton.label).toBe("148");
});
it("should call the function with the last previous url", () => {
const collection = {
page: 41,
pageTotal: 148,
_links: {
first: dummyLink,
prev: {
href: "https://www.scm-manager.org"
},
next: dummyLink,
last: dummyLink
}
};
let urlToOpen;
const callMe = (url: string) => {
urlToOpen = url;
};
const paginator = mount(
<Paginator collection={collection} onPageChange={callMe} />
);
paginator.find("Button.pagination-previous").simulate("click");
expect(urlToOpen).toBe("https://www.scm-manager.org");
});
});

View File

@@ -0,0 +1,39 @@
//@flow
import React, { Component } from "react";
import { Route, Redirect, withRouter } from "react-router-dom";
type Props = {
authenticated?: boolean,
component: Component<any, any>
};
class ProtectedRoute extends React.Component<Props> {
renderRoute = (Component: any, authenticated?: boolean) => {
return (routeProps: any) => {
if (authenticated) {
return <Component {...routeProps} />;
} else {
return (
<Redirect
to={{
pathname: "/login",
state: { from: routeProps.location }
}}
/>
);
}
};
};
render() {
const { component, authenticated, ...routeProps } = this.props;
return (
<Route
{...routeProps}
render={this.renderRoute(component, authenticated)}
/>
);
}
}
export default withRouter(ProtectedRoute);

View File

@@ -0,0 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class AddButton extends React.Component<ButtonProps> {
render() {
return <Button type="default" {...this.props} />;
}
}
export default AddButton;

View File

@@ -0,0 +1,64 @@
//@flow
import React from "react";
import classNames from "classnames";
import { Link } from "react-router-dom";
export type ButtonProps = {
label: string,
loading?: boolean,
disabled?: boolean,
action?: () => void,
link?: string,
fullWidth?: boolean,
className?: string
};
type Props = ButtonProps & {
type: string
};
class Button extends React.Component<Props> {
static defaultProps = {
type: "default"
};
renderButton = () => {
const {
label,
loading,
disabled,
type,
action,
fullWidth,
className
} = this.props;
const loadingClass = loading ? "is-loading" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : "";
return (
<button
disabled={disabled}
onClick={action ? action : () => {}}
className={classNames(
"button",
"is-" + type,
loadingClass,
fullWidthClass,
className
)}
>
{label}
</button>
);
};
render() {
const { link } = this.props;
if (link) {
return <Link to={link}>{this.renderButton()}</Link>;
} else {
return this.renderButton();
}
}
}
export default Button;

View File

@@ -0,0 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class DeleteButton extends React.Component<ButtonProps> {
render() {
return <Button type="warning" {...this.props} />;
}
}
export default DeleteButton;

View File

@@ -0,0 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class EditButton extends React.Component<ButtonProps> {
render() {
return <Button type="default" {...this.props} />;
}
}
export default EditButton;

View File

@@ -0,0 +1,11 @@
//@flow
import React from "react";
import Button, { type ButtonProps } from "./Button";
class SubmitButton extends React.Component<ButtonProps> {
render() {
return <Button type="primary" {...this.props} />;
}
}
export default SubmitButton;

View File

@@ -0,0 +1,5 @@
export { default as AddButton } from "./AddButton";
export { default as Button } from "./Button";
export { default as DeleteButton } from "./DeleteButton";
export { default as EditButton } from "./EditButton";
export { default as SubmitButton } from "./SubmitButton";

View File

@@ -0,0 +1,34 @@
//@flow
import React from "react";
type Props = {
label?: string,
checked: boolean,
onChange?: boolean => void
};
class Checkbox extends React.Component<Props> {
onCheckboxChange = (event: SyntheticInputEvent<HTMLInputElement>) => {
if (this.props.onChange) {
this.props.onChange(event.target.checked);
}
};
render() {
return (
<div className="field">
<div className="control">
<label className="checkbox">
<input
type="checkbox"
checked={this.props.checked}
onChange={this.onCheckboxChange}
/>
{this.props.label}
</label>
</div>
</div>
);
}
}
export default Checkbox;

View File

@@ -0,0 +1,70 @@
//@flow
import React from "react";
import classNames from "classnames";
type Props = {
label?: string,
placeholder?: string,
value?: string,
type?: string,
autofocus?: boolean,
onChange: string => void,
validationError: boolean,
errorMessage: string
};
class InputField extends React.Component<Props> {
static defaultProps = {
type: "text",
placeholder: ""
};
field: ?HTMLInputElement;
componentDidMount() {
if (this.props.autofocus && this.field) {
this.field.focus();
}
}
handleInput = (event: SyntheticInputEvent<HTMLInputElement>) => {
this.props.onChange(event.target.value);
};
renderLabel = () => {
const label = this.props.label;
if (label) {
return <label className="label">{label}</label>;
}
return "";
};
render() {
const { type, placeholder, value, validationError, errorMessage } = this.props;
const errorView = validationError ? "is-danger" : "";
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
return (
<div className="field">
{this.renderLabel()}
<div className="control">
<input
ref={input => {
this.field = input;
}}
className={ classNames(
"input",
errorView
)}
type={type}
placeholder={placeholder}
value={value}
onChange={this.handleInput}
/>
</div>
{helper}
</div>
);
}
}
export default InputField;

View File

@@ -0,0 +1,2 @@
export { default as Checkbox } from "./Checkbox";
export { default as InputField } from "./InputField";

View File

@@ -0,0 +1,25 @@
//@flow
import React from "react";
import type { Me } from "../../types/Me";
type Props = {
me?: Me
};
class Footer extends React.Component<Props> {
render() {
const { me } = this.props;
if (!me) {
return "";
}
return (
<footer className="footer">
<div className="container is-centered">
<p className="has-text-centered">{me.displayName}</p>
</div>
</footer>
);
}
}
export default Footer;

View File

@@ -0,0 +1,31 @@
//@flow
import * as React from "react";
import Logo from "./../Logo";
type Props = {
children?: React.Node
};
class Header extends React.Component<Props> {
render() {
const { children } = this.props;
return (
<section className="hero is-dark is-small">
<div className="hero-body">
<div className="container">
<div className="columns is-vcentered">
<div className="column">
<Logo />
</div>
</div>
</div>
</div>
<div className="hero-foot">
<div className="container">{children}</div>
</div>
</section>
);
}
}
export default Header;

View File

@@ -0,0 +1,49 @@
//@flow
import * as React from "react";
import Loading from "./../Loading";
import ErrorNotification from "./../ErrorNotification";
type Props = {
title: string,
subtitle?: string,
loading?: boolean,
error?: Error,
children: React.Node
};
class Page extends React.Component<Props> {
render() {
const { title, error } = this.props;
return (
<section className="section">
<div className="container">
<h1 className="title">{title}</h1>
{this.renderSubtitle()}
<ErrorNotification error={error} />
{this.renderContent()}
</div>
</section>
);
}
renderSubtitle() {
const { subtitle } = this.props;
if (subtitle) {
return <h2 className="subtitle">{subtitle}</h2>;
}
return null;
}
renderContent() {
const { loading, children, error } = this.props;
if (error) {
return null;
}
if (loading) {
return <Loading />;
}
return children;
}
}
export default Page;

View File

@@ -0,0 +1,3 @@
export { default as Footer } from "./Footer";
export { default as Header } from "./Header";
export { default as Page } from "./Page";

View File

@@ -0,0 +1,102 @@
/*modified from https://github.com/GA-MO/react-confirm-alert*/
.react-confirm-alert-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
background: rgba(255, 255, 255, 0.9);
display: -webkit-flex;
display: -moz-flex;
display: -ms-flex;
display: -o-flex;
display: flex;
justify-content: center;
-ms-align-items: center;
align-items: center;
opacity: 0;
-webkit-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
-moz-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
-o-animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
animation: react-confirm-alert-fadeIn 0.5s 0.2s forwards;
}
.react-confirm-alert-body {
font-family: Arial, Helvetica, sans-serif;
width: 400px;
padding: 30px;
text-align: left;
background: #fff;
border-radius: 10px;
box-shadow: 0 20px 75px rgba(0, 0, 0, 0.13);
color: #666;
}
.react-confirm-alert-body > h1 {
margin-top: 0;
}
.react-confirm-alert-body > h3 {
margin: 0;
font-size: 16px;
}
.react-confirm-alert-button-group {
display: -webkit-flex;
display: -moz-flex;
display: -ms-flex;
display: -o-flex;
display: flex;
justify-content: flex-start;
margin-top: 20px;
}
.react-confirm-alert-button-group > button {
outline: none;
background: #333;
border: none;
display: inline-block;
padding: 6px 18px;
color: #eee;
margin-right: 10px;
border-radius: 5px;
font-size: 12px;
cursor: pointer;
}
@-webkit-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-moz-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-o-keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes react-confirm-alert-fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@@ -0,0 +1,63 @@
//modified from https://github.com/GA-MO/react-confirm-alert
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
type Props = {
title:string,
message: string,
buttons: array,
}
class ConfirmAlert extends React.Component<Props> {
handleClickButton = button => {
if (button.onClick) button.onClick()
this.close()
}
close = () => {
removeElementReconfirm()
}
render () {
const { title, message, buttons } = this.props;
return (
<div className="react-confirm-alert-overlay">
<div className="react-confirm-alert">
{<div className="react-confirm-alert-body">
{title && <h1>{title}</h1>}
{message}
<div className="react-confirm-alert-button-group">
{buttons.map((button, i) => (
<button key={i} onClick={() => this.handleClickButton(button)}>
{button.label}
</button>
))}
</div>
</div>}
</div>
</div>
)
}
}
function createElementReconfirm (properties) {
const divTarget = document.createElement('div')
divTarget.id = 'react-confirm-alert'
document.body.appendChild(divTarget)
render(<ConfirmAlert {...properties} />, divTarget)
}
function removeElementReconfirm () {
const target = document.getElementById('react-confirm-alert')
unmountComponentAtNode(target)
target.parentNode.removeChild(target)
}
export function confirmAlert (properties) {
createElementReconfirm(properties)
}
export default ConfirmAlert;

View File

@@ -0,0 +1 @@
export { default as ConfirmAlert } from "./ConfirmAlert";

View File

@@ -0,0 +1,20 @@
//@flow
import React from "react";
type Props = {
label: string,
action: () => void
};
class NavAction extends React.Component<Props> {
render() {
const { label, action } = this.props;
return (
<li>
<a onClick={action}>{label}</a>
</li>
);
}
}
export default NavAction;

View File

@@ -0,0 +1,37 @@
//@flow
import * as React from "react";
import { Route, Link } from "react-router-dom";
// TODO mostly copy of PrimaryNavigationLink
type Props = {
to: string,
label: string,
activeOnlyWhenExact?: boolean
};
class NavLink extends React.Component<Props> {
static defaultProps = {
activeOnlyWhenExact: true
};
renderLink = (route: any) => {
const { to, label } = this.props;
return (
<li>
<Link className={route.match ? "is-active" : ""} to={to}>
{label}
</Link>
</li>
);
};
render() {
const { to, activeOnlyWhenExact } = this.props;
return (
<Route path={to} exact={activeOnlyWhenExact} children={this.renderLink} />
);
}
}
export default NavLink;

View File

@@ -0,0 +1,14 @@
//@flow
import * as React from "react";
type Props = {
children?: React.Node
};
class Navigation extends React.Component<Props> {
render() {
return <aside className="menu">{this.props.children}</aside>;
}
}
export default Navigation;

View File

@@ -0,0 +1,36 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink";
type Props = {
t: string => string
};
class PrimaryNavigation extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<nav className="tabs is-boxed">
<ul>
<PrimaryNavigationLink
to="/"
activeOnlyWhenExact={true}
label={t("primary-navigation.repositories")}
/>
<PrimaryNavigationLink
to="/users"
match="/(user|users)"
label={t("primary-navigation.users")}
/>
<PrimaryNavigationLink
to="/logout"
label={t("primary-navigation.logout")}
/>
</ul>
</nav>
);
}
}
export default translate("commons")(PrimaryNavigation);

View File

@@ -0,0 +1,35 @@
//@flow
import * as React from "react";
import { Route, Link } from "react-router-dom";
type Props = {
to: string,
label: string,
match?: string,
activeOnlyWhenExact?: boolean
};
class PrimaryNavigationLink extends React.Component<Props> {
renderLink = (route: any) => {
const { to, label } = this.props;
return (
<li className={route.match ? "is-active" : ""}>
<Link to={to}>{label}</Link>
</li>
);
};
render() {
const { to, match, activeOnlyWhenExact } = this.props;
const path = match ? match : to;
return (
<Route
path={path}
exact={activeOnlyWhenExact}
children={this.renderLink}
/>
);
}
}
export default PrimaryNavigationLink;

View File

@@ -0,0 +1,21 @@
//@flow
import * as React from "react";
type Props = {
label: string,
children?: React.Node
};
class Section extends React.Component<Props> {
render() {
const { label, children } = this.props;
return (
<div>
<p className="menu-label">{label}</p>
<ul className="menu-list">{children}</ul>
</div>
);
}
}
export default Section;

View File

@@ -0,0 +1,8 @@
//primary Navigation
export { default as PrimaryNavigation } from "./PrimaryNavigation";
export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink";
//secondary Navigation
export { default as Navigation } from "./Navigation";
export { default as Section } from "./Section";
export { default as NavLink } from "./NavLink";
export { default as NavAction } from "./NavAction";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import React, { Component } from "react";
import Main from "./Main";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { withRouter } from "react-router-dom";
import {
fetchMe,
isAuthenticated,
getMe,
isFetchMePending,
getFetchMeFailure
} from "../modules/auth";
import "./App.css";
import "../components/modals/ConfirmAlert.css";
import { PrimaryNavigation } from "../components/navigation";
import Loading from "../components/Loading";
import ErrorPage from "../components/ErrorPage";
import { Footer, Header } from "../components/layout";
type Props = {
me: Me,
authenticated: boolean,
error: Error,
loading: boolean,
// dispatcher functions
fetchMe: () => void,
// context props
t: string => string
};
class App extends Component<Props> {
componentDidMount() {
this.props.fetchMe();
}
render() {
const { me, loading, error, authenticated, t } = this.props;
let content;
const navigation = authenticated ? <PrimaryNavigation /> : "";
if (loading) {
content = <Loading />;
} else if (error) {
content = (
<ErrorPage
title={t("app.error.title")}
subtitle={t("app.error.subtitle")}
error={error}
/>
);
} else {
content = <Main authenticated={authenticated} />;
}
return (
<div className="App">
<Header>{navigation}</Header>
{content}
<Footer me={me} />
</div>
);
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
fetchMe: () => dispatch(fetchMe())
};
};
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const me = getMe(state);
const loading = isFetchMePending(state);
const error = getFetchMeFailure(state);
return {
authenticated,
me,
loading,
error
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(App))
);

View File

@@ -0,0 +1,35 @@
@import "bulma/sass/utilities/initial-variables";
@import "bulma/sass/utilities/functions";
$blue: #33B2E8;
// $footer-background-color
.is-ellipsis-overflow {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.has-rounded-border {
border-radius: 0.25rem;
}
.is-full-width {
width: 100%;
}
.fitParent {
// TODO get rid of important
margin: 0 !important;
// 3.8em for line-numbers
padding: 0 0 0 3.8em !important;
}
html, body {
background-color: whitesmoke;
height: 100%;
}
// 6. Import the rest of Bulma
@import "bulma/bulma";

View File

@@ -0,0 +1,167 @@
//@flow
import React from "react";
import { Redirect, withRouter } from "react-router-dom";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import {
login,
isAuthenticated,
isLoginPending,
getLoginFailure
} from "../modules/auth";
import { connect } from "react-redux";
import { InputField } from "../components/forms";
import { SubmitButton } from "../components/buttons";
import classNames from "classnames";
import Avatar from "../images/blib.jpg";
import ErrorNotification from "../components/ErrorNotification";
const styles = {
avatar: {
marginTop: "-70px",
paddingBottom: "20px"
},
avatarImage: {
border: "1px solid lightgray",
padding: "5px",
background: "#fff",
borderRadius: "50%",
width: "128px",
height: "128px"
},
avatarSpacing: {
marginTop: "5rem"
}
};
type Props = {
authenticated: boolean,
loading: boolean,
error: Error,
// dispatcher props
login: (username: string, password: string) => void,
// context props
t: string => string,
classes: any,
from: any,
location: any
};
type State = {
username: string,
password: string
};
class Login extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { username: "", password: "" };
}
handleUsernameChange = (value: string) => {
this.setState({ username: value });
};
handlePasswordChange = (value: string) => {
this.setState({ password: value });
};
handleSubmit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.login(this.state.username, this.state.password);
}
};
isValid() {
return this.state.username && this.state.password;
}
isInValid() {
return !this.isValid();
}
renderRedirect = () => {
const { from } = this.props.location.state || { from: { pathname: "/" } };
return <Redirect to={from} />;
};
render() {
const { authenticated, loading, error, t, classes } = this.props;
if (authenticated) {
return this.renderRedirect();
}
return (
<section className="hero has-background-light">
<div className="hero-body">
<div className="container has-text-centered">
<div className="column is-4 is-offset-4">
<h3 className="title">{t("login.title")}</h3>
<p className="subtitle">{t("login.subtitle")}</p>
<div className={classNames("box", classes.avatarSpacing)}>
<figure className={classes.avatar}>
<img
className={classes.avatarImage}
src={Avatar}
alt={t("login.logo-alt")}
/>
</figure>
<ErrorNotification error={error} />
<form onSubmit={this.handleSubmit}>
<InputField
placeholder={t("login.username-placeholder")}
autofocus={true}
onChange={this.handleUsernameChange}
/>
<InputField
placeholder={t("login.password-placeholder")}
type="password"
onChange={this.handlePasswordChange}
/>
<SubmitButton
label={t("login.submit")}
disabled={this.isInValid()}
fullWidth={true}
loading={loading}
/>
</form>
</div>
</div>
</div>
</div>
</section>
);
}
}
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const loading = isLoginPending(state);
const error = getLoginFailure(state);
return {
authenticated,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
login: (username: string, password: string) =>
dispatch(login(username, password))
};
};
const StyledLogin = injectSheet(styles)(
connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(Login))
);
export default withRouter(StyledLogin);

View File

@@ -0,0 +1,71 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Redirect } from "react-router-dom";
import {
logout,
isAuthenticated,
isLogoutPending,
getLogoutFailure
} from "../modules/auth";
import ErrorPage from "../components/ErrorPage";
import Loading from "../components/Loading";
type Props = {
authenticated: boolean,
loading: boolean,
error: Error,
// dispatcher functions
logout: () => void,
// context props
t: string => string
};
class Logout extends React.Component<Props> {
componentDidMount() {
this.props.logout();
}
render() {
const { authenticated, loading, error, t } = this.props;
if (error) {
return (
<ErrorPage
title={t("logout.error.title")}
subtitle={t("logout.error.subtitle")}
error={error}
/>
);
} else if (loading || authenticated) {
return <Loading />;
} else {
return <Redirect to="/login" />;
}
}
}
const mapStateToProps = state => {
const authenticated = isAuthenticated(state);
const loading = isLogoutPending(state);
const error = getLogoutFailure(state);
return {
authenticated,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
logout: () => dispatch(logout())
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("commons")(Logout));

View File

@@ -0,0 +1,62 @@
//@flow
import React from "react";
import { Route, withRouter } from "react-router";
import Repositories from "../repositories/containers/Repositories";
import Users from "../users/containers/Users";
import Login from "../containers/Login";
import Logout from "../containers/Logout";
import { Switch } from "react-router-dom";
import ProtectedRoute from "../components/ProtectedRoute";
import AddUser from "../users/containers/AddUser";
import SingleUser from "../users/containers/SingleUser";
type Props = {
authenticated?: boolean
};
class Main extends React.Component<Props> {
render() {
const { authenticated } = this.props;
return (
<div>
<Switch>
<ProtectedRoute
exact
path="/"
component={Repositories}
authenticated={authenticated}
/>
<Route exact path="/login" component={Login} />
<Route path="/logout" component={Logout} />
<ProtectedRoute
exact
path="/users"
component={Users}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/users/add"
component={AddUser}
/>
<ProtectedRoute
exact
path="/users/:page"
component={Users}
authenticated={authenticated}
/>
<ProtectedRoute
authenticated={authenticated}
path="/user/:name"
component={SingleUser}
/>
</Switch>
</div>
);
}
}
export default withRouter(Main);

View File

@@ -0,0 +1,32 @@
// @flow
import thunk from "redux-thunk";
import logger from "redux-logger";
import { createStore, compose, applyMiddleware, combineReducers } from "redux";
import { routerReducer, routerMiddleware } from "react-router-redux";
import users from "./users/modules/users";
import auth from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
import type { BrowserHistory } from "history/createBrowserHistory";
function createReduxStore(history: BrowserHistory) {
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const reducer = combineReducers({
router: routerReducer,
pending,
failure,
users,
auth
});
return createStore(
reducer,
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk, logger))
);
}
export default createReduxStore;

37
scm-ui/src/i18n.js Normal file
View File

@@ -0,0 +1,37 @@
import i18n from "i18next";
import Backend from "i18next-fetch-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { reactI18nextModule } from "react-i18next";
const loadPath = process.env.PUBLIC_URL + "/locales/{{lng}}/{{ns}}.json";
i18n
.use(Backend)
.use(LanguageDetector)
.use(reactI18nextModule)
.init({
fallbackLng: "en",
// have a common namespace used around the full app
ns: ["commons"],
defaultNS: "commons",
debug: true,
interpolation: {
escapeValue: false // not needed for react!!
},
react: {
wait: true
},
backend: {
loadPath: loadPath,
init: {
credentials: "same-origin"
}
}
});
export default i18n;

BIN
scm-ui/src/images/blib.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,36 @@
<svg version="1.1" id="L7" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve">
<path fill="#33B2E8" d="M31.6,3.5C5.9,13.6-6.6,42.7,3.5,68.4c10.1,25.7,39.2,38.3,64.9,28.1l-3.1-7.9c-21.3,8.4-45.4-2-53.8-23.3
c-8.4-21.3,2-45.4,23.3-53.8L31.6,3.5z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="2s"
from="0 50 50"
to="360 50 50"
repeatCount="indefinite" />
</path>
<path fill="#33B2E8" d="M42.3,39.6c5.7-4.3,13.9-3.1,18.1,2.7c4.3,5.7,3.1,13.9-2.7,18.1l4.1,5.5c8.8-6.5,10.6-19,4.1-27.7
c-6.5-8.8-19-10.6-27.7-4.1L42.3,39.6z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="1s"
from="0 50 50"
to="-360 50 50"
repeatCount="indefinite" />
</path>
<path fill="#33B2E8" d="M82,35.7C74.1,18,53.4,10.1,35.7,18S10.1,46.6,18,64.3l7.6-3.4c-6-13.5,0-29.3,13.5-35.3s29.3,0,35.3,13.5
L82,35.7z">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
dur="2s"
from="0 50 50"
to="360 50 50"
repeatCount="indefinite" />
</path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
scm-ui/src/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

46
scm-ui/src/index.js Normal file
View File

@@ -0,0 +1,46 @@
// @flow
import React from "react";
import ReactDOM from "react-dom";
import App from "./containers/App";
import registerServiceWorker from "./registerServiceWorker";
import { I18nextProvider } from "react-i18next";
import i18n from "./i18n";
import { Provider } from "react-redux";
import createHistory from "history/createBrowserHistory";
import type { BrowserHistory } from "history/createBrowserHistory";
import createReduxStore from "./createReduxStore";
import { ConnectedRouter } from "react-router-redux";
const publicUrl: string = process.env.PUBLIC_URL || "";
// Create a history of your choosing (we're using a browser history in this case)
const history: BrowserHistory = createHistory({
basename: publicUrl
});
// Add the reducer to your store on the `router` key
// Also apply our middleware for navigating
const store = createReduxStore(history);
const root = document.getElementById("root");
if (!root) {
throw new Error("could not find root element");
}
ReactDOM.render(
<Provider store={store}>
<I18nextProvider i18n={i18n}>
{/* ConnectedRouter will use the store from Provider automatically */}
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</I18nextProvider>
</Provider>,
root
);
registerServiceWorker();

235
scm-ui/src/modules/auth.js Normal file
View File

@@ -0,0 +1,235 @@
// @flow
import type { Me } from "../types/Me";
import * as types from "./types";
import { apiClient, UNAUTHORIZED_ERROR } from "../apiclient";
import { isPending } from "./pending";
import { getFailure } from "./failure";
// Action
export const LOGIN = "scm/auth/LOGIN";
export const LOGIN_PENDING = `${LOGIN}_${types.PENDING_SUFFIX}`;
export const LOGIN_SUCCESS = `${LOGIN}_${types.SUCCESS_SUFFIX}`;
export const LOGIN_FAILURE = `${LOGIN}_${types.FAILURE_SUFFIX}`;
export const FETCH_ME = "scm/auth/FETCH_ME";
export const FETCH_ME_PENDING = `${FETCH_ME}_${types.PENDING_SUFFIX}`;
export const FETCH_ME_SUCCESS = `${FETCH_ME}_${types.SUCCESS_SUFFIX}`;
export const FETCH_ME_FAILURE = `${FETCH_ME}_${types.FAILURE_SUFFIX}`;
export const FETCH_ME_UNAUTHORIZED = `${FETCH_ME}_UNAUTHORIZED`;
export const LOGOUT = "scm/auth/LOGOUT";
export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`;
export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`;
export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`;
// Reducer
const initialState = {};
export default function reducer(
state: Object = initialState,
action: Object = { type: "UNKNOWN" }
) {
switch (action.type) {
case LOGIN_SUCCESS:
case FETCH_ME_SUCCESS:
return {
...state,
me: action.payload,
authenticated: true
};
case FETCH_ME_UNAUTHORIZED:
return {
me: {},
authenticated: false
};
case LOGOUT_SUCCESS:
return initialState;
default:
return state;
}
}
// Action Creators
export const loginPending = () => {
return {
type: LOGIN_PENDING
};
};
export const loginSuccess = (me: Me) => {
return {
type: LOGIN_SUCCESS,
payload: me
};
};
export const loginFailure = (error: Error) => {
return {
type: LOGIN_FAILURE,
payload: error
};
};
export const logoutPending = () => {
return {
type: LOGOUT_PENDING
};
};
export const logoutSuccess = () => {
return {
type: LOGOUT_SUCCESS
};
};
export const logoutFailure = (error: Error) => {
return {
type: LOGOUT_FAILURE,
payload: error
};
};
export const fetchMePending = () => {
return {
type: FETCH_ME_PENDING
};
};
export const fetchMeSuccess = (me: Me) => {
return {
type: FETCH_ME_SUCCESS,
payload: me
};
};
export const fetchMeUnauthenticated = () => {
return {
type: FETCH_ME_UNAUTHORIZED,
resetPending: true
};
};
export const fetchMeFailure = (error: Error) => {
return {
type: FETCH_ME_FAILURE,
payload: error
};
};
// urls
const ME_URL = "/me";
const LOGIN_URL = "/auth/access_token";
// side effects
const callFetchMe = (): Promise<Me> => {
return apiClient
.get(ME_URL)
.then(response => {
return response.json();
})
.then(json => {
return { name: json.name, displayName: json.displayName };
});
};
export const login = (username: string, password: string) => {
const login_data = {
cookie: true,
grant_type: "password",
username,
password
};
return function(dispatch: any) {
dispatch(loginPending());
return apiClient
.post(LOGIN_URL, login_data)
.then(response => {
return callFetchMe();
})
.then(me => {
dispatch(loginSuccess(me));
})
.catch(err => {
dispatch(loginFailure(err));
});
};
};
export const fetchMe = () => {
return function(dispatch: any) {
dispatch(fetchMePending());
return callFetchMe()
.then(me => {
dispatch(fetchMeSuccess(me));
})
.catch((error: Error) => {
if (error === UNAUTHORIZED_ERROR) {
dispatch(fetchMeUnauthenticated());
} else {
dispatch(fetchMeFailure(error));
}
});
};
};
export const logout = () => {
return function(dispatch: any) {
dispatch(logoutPending());
return apiClient
.delete(LOGIN_URL)
.then(() => {
dispatch(logoutSuccess());
})
.catch(error => {
dispatch(logoutFailure(error));
});
};
};
// selectors
const stateAuth = (state: Object): Object => {
return state.auth || {};
};
export const isAuthenticated = (state: Object) => {
if (stateAuth(state).authenticated) {
return true;
}
return false;
};
export const getMe = (state: Object): Me => {
return stateAuth(state).me;
};
export const isFetchMePending = (state: Object) => {
return isPending(state, FETCH_ME);
};
export const getFetchMeFailure = (state: Object) => {
return getFailure(state, FETCH_ME);
};
export const isLoginPending = (state: Object) => {
return isPending(state, LOGIN);
};
export const getLoginFailure = (state: Object) => {
return getFailure(state, LOGIN);
};
export const isLogoutPending = (state: Object) => {
return isPending(state, LOGOUT);
};
export const getLogoutFailure = (state: Object) => {
return getFailure(state, LOGOUT);
};

View File

@@ -0,0 +1,279 @@
import reducer, {
fetchMeSuccess,
logout,
logoutSuccess,
loginSuccess,
fetchMeUnauthenticated,
LOGIN_SUCCESS,
login,
LOGIN_FAILURE,
LOGOUT_FAILURE,
LOGOUT_SUCCESS,
FETCH_ME_SUCCESS,
fetchMe,
FETCH_ME_FAILURE,
FETCH_ME_UNAUTHORIZED,
isAuthenticated,
LOGIN_PENDING,
FETCH_ME_PENDING,
LOGOUT_PENDING,
getMe,
isFetchMePending,
isLoginPending,
isLogoutPending,
getFetchMeFailure,
LOGIN,
FETCH_ME,
LOGOUT,
getLoginFailure,
getLogoutFailure
} from "./auth";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
const me = { name: "tricia", displayName: "Tricia McMillian" };
describe("auth reducer", () => {
it("should set me and login on successful fetch of me", () => {
const state = reducer(undefined, fetchMeSuccess(me));
expect(state.me).toBe(me);
expect(state.authenticated).toBe(true);
});
it("should set authenticated to false", () => {
const initialState = {
authenticated: true,
me
};
const state = reducer(initialState, fetchMeUnauthenticated());
expect(state.me.name).toBeUndefined();
expect(state.authenticated).toBe(false);
});
it("should reset the state after logout", () => {
const initialState = {
authenticated: true,
me
};
const state = reducer(initialState, logoutSuccess());
expect(state.me).toBeUndefined();
expect(state.authenticated).toBeUndefined();
});
it("should set state authenticated and me after login", () => {
const state = reducer(undefined, loginSuccess(me));
expect(state.me).toBe(me);
expect(state.authenticated).toBe(true);
});
});
describe("auth actions", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should dispatch login success and dispatch fetch me", () => {
fetchMock.postOnce("/scm/api/rest/v2/auth/access_token", {
body: {
cookie: true,
grant_type: "password",
username: "tricia",
password: "secret123"
},
headers: { "content-type": "application/json" }
});
fetchMock.getOnce("/scm/api/rest/v2/me", {
body: me,
headers: { "content-type": "application/json" }
});
const expectedActions = [
{ type: LOGIN_PENDING },
{ type: LOGIN_SUCCESS, payload: me }
];
const store = mockStore({});
return store.dispatch(login("tricia", "secret123")).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch login failure", () => {
fetchMock.postOnce("/scm/api/rest/v2/auth/access_token", {
status: 400
});
const store = mockStore({});
return store.dispatch(login("tricia", "secret123")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(LOGIN_PENDING);
expect(actions[1].type).toEqual(LOGIN_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should dispatch fetch me success", () => {
fetchMock.getOnce("/scm/api/rest/v2/me", {
body: me,
headers: { "content-type": "application/json" }
});
const expectedActions = [
{ type: FETCH_ME_PENDING },
{
type: FETCH_ME_SUCCESS,
payload: me
}
];
const store = mockStore({});
return store.dispatch(fetchMe()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch fetch me failure", () => {
fetchMock.getOnce("/scm/api/rest/v2/me", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchMe()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_ME_PENDING);
expect(actions[1].type).toEqual(FETCH_ME_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should dispatch fetch me unauthorized", () => {
fetchMock.getOnce("/scm/api/rest/v2/me", {
status: 401
});
const expectedActions = [
{ type: FETCH_ME_PENDING },
{ type: FETCH_ME_UNAUTHORIZED, resetPending: true }
];
const store = mockStore({});
return store.dispatch(fetchMe()).then(() => {
// return of async actions
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch logout success", () => {
fetchMock.deleteOnce("/scm/api/rest/v2/auth/access_token", {
status: 204
});
fetchMock.getOnce("/scm/api/rest/v2/me", {
status: 401
});
const expectedActions = [
{ type: LOGOUT_PENDING },
{ type: LOGOUT_SUCCESS }
];
const store = mockStore({});
return store.dispatch(logout()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch logout failure", () => {
fetchMock.deleteOnce("/scm/api/rest/v2/auth/access_token", {
status: 500
});
const store = mockStore({});
return store.dispatch(logout()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(LOGOUT_PENDING);
expect(actions[1].type).toEqual(LOGOUT_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("auth selectors", () => {
const error = new Error("yo it failed");
it("should be false, if authenticated is undefined or false", () => {
expect(isAuthenticated({})).toBe(false);
expect(isAuthenticated({ auth: {} })).toBe(false);
expect(isAuthenticated({ auth: { authenticated: false } })).toBe(false);
});
it("should be true, if authenticated is true", () => {
expect(isAuthenticated({ auth: { authenticated: true } })).toBe(true);
});
it("should return me", () => {
expect(getMe({ auth: { me } })).toBe(me);
});
it("should return undefined, if me is not set", () => {
expect(getMe({})).toBeUndefined();
});
it("should return true, if FETCH_ME is pending", () => {
expect(isFetchMePending({ pending: { [FETCH_ME]: true } })).toBe(true);
});
it("should return false, if FETCH_ME is not in pending state", () => {
expect(isFetchMePending({ pending: {} })).toBe(false);
});
it("should return true, if LOGIN is pending", () => {
expect(isLoginPending({ pending: { [LOGIN]: true } })).toBe(true);
});
it("should return false, if LOGIN is not in pending state", () => {
expect(isLoginPending({ pending: {} })).toBe(false);
});
it("should return true, if LOGOUT is pending", () => {
expect(isLogoutPending({ pending: { [LOGOUT]: true } })).toBe(true);
});
it("should return false, if LOGOUT is not in pending state", () => {
expect(isLogoutPending({ pending: {} })).toBe(false);
});
it("should return the error, if failure state is set for FETCH_ME", () => {
expect(getFetchMeFailure({ failure: { [FETCH_ME]: error } })).toBe(error);
});
it("should return unknown, if failure state is not set for FETCH_ME", () => {
expect(getFetchMeFailure({})).toBeUndefined();
});
it("should return the error, if failure state is set for LOGIN", () => {
expect(getLoginFailure({ failure: { [LOGIN]: error } })).toBe(error);
});
it("should return unknown, if failure state is not set for LOGIN", () => {
expect(getLoginFailure({})).toBeUndefined();
});
it("should return the error, if failure state is set for LOGOUT", () => {
expect(getLogoutFailure({ failure: { [LOGOUT]: error } })).toBe(error);
});
it("should return unknown, if failure state is not set for LOGOUT", () => {
expect(getLogoutFailure({})).toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
// @flow
import type { Action } from "../types/Action";
const FAILURE_SUFFIX = "_FAILURE";
const RESET_PATTERN = /^(.*)_(SUCCESS|RESET)$/;
function extractIdentifierFromFailure(action: Action) {
const type = action.type;
let identifier = type.substring(0, type.length - FAILURE_SUFFIX.length);
if (action.itemId) {
identifier += "/" + action.itemId;
}
return identifier;
}
function removeFromState(state: Object, identifier: string) {
const newState = {};
for (let failureType in state) {
if (failureType !== identifier) {
newState[failureType] = state[failureType];
}
}
return newState;
}
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
const type = action.type;
if (type.endsWith(FAILURE_SUFFIX)) {
const identifier = extractIdentifierFromFailure(action);
let payload;
if (action.payload instanceof Error) {
payload = action.payload;
} else if (action.payload) {
payload = action.payload.error;
}
return {
...state,
[identifier]: payload
};
} else {
const match = RESET_PATTERN.exec(type);
if (match) {
let identifier = match[1];
if (action.itemId) {
identifier += "/" + action.itemId;
}
return removeFromState(state, identifier);
}
}
return state;
}
export function getFailure(
state: Object,
actionType: string,
itemId?: string | number
) {
if (state.failure) {
let identifier = actionType;
if (itemId) {
identifier += "/" + itemId;
}
return state.failure[identifier];
}
}

View File

@@ -0,0 +1,130 @@
// @flow
import reducer, { getFailure } from "./failure";
const err = new Error("something failed");
const otherErr = new Error("something else failed");
describe("failure reducer", () => {
it("should set the error for FETCH_ITEMS", () => {
const newState = reducer({}, { type: "FETCH_ITEMS_FAILURE", payload: err });
expect(newState["FETCH_ITEMS"]).toBe(err);
});
it("should do nothing for unknown action types", () => {
const state = {};
const newState = reducer(state, { type: "UNKNOWN" });
expect(newState).toBe(state);
});
it("should set the error for FETCH_ITEMS, if payload has multiple values", () => {
const newState = reducer(
{},
{
type: "FETCH_ITEMS_FAILURE",
payload: { something: "something", error: err }
}
);
expect(newState["FETCH_ITEMS"]).toBe(err);
});
it("should set the error for FETCH_ITEMS, but should not affect others", () => {
const newState = reducer(
{
FETCH_USERS: otherErr
},
{ type: "FETCH_ITEMS_FAILURE", payload: err }
);
expect(newState["FETCH_ITEMS"]).toBe(err);
expect(newState["FETCH_USERS"]).toBe(otherErr);
});
it("should reset FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
const newState = reducer(
{
FETCH_ITEMS: err
},
{ type: "FETCH_ITEMS_SUCCESS" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
const newState = reducer(
{
FETCH_ITEMS: err
},
{ type: "FETCH_ITEMS_RESET" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset FETCH_ITEMS after FETCH_ITEMS_RESET, but should not affect others", () => {
const newState = reducer(
{
FETCH_ITEMS: err,
FETCH_USERS: err
},
{ type: "FETCH_ITEMS_RESET" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
expect(newState["FETCH_USERS"]).toBe(err);
});
it("should set the error for a single item of FETCH_ITEM", () => {
const newState = reducer(
{},
{ type: "FETCH_ITEM_FAILURE", payload: err, itemId: 42 }
);
expect(newState["FETCH_ITEM/42"]).toBe(err);
});
it("should reset error for a single item of FETCH_ITEM", () => {
const newState = reducer(
{ "FETCH_ITEM/42": err },
{ type: "FETCH_ITEM_SUCCESS", payload: err, itemId: 42 }
);
expect(newState["FETCH_ITEM/42"]).toBeUndefined();
});
});
describe("failure selector", () => {
it("should return failure, if FETCH_ITEMS failure exists", () => {
const failure = getFailure(
{
failure: {
FETCH_ITEMS: err
}
},
"FETCH_ITEMS"
);
expect(failure).toBe(err);
});
it("should return undefined, if state has no failure", () => {
const failure = getFailure({}, "FETCH_ITEMS");
expect(failure).toBeUndefined();
});
it("should return undefined, if FETCH_ITEMS is not defined", () => {
const failure = getFailure(
{
failure: {}
},
"FETCH_ITEMS"
);
expect(failure).toBeFalsy();
});
it("should return failure, if FETCH_ITEM 42 failure exists", () => {
const failure = getFailure(
{
failure: {
"FETCH_ITEM/42": err
}
},
"FETCH_ITEM",
42
);
expect(failure).toBe(err);
});
});

View File

@@ -0,0 +1,71 @@
// @flow
import type { Action } from "../types/Action";
import * as types from "./types";
const PENDING_SUFFIX = "_" + types.PENDING_SUFFIX;
const RESET_ACTIONTYPES = [
types.SUCCESS_SUFFIX,
types.FAILURE_SUFFIX,
types.RESET_SUFFIX
];
function removeFromState(state: Object, identifier: string) {
let newState = {};
for (let childType in state) {
if (childType !== identifier) {
newState[childType] = state[childType];
}
}
return newState;
}
function extractIdentifierFromPending(action: Action) {
const type = action.type;
let identifier = type.substring(0, type.length - PENDING_SUFFIX.length);
if (action.itemId) {
identifier += "/" + action.itemId;
}
return identifier;
}
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
const type = action.type;
if (type.endsWith(PENDING_SUFFIX)) {
const identifier = extractIdentifierFromPending(action);
return {
...state,
[identifier]: true
};
} else {
const index = type.lastIndexOf("_");
if (index > 0) {
const actionType = type.substring(index + 1);
if (RESET_ACTIONTYPES.indexOf(actionType) >= 0 || action.resetPending) {
let identifier = type.substring(0, index);
if (action.itemId) {
identifier += "/" + action.itemId;
}
return removeFromState(state, identifier);
}
}
}
return state;
}
export function isPending(
state: Object,
actionType: string,
itemId?: string | number
) {
let type = actionType;
if (itemId) {
type += "/" + itemId;
}
if (state.pending && state.pending[type]) {
return true;
}
return false;
}

View File

@@ -0,0 +1,143 @@
import reducer, { isPending } from "./pending";
describe("pending reducer", () => {
it("should set pending for FETCH_ITEMS to true", () => {
const newState = reducer({}, { type: "FETCH_ITEMS_PENDING" });
expect(newState["FETCH_ITEMS"]).toBe(true);
});
it("should do nothing for unknown action types", () => {
const state = {};
const newState = reducer(state, { type: "UNKNOWN" });
expect(newState).toBe(state);
});
it("should set pending for FETCH_ITEMS to true, but should not affect others", () => {
const newState = reducer(
{
FETCH_USERS: true
},
{ type: "FETCH_ITEMS_PENDING" }
);
expect(newState["FETCH_ITEMS"]).toBe(true);
expect(newState["FETCH_USERS"]).toBe(true);
});
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS", () => {
const newState = reducer(
{
FETCH_ITEMS: true
},
{ type: "FETCH_ITEMS_SUCCESS" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_FAILURE", () => {
const newState = reducer(
{
FETCH_ITEMS: true
},
{ type: "FETCH_ITEMS_FAILURE" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_RESET", () => {
const newState = reducer(
{
FETCH_ITEMS: true
},
{ type: "FETCH_ITEMS_RESET" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset pending state for FETCH_ITEMS, if resetPending prop is available", () => {
const newState = reducer(
{
FETCH_ITEMS: true
},
{ type: "FETCH_ITEMS_SOMETHING", resetPending: true }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
});
it("should reset pending state for FETCH_ITEMS after FETCH_ITEMS_SUCCESS, but should not affect others", () => {
const newState = reducer(
{
FETCH_USERS: true,
FETCH_ITEMS: true
},
{ type: "FETCH_ITEMS_SUCCESS" }
);
expect(newState["FETCH_ITEMS"]).toBeFalsy();
expect(newState["FETCH_USERS"]).toBe(true);
});
it("should set pending for a single item", () => {
const newState = reducer(
{
"FETCH_USER/42": false
},
{ type: "FETCH_USER_PENDING", itemId: 21 }
);
expect(newState["FETCH_USER/21"]).toBe(true);
expect(newState["FETCH_USER/42"]).toBe(false);
});
it("should reset pending for a single item", () => {
const newState = reducer(
{
"FETCH_USER/42": true
},
{ type: "FETCH_USER_SUCCESS", itemId: 42 }
);
expect(newState["FETCH_USER/42"]).toBeFalsy();
});
});
describe("pending selectors", () => {
it("should return true, while FETCH_ITEMS is pending", () => {
const result = isPending(
{
pending: {
FETCH_ITEMS: true
}
},
"FETCH_ITEMS"
);
expect(result).toBe(true);
});
it("should return false, if pending is not defined", () => {
const result = isPending({}, "FETCH_ITEMS");
expect(result).toBe(false);
});
it("should return true, while FETCH_ITEM 42 is pending", () => {
const result = isPending(
{
pending: {
"FETCH_ITEM/42": true
}
},
"FETCH_ITEM",
42
);
expect(result).toBe(true);
});
it("should return true, while FETCH_ITEM 42 is undefined", () => {
const result = isPending(
{
pending: {
"FETCH_ITEM/21": true
}
},
"FETCH_ITEM",
42
);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,4 @@
export const PENDING_SUFFIX = "PENDING";
export const SUCCESS_SUFFIX = "SUCCESS";
export const FAILURE_SUFFIX = "FAILURE";
export const RESET_SUFFIX = "RESET";

View File

@@ -0,0 +1,117 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
});
}
}
function registerValidSW(swUrl) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

View File

@@ -0,0 +1,24 @@
// @flow
import React from "react";
import { Page } from "../../components/layout";
import { translate } from "react-i18next";
type Props = {
t: string => string
};
class Repositories extends React.Component<Props> {
render() {
const { t } = this.props;
return (
<Page
title={t("repositories.title")}
subtitle={t("repositories.subtitle")}
>
{t("repositories.body")}
</Page>
);
}
}
export default translate("repositories")(Repositories);

View File

@@ -0,0 +1,12 @@
import "raf/polyfill";
import { configure } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
// Temporary hack to suppress error
// https://github.com/facebook/create-react-app/issues/3199#issuecomment-345024029
window.requestAnimationFrame = function(callback) {
setTimeout(callback, 0);
return 0;
};
configure({ adapter: new Adapter() });

7
scm-ui/src/tests/i18n.js Normal file
View File

@@ -0,0 +1,7 @@
jest.mock("react-i18next", () => ({
// this mock makes sure any components using the translate HoC receive the t function as a prop
translate: () => Component => {
Component.defaultProps = { ...Component.defaultProps, t: key => key };
return Component;
}
}));

View File

@@ -0,0 +1,7 @@
// @flow
export type Action = {
type: string,
payload?: any,
itemId?: string | number,
resetPending?: boolean
};

View File

@@ -0,0 +1,12 @@
// @flow
import type { Links } from "./hal";
export type Collection = {
_embedded: Object,
_links: Links
};
export type PagedCollection = Collection & {
page: number,
pageTotal: number
};

6
scm-ui/src/types/Me.js Normal file
View File

@@ -0,0 +1,6 @@
// @flow
export type Me = {
name: string,
displayName: string
};

6
scm-ui/src/types/hal.js Normal file
View File

@@ -0,0 +1,6 @@
// @flow
export type Link = {
href: string
};
export type Links = { [string]: Link };

View File

@@ -0,0 +1,208 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "../types/User";
import { Checkbox, InputField } from "../../components/forms";
import { SubmitButton } from "../../components/buttons";
import * as validator from "./userValidation";
type Props = {
submitForm: User => void,
user?: User,
loading?: boolean,
t: string => string
};
type State = {
user: User,
mailValidationError: boolean,
nameValidationError: boolean,
displayNameValidationError: boolean,
passwordValidationError: boolean,
validatePasswordError: boolean,
validatePassword: string
};
class UserForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
user: {
name: "",
displayName: "",
mail: "",
password: "",
admin: false,
active: false,
_links: {}
},
mailValidationError: false,
displayNameValidationError: false,
nameValidationError: false,
passwordValidationError: false,
validatePasswordError: false,
validatePassword: ""
};
}
componentDidMount() {
const { user } = this.props;
if (user) {
this.setState({ user: { ...user } });
}
}
isFalsy(value) {
if (!value) {
return true;
}
return false;
}
isValid = () => {
const user = this.state.user;
return !(
this.state.validatePasswordError ||
this.state.nameValidationError ||
this.state.mailValidationError ||
this.state.passwordValidationError ||
this.state.displayNameValidationError ||
this.isFalsy(user.name) ||
this.isFalsy(user.displayName)
);
};
submit = (event: Event) => {
event.preventDefault();
if (this.isValid()) {
this.props.submitForm(this.state.user);
}
};
render() {
const { loading, t } = this.props;
const user = this.state.user;
let nameField = null;
if (!this.props.user) {
nameField = (
<InputField
label={t("user.name")}
onChange={this.handleUsernameChange}
value={user ? user.name : ""}
validationError={this.state.nameValidationError}
errorMessage={t("validation.name-invalid")}
/>
);
}
return (
<form onSubmit={this.submit}>
{nameField}
<InputField
label={t("user.displayName")}
onChange={this.handleDisplayNameChange}
value={user ? user.displayName : ""}
validationError={this.state.displayNameValidationError}
errorMessage={t("validation.displayname-invalid")}
/>
<InputField
label={t("user.mail")}
onChange={this.handleEmailChange}
value={user ? user.mail : ""}
validationError={this.state.mailValidationError}
errorMessage={t("validation.mail-invalid")}
/>
<InputField
label={t("user.password")}
type="password"
onChange={this.handlePasswordChange}
value={user ? user.password : ""}
validationError={this.state.validatePasswordError}
errorMessage={t("validation.password-invalid")}
/>
<InputField
label={t("validation.validatePassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.validatePassword : ""}
validationError={this.state.passwordValidationError}
errorMessage={t("validation.passwordValidation-invalid")}
/>
<Checkbox
label={t("user.admin")}
onChange={this.handleAdminChange}
checked={user ? user.admin : false}
/>
<Checkbox
label={t("user.active")}
onChange={this.handleActiveChange}
checked={user ? user.active : false}
/>
<SubmitButton
disabled={!this.isValid()}
loading={loading}
label={t("user-form.submit")}
/>
</form>
);
}
handleUsernameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),
user: { ...this.state.user, name }
});
};
handleDisplayNameChange = (displayName: string) => {
this.setState({
displayNameValidationError: !validator.isDisplayNameValid(displayName),
user: { ...this.state.user, displayName }
});
};
handleEmailChange = (mail: string) => {
this.setState({
mailValidationError: !validator.isMailValid(mail),
user: { ...this.state.user, mail }
});
};
handlePasswordChange = (password: string) => {
const validatePasswordError = !this.checkPasswords(
password,
this.state.validatePassword
);
this.setState({
validatePasswordError: !validator.isPasswordValid(password),
passwordValidationError: validatePasswordError,
user: { ...this.state.user, password }
});
};
handlePasswordValidationChange = (validatePassword: string) => {
const validatePasswordError = this.checkPasswords(
this.state.user.password,
validatePassword
);
this.setState({
validatePassword,
passwordValidationError: !validatePasswordError
});
};
checkPasswords = (password1: string, password2: string) => {
return password1 === password2;
};
handleAdminChange = (admin: boolean) => {
this.setState({ user: { ...this.state.user, admin } });
};
handleActiveChange = (active: boolean) => {
this.setState({ user: { ...this.state.user, active } });
};
}
export default translate("users")(UserForm);

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import injectSheet from "react-jss";
import { translate } from "react-i18next";
import { AddButton } from "../../../components/buttons";
import classNames from "classnames";
const styles = {
spacing: {
margin: "1em 0 0 1em"
}
};
type Props = {
t: string => string,
classes: any
};
class CreateUserButton extends React.Component<Props> {
render() {
const { classes, t } = this.props;
return (
<div className={classNames("is-pulled-right", classes.spacing)}>
<AddButton label={t("create-user-button.label")} link="/users/add" />
</div>
);
}
}
export default translate("users")(injectSheet(styles)(CreateUserButton));

View File

@@ -0,0 +1,57 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "../../types/User";
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
import { NavAction } from "../../../components/navigation";
type Props = {
user: User,
confirmDialog?: boolean,
t: string => string,
deleteUser: (user: User) => void
};
class DeleteUserNavLink extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
deleteUser = () => {
this.props.deleteUser(this.props.user);
};
confirmDelete = () => {
const { t } = this.props;
confirmAlert({
title: t("delete-user-button.confirm-alert.title"),
message: t("delete-user-button.confirm-alert.message"),
buttons: [
{
label: t("delete-user-button.confirm-alert.submit"),
onClick: () => this.deleteUser()
},
{
label: t("delete-user-button.confirm-alert.cancel"),
onClick: () => null
}
]
});
};
isDeletable = () => {
return this.props.user._links.delete;
};
render() {
const { confirmDialog, t } = this.props;
const action = confirmDialog ? this.confirmDelete : this.deleteUser;
if (!this.isDeletable()) {
return null;
}
return <NavAction label={t("delete-user-button.label")} action={action} />;
}
}
export default translate("users")(DeleteUserNavLink);

View File

@@ -0,0 +1,79 @@
import React from "react";
import { mount, shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import DeleteUserNavLink from "./DeleteUserNavLink";
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
jest.mock("../../../components/modals/ConfirmAlert");
describe("DeleteUserNavLink", () => {
it("should render nothing, if the delete link is missing", () => {
const user = {
_links: {}
};
const navLink = shallow(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on navLink click", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
it("should call the delete user function with delete url", () => {
const user = {
_links: {
delete: {
href: "/users"
}
}
};
let calledUrl = null;
function capture(user) {
calledUrl = user._links.delete.href;
}
const navLink = mount(
<DeleteUserNavLink
user={user}
confirmDialog={false}
deleteUser={capture}
/>
);
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/users");
});
});

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { User } from "../../types/User";
import { NavLink } from "../../../components/navigation";
type Props = {
t: string => string,
user: User,
editUrl: String
};
class EditUserNavLink extends React.Component<Props> {
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink label={t("edit-user-button.label")} to={editUrl} />;
}
isEditable = () => {
return this.props.user._links.update;
};
}
export default translate("users")(EditUserNavLink);

View File

@@ -0,0 +1,27 @@
import React from "react";
import { shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import EditUserNavLink from "./EditUserNavLink";
it("should render nothing, if the edit link is missing", () => {
const user = {
_links: {}
};
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const user = {
_links: {
update: {
href: "/users"
}
}
};
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
expect(navLink.text()).not.toBe("");
});

View File

@@ -0,0 +1,2 @@
export { default as DeleteUserNavLink } from "./DeleteUserNavLink";
export { default as EditUserNavLink } from "./EditUserNavLink";

View File

@@ -0,0 +1,48 @@
//@flow
import React from "react";
import type { User } from "../../types/User";
import { translate } from "react-i18next";
import { Checkbox } from "../../../components/forms";
type Props = {
user: User,
t: string => string
};
class Details extends React.Component<Props> {
render() {
const { user, t } = this.props;
return (
<table className="table">
<tbody>
<tr>
<td>{t("user.name")}</td>
<td>{user.name}</td>
</tr>
<tr>
<td>{t("user.displayName")}</td>
<td>{user.displayName}</td>
</tr>
<tr>
<td>{t("user.mail")}</td>
<td>{user.mail}</td>
</tr>
<tr>
<td>{t("user.admin")}</td>
<td>
<Checkbox checked={user.admin} />
</td>
</tr>
<tr>
<td>{t("user.active")}</td>
<td>
<Checkbox checked={user.active} />
</td>
</tr>
</tbody>
</table>
);
}
}
export default translate("users")(Details);

View File

@@ -0,0 +1,31 @@
// @flow
import React from "react";
import { Link } from "react-router-dom";
import type { User } from "../../types/User";
type Props = {
user: User
};
export default class UserRow extends React.Component<Props> {
renderLink(to: string, label: string) {
return <Link to={to}>{label}</Link>;
}
render() {
const { user } = this.props;
const to = `/user/${user.name}`;
return (
<tr>
<td className="is-hidden-mobile">{this.renderLink(to, user.name)}</td>
<td>{this.renderLink(to, user.displayName)}</td>
<td>
<a href={`mailto: ${user.mail}`}>{user.mail}</a>
</td>
<td className="is-hidden-mobile">
<input type="checkbox" id="admin" checked={user.admin} readOnly />
</td>
</tr>
);
}
}

View File

@@ -0,0 +1,35 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import UserRow from "./UserRow";
import type { User } from "../../types/User";
type Props = {
t: string => string,
users: User[]
};
class UserTable extends React.Component<Props> {
render() {
const { users, t } = this.props;
return (
<table className="table is-hoverable is-fullwidth">
<thead>
<tr>
<th className="is-hidden-mobile">{t("user.name")}</th>
<th>{t("user.displayName")}</th>
<th>{t("user.mail")}</th>
<th className="is-hidden-mobile">{t("user.admin")}</th>
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return <UserRow key={index} user={user} />;
})}
</tbody>
</table>
);
}
}
export default translate("users")(UserTable);

View File

@@ -0,0 +1,3 @@
export { default as Details } from "./Details";
export { default as UserRow } from "./UserRow";
export { default as UserTable } from "./UserTable";

View File

@@ -0,0 +1,24 @@
// @flow
const nameRegex = /^([A-z0-9.\-_@]|[^ ]([A-z0-9.\-_@ ]*[A-z0-9.\-_@]|[^\s])?)$/;
export const isNameValid = (name: string) => {
return nameRegex.test(name);
};
export const isDisplayNameValid = (displayName: string) => {
if (displayName) {
return true;
}
return false;
};
const mailRegex = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-.]*\.[A-z0-9][A-z0-9-]+$/;
export const isMailValid = (mail: string) => {
return mailRegex.test(mail);
};
export const isPasswordValid = (password: string) => {
return password.length > 6 && password.length < 32;
};

View File

@@ -0,0 +1,114 @@
// @flow
import * as validator from "./userValidation";
describe("test name validation", () => {
it("should return false", () => {
// invalid names taken from ValidationUtilTest.java
const invalidNames = [
" test 123",
" test 123 ",
"test 123 ",
"test/123",
"test%123",
"test:123",
"t ",
" t",
" t ",
""
];
for (let name of invalidNames) {
expect(validator.isNameValid(name)).toBe(false);
}
});
it("should return true", () => {
// valid names taken from ValidationUtilTest.java
const validNames = [
"test",
"test.git",
"Test123.git",
"Test123-git",
"Test_user-123.git",
"test@scm-manager.de",
"test 123",
"tt",
"t"
];
for (let name of validNames) {
expect(validator.isNameValid(name)).toBe(true);
}
});
});
describe("test displayName validation", () => {
it("should return false", () => {
expect(validator.isDisplayNameValid("")).toBe(false);
});
it("should return true", () => {
// valid names taken from ValidationUtilTest.java
const validNames = [
"Arthur Dent",
"Tricia.McMillan@hitchhiker.com",
"Ford Prefect (ford.prefect@hitchhiker.com)",
"Zaphod Beeblebrox <zaphod.beeblebrox@hitchhiker.com>",
"Marvin, der depressive Roboter"
];
for (let name of validNames) {
expect(validator.isDisplayNameValid(name)).toBe(true);
}
});
});
describe("test mail validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java
const invalid = [
"ostfalia.de",
"@ostfalia.de",
"s.sdorra@",
"s.sdorra@ostfalia",
"s.sdorra@@ostfalia.de",
"s.sdorra@ ostfalia.de",
"s.sdorra @ostfalia.de"
];
for (let mail of invalid) {
expect(validator.isMailValid(mail)).toBe(false);
}
});
it("should return true", () => {
// valid taken from ValidationUtilTest.java
const valid = [
"s.sdorra@ostfalia.de",
"sdorra@ostfalia.de",
"s.sdorra@hbk-bs.de",
"s.sdorra@gmail.com",
"s.sdorra@t.co",
"s.sdorra@ucla.college",
"s.sdorra@example.xn--p1ai",
"s.sdorra@scm.solutions"
];
for (let mail of valid) {
expect(validator.isMailValid(mail)).toBe(true);
}
});
});
describe("test password validation", () => {
it("should return false", () => {
// invalid taken from ValidationUtilTest.java
const invalid = ["", "abc", "aaabbbcccdddeeefffggghhhiiijjjkkk"];
for (let password of invalid) {
expect(validator.isPasswordValid(password)).toBe(false);
}
});
it("should return true", () => {
// valid taken from ValidationUtilTest.java
const valid = ["secret123", "mySuperSecretPassword"];
for (let password of valid) {
expect(validator.isPasswordValid(password)).toBe(true);
}
});
});

View File

@@ -0,0 +1,84 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import UserForm from "./../components/UserForm";
import type { User } from "../types/User";
import type { History } from "history";
import {
createUser,
createUserReset,
isCreateUserPending,
getCreateUserFailure
} from "../modules/users";
import { Page } from "../../components/layout";
import { translate } from "react-i18next";
type Props = {
loading?: boolean,
error?: Error,
// dispatcher functions
addUser: (user: User, callback?: () => void) => void,
resetForm: () => void,
// context objects
t: string => string,
history: History
};
class AddUser extends React.Component<Props> {
componentDidMount() {
this.props.resetForm();
}
userCreated = () => {
const { history } = this.props;
history.push("/users");
};
createUser = (user: User) => {
this.props.addUser(user, this.userCreated);
};
render() {
const { t, loading, error } = this.props;
return (
<Page
title={t("add-user.title")}
subtitle={t("add-user.subtitle")}
error={error}
>
<UserForm
submitForm={user => this.createUser(user)}
loading={loading}
/>
</Page>
);
}
}
const mapDispatchToProps = dispatch => {
return {
addUser: (user: User, callback?: () => void) => {
dispatch(createUser(user, callback));
},
resetForm: () => {
dispatch(createUserReset());
}
};
};
const mapStateToProps = (state, ownProps) => {
const loading = isCreateUserPending(state);
const error = getCreateUserFailure(state);
return {
loading,
error
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("users")(AddUser));

View File

@@ -0,0 +1,71 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { withRouter } from "react-router-dom";
import UserForm from "./../components/UserForm";
import type { User } from "../types/User";
import {
modifyUser,
isModifyUserPending,
getModifyUserFailure
} from "../modules/users";
import type { History } from "history";
import ErrorNotification from "../../components/ErrorNotification";
type Props = {
loading: boolean,
error: Error,
// dispatch functions
modifyUser: (user: User, callback?: () => void) => void,
// context objects
user: User,
history: History
};
class EditUser extends React.Component<Props> {
userModified = (user: User) => () => {
this.props.history.push(`/user/${user.name}`);
};
modifyUser = (user: User) => {
this.props.modifyUser(user, this.userModified(user));
};
render() {
const { user, loading, error } = this.props;
return (
<div>
<ErrorNotification error={error} />
<UserForm
submitForm={user => this.modifyUser(user)}
user={user}
loading={loading}
/>
</div>
);
}
}
const mapDispatchToProps = dispatch => {
return {
modifyUser: (user: User, callback?: () => void) => {
dispatch(modifyUser(user, callback));
}
};
};
const mapStateToProps = (state, ownProps) => {
const loading = isModifyUserPending(state, ownProps.user.name);
const error = getModifyUserFailure(state, ownProps.user.name);
return {
loading,
error
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(withRouter(EditUser));

View File

@@ -0,0 +1,146 @@
//@flow
import React from "react";
import { connect } from "react-redux";
import { Page } from "../../components/layout";
import { Route } from "react-router";
import { Details } from "./../components/table";
import EditUser from "./EditUser";
import type { User } from "../types/User";
import type { History } from "history";
import {
fetchUser,
deleteUser,
getUserByName,
isFetchUserPending,
getFetchUserFailure,
isDeleteUserPending,
getDeleteUserFailure
} from "../modules/users";
import Loading from "../../components/Loading";
import { Navigation, Section, NavLink } from "../../components/navigation";
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks";
import ErrorPage from "../../components/ErrorPage";
import { translate } from "react-i18next";
type Props = {
name: string,
user: User,
loading: boolean,
error: Error,
// dispatcher functions
deleteUser: (user: User, callback?: () => void) => void,
fetchUser: string => void,
// context objects
t: string => string,
match: any,
history: History
};
class SingleUser extends React.Component<Props> {
componentDidMount() {
this.props.fetchUser(this.props.name);
}
userDeleted = () => {
this.props.history.push("/users");
};
deleteUser = (user: User) => {
this.props.deleteUser(user, this.userDeleted);
};
stripEndingSlash = (url: string) => {
if (url.endsWith("/")) {
return url.substring(0, url.length - 2);
}
return url;
};
matchedUrl = () => {
return this.stripEndingSlash(this.props.match.url);
};
render() {
const { t, loading, error, user } = this.props;
if (error) {
return (
<ErrorPage
title={t("single-user.error-title")}
subtitle={t("single-user.error-subtitle")}
error={error}
/>
);
}
if (!user || loading) {
return <Loading />;
}
const url = this.matchedUrl();
return (
<Page title={user.displayName}>
<div className="columns">
<div className="column is-three-quarters">
<Route path={url} exact component={() => <Details user={user} />} />
<Route
path={`${url}/edit`}
component={() => <EditUser user={user} />}
/>
</div>
<div className="column">
<Navigation>
<Section label={t("single-user.navigation-label")}>
<NavLink
to={`${url}`}
label={t("single-user.information-label")}
/>
<EditUserNavLink user={user} editUrl={`${url}/edit`} />
</Section>
<Section label={t("single-user.actions-label")}>
<DeleteUserNavLink user={user} deleteUser={this.deleteUser} />
<NavLink to="/users" label={t("single-user.back-label")} />
</Section>
</Navigation>
</div>
</div>
</Page>
);
}
}
const mapStateToProps = (state, ownProps) => {
const name = ownProps.match.params.name;
const user = getUserByName(state, name);
const loading =
isFetchUserPending(state, name) || isDeleteUserPending(state, name);
const error =
getFetchUserFailure(state, name) || getDeleteUserFailure(state, name);
return {
name,
user,
loading,
error
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUser: (name: string) => {
dispatch(fetchUser(name));
},
deleteUser: (user: User, callback?: () => void) => {
dispatch(deleteUser(user, callback));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("users")(SingleUser));

View File

@@ -0,0 +1,140 @@
// @flow
import React from "react";
import type { History } from "history";
import { connect } from "react-redux";
import { translate } from "react-i18next";
import {
fetchUsersByPage,
fetchUsersByLink,
getUsersFromState,
selectListAsCollection,
isPermittedToCreateUsers,
isFetchUsersPending,
getFetchUsersFailure
} from "../modules/users";
import { Page } from "../../components/layout";
import { UserTable } from "./../components/table";
import type { User } from "../types/User";
import type { PagedCollection } from "../../types/Collection";
import Paginator from "../../components/Paginator";
import CreateUserButton from "../components/buttons/CreateUserButton";
type Props = {
users: User[],
loading: boolean,
error: Error,
canAddUsers: boolean,
list: PagedCollection,
page: number,
// context objects
t: string => string,
history: History,
// dispatch functions
fetchUsersByPage: (page: number) => void,
fetchUsersByLink: (link: string) => void
};
class Users extends React.Component<Props> {
componentDidMount() {
this.props.fetchUsersByPage(this.props.page);
}
onPageChange = (link: string) => {
this.props.fetchUsersByLink(link);
};
/**
* reflect page transitions in the uri
*/
componentDidUpdate = (prevProps: Props) => {
const { page, list } = this.props;
if (list.page) {
// backend starts paging by 0
const statePage: number = list.page + 1;
if (page !== statePage) {
this.props.history.push(`/users/${statePage}`);
}
}
};
render() {
const { users, loading, error, t } = this.props;
return (
<Page
title={t("users.title")}
subtitle={t("users.subtitle")}
loading={loading || !users}
error={error}
>
<UserTable users={users} />
{this.renderPaginator()}
{this.renderCreateButton()}
</Page>
);
}
renderPaginator() {
const { list } = this.props;
if (list) {
return <Paginator collection={list} onPageChange={this.onPageChange} />;
}
return null;
}
renderCreateButton() {
if (this.props.canAddUsers) {
return <CreateUserButton />;
} else {
return;
}
}
}
const getPageFromProps = props => {
let page = props.match.params.page;
if (page) {
page = parseInt(page, 10);
} else {
page = 1;
}
return page;
};
const mapStateToProps = (state, ownProps) => {
const users = getUsersFromState(state);
const loading = isFetchUsersPending(state);
const error = getFetchUsersFailure(state);
const page = getPageFromProps(ownProps);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
return {
users,
loading,
error,
canAddUsers,
list,
page
};
};
const mapDispatchToProps = dispatch => {
return {
fetchUsersByPage: (page: number) => {
dispatch(fetchUsersByPage(page));
},
fetchUsersByLink: (link: string) => {
dispatch(fetchUsersByLink(link));
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(translate("users")(Users));

View File

@@ -0,0 +1,479 @@
// @flow
import { apiClient } from "../../apiclient";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import * as types from "../../modules/types";
import type { User } from "../types/User";
import { combineReducers, Dispatch } from "redux";
import type { Action } from "../../types/Action";
import type { PagedCollection } from "../../types/Collection";
export const FETCH_USERS = "scm/users/FETCH_USERS";
export const FETCH_USERS_PENDING = `${FETCH_USERS}_${types.PENDING_SUFFIX}`;
export const FETCH_USERS_SUCCESS = `${FETCH_USERS}_${types.SUCCESS_SUFFIX}`;
export const FETCH_USERS_FAILURE = `${FETCH_USERS}_${types.FAILURE_SUFFIX}`;
export const FETCH_USER = "scm/users/FETCH_USER";
export const FETCH_USER_PENDING = `${FETCH_USER}_${types.PENDING_SUFFIX}`;
export const FETCH_USER_SUCCESS = `${FETCH_USER}_${types.SUCCESS_SUFFIX}`;
export const FETCH_USER_FAILURE = `${FETCH_USER}_${types.FAILURE_SUFFIX}`;
export const CREATE_USER = "scm/users/CREATE_USER";
export const CREATE_USER_PENDING = `${CREATE_USER}_${types.PENDING_SUFFIX}`;
export const CREATE_USER_SUCCESS = `${CREATE_USER}_${types.SUCCESS_SUFFIX}`;
export const CREATE_USER_FAILURE = `${CREATE_USER}_${types.FAILURE_SUFFIX}`;
export const CREATE_USER_RESET = `${CREATE_USER}_${types.RESET_SUFFIX}`;
export const MODIFY_USER = "scm/users/MODIFY_USER";
export const MODIFY_USER_PENDING = `${MODIFY_USER}_${types.PENDING_SUFFIX}`;
export const MODIFY_USER_SUCCESS = `${MODIFY_USER}_${types.SUCCESS_SUFFIX}`;
export const MODIFY_USER_FAILURE = `${MODIFY_USER}_${types.FAILURE_SUFFIX}`;
export const DELETE_USER = "scm/users/DELETE";
export const DELETE_USER_PENDING = `${DELETE_USER}_${types.PENDING_SUFFIX}`;
export const DELETE_USER_SUCCESS = `${DELETE_USER}_${types.SUCCESS_SUFFIX}`;
export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`;
const USERS_URL = "users";
const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
// TODO i18n for error messages
// fetch users
export function fetchUsers() {
return fetchUsersByLink(USERS_URL);
}
export function fetchUsersByPage(page: number) {
// backend start counting by 0
return fetchUsersByLink(USERS_URL + "?page=" + (page - 1));
}
export function fetchUsersByLink(link: string) {
return function(dispatch: any) {
dispatch(fetchUsersPending());
return apiClient
.get(link)
.then(response => response.json())
.then(data => {
dispatch(fetchUsersSuccess(data));
})
.catch(cause => {
const error = new Error(`could not fetch users: ${cause.message}`);
dispatch(fetchUsersFailure(USERS_URL, error));
});
};
}
export function fetchUsersPending(): Action {
return {
type: FETCH_USERS_PENDING
};
}
export function fetchUsersSuccess(users: any): Action {
return {
type: FETCH_USERS_SUCCESS,
payload: users
};
}
export function fetchUsersFailure(url: string, error: Error): Action {
return {
type: FETCH_USERS_FAILURE,
payload: {
error,
url
}
};
}
//fetch user
export function fetchUser(name: string) {
const userUrl = USERS_URL + "/" + name;
return function(dispatch: any) {
dispatch(fetchUserPending(name));
return apiClient
.get(userUrl)
.then(response => {
return response.json();
})
.then(data => {
dispatch(fetchUserSuccess(data));
})
.catch(cause => {
const error = new Error(`could not fetch user: ${cause.message}`);
dispatch(fetchUserFailure(name, error));
});
};
}
export function fetchUserPending(name: string): Action {
return {
type: FETCH_USER_PENDING,
payload: name,
itemId: name
};
}
export function fetchUserSuccess(user: any): Action {
return {
type: FETCH_USER_SUCCESS,
payload: user,
itemId: user.name
};
}
export function fetchUserFailure(name: string, error: Error): Action {
return {
type: FETCH_USER_FAILURE,
payload: {
name,
error
},
itemId: name
};
}
//create user
export function createUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(createUserPending(user));
return apiClient
.postWithContentType(USERS_URL, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(createUserSuccess());
if (callback) {
callback();
}
})
.catch(err =>
dispatch(
createUserFailure(
new Error(`failed to add user ${user.name}: ${err.message}`)
)
)
);
};
}
export function createUserPending(user: User): Action {
return {
type: CREATE_USER_PENDING,
user
};
}
export function createUserSuccess(): Action {
return {
type: CREATE_USER_SUCCESS
};
}
export function createUserFailure(error: Error): Action {
return {
type: CREATE_USER_FAILURE,
payload: error
};
}
export function createUserReset() {
return {
type: CREATE_USER_RESET
};
}
//modify user
export function modifyUser(user: User, callback?: () => void) {
return function(dispatch: Dispatch) {
dispatch(modifyUserPending(user));
return apiClient
.putWithContentType(user._links.update.href, user, CONTENT_TYPE_USER)
.then(() => {
dispatch(modifyUserSuccess(user));
if (callback) {
callback();
}
})
.catch(err => {
dispatch(modifyUserFailure(user, err));
});
};
}
export function modifyUserPending(user: User): Action {
return {
type: MODIFY_USER_PENDING,
payload: user,
itemId: user.name
};
}
export function modifyUserSuccess(user: User): Action {
return {
type: MODIFY_USER_SUCCESS,
payload: user,
itemId: user.name
};
}
export function modifyUserFailure(user: User, error: Error): Action {
return {
type: MODIFY_USER_FAILURE,
payload: {
error,
user
},
itemId: user.name
};
}
//delete user
export function deleteUser(user: User, callback?: () => void) {
return function(dispatch: any) {
dispatch(deleteUserPending(user));
return apiClient
.delete(user._links.delete.href)
.then(() => {
dispatch(deleteUserSuccess(user));
if (callback) {
callback();
}
})
.catch(cause => {
const error = new Error(
`could not delete user ${user.name}: ${cause.message}`
);
dispatch(deleteUserFailure(user, error));
});
};
}
export function deleteUserPending(user: User): Action {
return {
type: DELETE_USER_PENDING,
payload: user,
itemId: user.name
};
}
export function deleteUserSuccess(user: User): Action {
return {
type: DELETE_USER_SUCCESS,
payload: user,
itemId: user.name
};
}
export function deleteUserFailure(user: User, error: Error): Action {
return {
type: DELETE_USER_FAILURE,
payload: {
error,
user
},
itemId: user.name
};
}
function extractUsersByNames(
users: User[],
userNames: string[],
oldUsersByNames: Object
) {
const usersByNames = {};
for (let user of users) {
usersByNames[user.name] = user;
}
for (let userName in oldUsersByNames) {
usersByNames[userName] = oldUsersByNames[userName];
}
return usersByNames;
}
function deleteUserInUsersByNames(users: {}, userName: string) {
let newUsers = {};
for (let username in users) {
if (username !== userName) newUsers[username] = users[username];
}
return newUsers;
}
function deleteUserInEntries(users: [], userName: string) {
let newUsers = [];
for (let user of users) {
if (user !== userName) newUsers.push(user);
}
return newUsers;
}
const reducerByName = (state: any, username: string, newUserState: any) => {
const newUsersByNames = {
...state,
[username]: newUserState
};
return newUsersByNames;
};
function listReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case FETCH_USERS_SUCCESS:
const users = action.payload._embedded.users;
const userNames = users.map(user => user.name);
return {
...state,
entries: userNames,
entry: {
userCreatePermission: action.payload._links.create ? true : false,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}
};
// Delete single user actions
case DELETE_USER_SUCCESS:
const newUserEntries = deleteUserInEntries(
state.entries,
action.payload.name
);
return {
...state,
entries: newUserEntries
};
default:
return state;
}
}
function byNamesReducer(state: any = {}, action: any = {}) {
switch (action.type) {
// Fetch all users actions
case FETCH_USERS_SUCCESS:
const users = action.payload._embedded.users;
const userNames = users.map(user => user.name);
const byNames = extractUsersByNames(users, userNames, state.byNames);
return {
...byNames
};
// Fetch single user actions
case FETCH_USER_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case MODIFY_USER_SUCCESS:
return reducerByName(state, action.payload.name, action.payload);
case DELETE_USER_SUCCESS:
const newUserByNames = deleteUserInUsersByNames(
state,
action.payload.name
);
return newUserByNames;
default:
return state;
}
}
export default combineReducers({
list: listReducer,
byNames: byNamesReducer
});
// selectors
const selectList = (state: Object) => {
if (state.users && state.users.list) {
return state.users.list;
}
return {};
};
const selectListEntry = (state: Object): Object => {
const list = selectList(state);
if (list.entry) {
return list.entry;
}
return {};
};
export const selectListAsCollection = (state: Object): PagedCollection => {
return selectListEntry(state);
};
export const isPermittedToCreateUsers = (state: Object): boolean => {
const permission = selectListEntry(state).userCreatePermission;
if (permission) {
return true;
}
return false;
};
export function getUsersFromState(state: Object) {
const userNames = selectList(state).entries;
if (!userNames) {
return null;
}
const userEntries: User[] = [];
for (let userName of userNames) {
userEntries.push(state.users.byNames[userName]);
}
return userEntries;
}
export function isFetchUsersPending(state: Object) {
return isPending(state, FETCH_USERS);
}
export function getFetchUsersFailure(state: Object) {
return getFailure(state, FETCH_USERS);
}
export function isCreateUserPending(state: Object) {
return isPending(state, CREATE_USER);
}
export function getCreateUserFailure(state: Object) {
return getFailure(state, CREATE_USER);
}
export function getUserByName(state: Object, name: string) {
if (state.users && state.users.byNames) {
return state.users.byNames[name];
}
}
export function isFetchUserPending(state: Object, name: string) {
return isPending(state, FETCH_USER, name);
}
export function getFetchUserFailure(state: Object, name: string) {
return getFailure(state, FETCH_USER, name);
}
export function isModifyUserPending(state: Object, name: string) {
return isPending(state, MODIFY_USER, name);
}
export function getModifyUserFailure(state: Object, name: string) {
return getFailure(state, MODIFY_USER, name);
}
export function isDeleteUserPending(state: Object, name: string) {
return isPending(state, DELETE_USER, name);
}
export function getDeleteUserFailure(state: Object, name: string) {
return getFailure(state, DELETE_USER, name);
}

View File

@@ -0,0 +1,640 @@
//@flow
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import reducer, {
CREATE_USER_FAILURE,
CREATE_USER_PENDING,
CREATE_USER_SUCCESS,
createUser,
DELETE_USER_FAILURE,
DELETE_USER_PENDING,
DELETE_USER_SUCCESS,
deleteUser,
deleteUserSuccess,
FETCH_USER_FAILURE,
FETCH_USER_PENDING,
isFetchUserPending,
FETCH_USER_SUCCESS,
FETCH_USERS_FAILURE,
FETCH_USERS_PENDING,
FETCH_USERS_SUCCESS,
fetchUser,
fetchUserSuccess,
getFetchUserFailure,
fetchUsers,
fetchUsersSuccess,
isFetchUsersPending,
selectListAsCollection,
isPermittedToCreateUsers,
MODIFY_USER,
MODIFY_USER_FAILURE,
MODIFY_USER_PENDING,
MODIFY_USER_SUCCESS,
modifyUser,
modifyUserSuccess,
getUsersFromState,
FETCH_USERS,
getFetchUsersFailure,
FETCH_USER,
CREATE_USER,
isCreateUserPending,
getCreateUserFailure,
getUserByName,
isModifyUserPending,
getModifyUserFailure,
DELETE_USER,
isDeleteUserPending,
getDeleteUserFailure
} from "./users";
const userZaphod = {
active: true,
admin: true,
creationDate: "2018-07-11T12:23:49.027Z",
displayName: "Z. Beeblebrox",
mail: "president@heartofgold.universe",
name: "zaphod",
password: "__dummypassword__",
type: "xml",
properties: {},
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
},
update: {
href: "http://localhost:8081/scm/api/rest/v2/users/zaphod"
}
}
};
const userFord = {
active: true,
admin: false,
creationDate: "2018-07-06T13:21:18.459Z",
displayName: "F. Prefect",
mail: "ford@prefect.universe",
name: "ford",
password: "__dummypassword__",
type: "xml",
properties: {},
_links: {
self: {
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
},
delete: {
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
},
update: {
href: "http://localhost:8081/scm/api/rest/v2/users/ford"
}
}
};
const responseBody = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
first: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
last: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
create: {
href: "http://localhost:3000/scm/api/rest/v2/users/"
}
},
_embedded: {
users: [userZaphod, userFord]
}
};
const response = {
headers: { "content-type": "application/json" },
responseBody
};
const USERS_URL = "/scm/api/rest/v2/users";
const error = new Error("KAPUTT");
describe("users fetch()", () => {
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
it("should successfully fetch users", () => {
fetchMock.getOnce(USERS_URL, response);
const expectedActions = [
{ type: FETCH_USERS_PENDING },
{
type: FETCH_USERS_SUCCESS,
payload: response
}
];
const store = mockStore({});
return store.dispatch(fetchUsers()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should fail getting users on HTTP 500", () => {
fetchMock.getOnce(USERS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchUsers()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USERS_PENDING);
expect(actions[1].type).toEqual(FETCH_USERS_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should sucessfully fetch single user", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", userZaphod);
const store = mockStore({});
return store.dispatch(fetchUser("zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_SUCCESS);
expect(actions[1].payload).toBeDefined();
});
});
it("should fail fetching single user on HTTP 500", () => {
fetchMock.getOnce(USERS_URL + "/zaphod", {
status: 500
});
const store = mockStore({});
return store.dispatch(fetchUser("zaphod")).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_USER_PENDING);
expect(actions[1].type).toEqual(FETCH_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should add a user successfully", () => {
// unmatched
fetchMock.postOnce(USERS_URL, {
status: 204
});
// after create, the users are fetched again
fetchMock.getOnce(USERS_URL, response);
const store = mockStore({});
return store.dispatch(createUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_SUCCESS);
});
});
it("should fail adding a user on HTTP 500", () => {
fetchMock.postOnce(USERS_URL, {
status: 500
});
const store = mockStore({});
return store.dispatch(createUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(CREATE_USER_PENDING);
expect(actions[1].type).toEqual(CREATE_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should call the callback after user successfully created", () => {
// unmatched
fetchMock.postOnce(USERS_URL, {
status: 204
});
let callMe = "not yet";
const callback = () => {
callMe = "yeah";
};
const store = mockStore({});
return store.dispatch(createUser(userZaphod, callback)).then(() => {
expect(callMe).toBe("yeah");
});
});
it("successfully update user", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 204
});
const store = mockStore({});
return store.dispatch(modifyUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(MODIFY_USER_PENDING);
expect(actions[1].type).toEqual(MODIFY_USER_SUCCESS);
});
});
it("should call callback, after successful modified user", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 204
});
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(modifyUser(userZaphod, callMe)).then(() => {
expect(called).toBeTruthy();
});
});
it("should fail updating user on HTTP 500", () => {
fetchMock.putOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 500
});
const store = mockStore({});
return store.dispatch(modifyUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(MODIFY_USER_PENDING);
expect(actions[1].type).toEqual(MODIFY_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should delete successfully user zaphod", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 204
});
const store = mockStore({});
return store.dispatch(deleteUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
expect(actions[0].payload).toBe(userZaphod);
expect(actions[1].type).toEqual(DELETE_USER_SUCCESS);
});
});
it("should call the callback, after successful delete", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 204
});
let called = false;
const callMe = () => {
called = true;
};
const store = mockStore({});
return store.dispatch(deleteUser(userZaphod, callMe)).then(() => {
expect(called).toBeTruthy();
});
});
it("should fail to delete user zaphod", () => {
fetchMock.deleteOnce("http://localhost:8081/scm/api/rest/v2/users/zaphod", {
status: 500
});
const store = mockStore({});
return store.dispatch(deleteUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
expect(actions[0].payload).toBe(userZaphod);
expect(actions[1].type).toEqual(DELETE_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("users reducer", () => {
it("should update state correctly according to FETCH_USERS_SUCCESS action", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list).toEqual({
entries: ["zaphod", "ford"],
entry: {
userCreatePermission: true,
page: 0,
pageTotal: 1,
_links: responseBody._links
}
});
expect(newState.byNames).toEqual({
zaphod: userZaphod,
ford: userFord
});
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
it("should set userCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
it("should not replace whole byNames map when fetching users", () => {
const oldState = {
byNames: {
ford: userFord
}
};
const newState = reducer(oldState, fetchUsersSuccess(responseBody));
expect(newState.byNames["zaphod"]).toBeDefined();
expect(newState.byNames["ford"]).toBeDefined();
});
it("should remove user from state when delete succeeds", () => {
const state = {
list: {
entries: ["ford", "zaphod"]
},
byNames: {
zaphod: userZaphod,
ford: userFord
}
};
const newState = reducer(state, deleteUserSuccess(userFord));
expect(newState.byNames["zaphod"]).toBeDefined();
expect(newState.byNames["ford"]).toBeFalsy();
expect(newState.list.entries).toEqual(["zaphod"]);
});
it("should set userCreatePermission to true if create link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list.entry.userCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["zaphod", "ford"]);
expect(newState.byNames["ford"]).toBeTruthy();
expect(newState.byNames["zaphod"]).toBeTruthy();
});
it("should update state according to FETCH_USER_SUCCESS action", () => {
const newState = reducer({}, fetchUserSuccess(userFord));
expect(newState.byNames["ford"]).toBe(userFord);
});
it("should affect users state nor the state of other users", () => {
const newState = reducer(
{
list: {
entries: ["zaphod"]
}
},
fetchUserSuccess(userFord)
);
expect(newState.byNames["ford"]).toBe(userFord);
expect(newState.list.entries).toEqual(["zaphod"]);
});
it("should update state according to MODIFY_USER_SUCCESS action", () => {
const newState = reducer(
{
byNames: {
ford: {
name: "ford"
}
}
},
modifyUserSuccess(userFord)
);
expect(newState.byNames["ford"]).toBe(userFord);
});
});
describe("selector tests", () => {
it("should return an empty object", () => {
expect(selectListAsCollection({})).toEqual({});
expect(selectListAsCollection({ users: { a: "a" } })).toEqual({});
});
it("should return a state slice collection", () => {
const collection = {
page: 3,
totalPages: 42
};
const state = {
users: {
list: {
entry: collection
}
}
};
expect(selectListAsCollection(state)).toBe(collection);
});
it("should return false", () => {
expect(isPermittedToCreateUsers({})).toBe(false);
expect(isPermittedToCreateUsers({ users: { list: { entry: {} } } })).toBe(
false
);
expect(
isPermittedToCreateUsers({
users: { list: { entry: { userCreatePermission: false } } }
})
).toBe(false);
});
it("should return true", () => {
const state = {
users: {
list: {
entry: {
userCreatePermission: true
}
}
}
};
expect(isPermittedToCreateUsers(state)).toBe(true);
});
it("should get users from state", () => {
const state = {
users: {
list: {
entries: ["a", "b"]
},
byNames: {
a: { name: "a" },
b: { name: "b" }
}
}
};
expect(getUsersFromState(state)).toEqual([{ name: "a" }, { name: "b" }]);
});
it("should return true, when fetch users is pending", () => {
const state = {
pending: {
[FETCH_USERS]: true
}
};
expect(isFetchUsersPending(state)).toEqual(true);
});
it("should return false, when fetch users is not pending", () => {
expect(isFetchUsersPending({})).toEqual(false);
});
it("should return error when fetch users did fail", () => {
const state = {
failure: {
[FETCH_USERS]: error
}
};
expect(getFetchUsersFailure(state)).toEqual(error);
});
it("should return undefined when fetch users did not fail", () => {
expect(getFetchUsersFailure({})).toBe(undefined);
});
it("should return true if create user is pending", () => {
const state = {
pending: {
[CREATE_USER]: true
}
};
expect(isCreateUserPending(state)).toBe(true);
});
it("should return false if create user is not pending", () => {
const state = {
pending: {
[CREATE_USER]: false
}
};
expect(isCreateUserPending(state)).toBe(false);
});
it("should return error when create user did fail", () => {
const state = {
failure: {
[CREATE_USER]: error
}
};
expect(getCreateUserFailure(state)).toEqual(error);
});
it("should return undefined when create user did not fail", () => {
expect(getCreateUserFailure({})).toBe(undefined);
});
it("should return user ford", () => {
const state = {
users: {
byNames: {
ford: userFord
}
}
};
expect(getUserByName(state, "ford")).toEqual(userFord);
});
it("should return true, when fetch user zaphod is pending", () => {
const state = {
pending: {
[FETCH_USER + "/zaphod"]: true
}
};
expect(isFetchUserPending(state, "zaphod")).toEqual(true);
});
it("should return false, when fetch user zaphod is not pending", () => {
expect(isFetchUserPending({}, "zaphod")).toEqual(false);
});
it("should return error when fetch user zaphod did fail", () => {
const state = {
failure: {
[FETCH_USER + "/zaphod"]: error
}
};
expect(getFetchUserFailure(state, "zaphod")).toEqual(error);
});
it("should return undefined when fetch user zaphod did not fail", () => {
expect(getFetchUserFailure({}, "zaphod")).toBe(undefined);
});
it("should return true, when modify user ford is pending", () => {
const state = {
pending: {
[MODIFY_USER + "/ford"]: true
}
};
expect(isModifyUserPending(state, "ford")).toEqual(true);
});
it("should return false, when modify user ford is not pending", () => {
expect(isModifyUserPending({}, "ford")).toEqual(false);
});
it("should return error when modify user ford did fail", () => {
const state = {
failure: {
[MODIFY_USER + "/ford"]: error
}
};
expect(getModifyUserFailure(state, "ford")).toEqual(error);
});
it("should return undefined when modify user ford did not fail", () => {
expect(getModifyUserFailure({}, "ford")).toBe(undefined);
});
it("should return true, when delete user zaphod is pending", () => {
const state = {
pending: {
[DELETE_USER + "/zaphod"]: true
}
};
expect(isDeleteUserPending(state, "zaphod")).toEqual(true);
});
it("should return false, when delete user zaphod is not pending", () => {
expect(isDeleteUserPending({}, "zaphod")).toEqual(false);
});
it("should return error when delete user zaphod did fail", () => {
const state = {
failure: {
[DELETE_USER + "/zaphod"]: error
}
};
expect(getDeleteUserFailure(state, "zaphod")).toEqual(error);
});
it("should return undefined when delete user zaphod did not fail", () => {
expect(getDeleteUserFailure({}, "zaphod")).toBe(undefined);
});
});

View File

@@ -0,0 +1,12 @@
//@flow
import type { Links } from "../../types/hal";
export type User = {
displayName: string,
name: string,
mail: string,
password: string,
admin: boolean,
active: boolean,
_links: Links
};

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